scripts/audit_storage.ts
Rama main · réplica de lectura del repositorio original.
/*
* Audit storage consistency:
* - Checks every app_versions.r2_path (non-deleted) exists in R2
* - Checks every manifest.s3_path exists in R2
* - Outputs missing versions + app IDs with issues
*
* Usage: bun scripts/audit_storage.ts
*/
import { HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
import { createClient } from '@supabase/supabase-js'
import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts'
const ENV_FILE = './internal/cloudflare/.env.prod'
const OUTPUT_DIR = './tmp/r2_audit'
const VERSIONS_OUT = `${OUTPUT_DIR}/missing_versions.json`
const MANIFESTS_OUT = `${OUTPUT_DIR}/missing_manifests.json`
const APPS_OUT = `${OUTPUT_DIR}/apps_with_issues.json`
const SUMMARY_OUT = `${OUTPUT_DIR}/summary.json`
const STATE_OUT = `${OUTPUT_DIR}/state.json`
const PAGE_SIZE = 1000
const CONCURRENCY = 20000
const USE_LISTING = true
const R2_PREFIX = ''
const FILTER_PAID_TRIAL = true
const ORG_CHUNK = 2000
const VERSION_ID_CHUNK = 1000
const MANIFEST_COUNT_CHUNK = 1000
let versionsWithManifestEnabled = 0
let versionsWithMissingManifest = 0
let backupZipAvailable = 0
let missingVersionButManifestOk = 0
let missingVersionAndManifestMissing = 0
let brokenVersionCount = 0
let brokenAppCount = 0
let totalVersionsToCheck = 0
let totalManifestsToCheck = 0
function loadEnv(filePath: string) {
const envText = Bun.file(filePath).text()
return envText.then((text) => {
const env: Record<string, string> = {}
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const idx = trimmed.indexOf('=')
if (idx <= 0) continue
const key = trimmed.slice(0, idx)
const value = trimmed.slice(idx + 1)
env[key] = value
}
return env
})
}
function isMissingError(err: any) {
const status = err?.$metadata?.httpStatusCode
const name = err?.name
return status === 404 || name === 'NotFound' || name === 'NoSuchKey'
}
function normalizeKey(key: string) {
if (!key) return key
return key.startsWith('/') ? key.slice(1) : key
}
async function asyncPool<T, R>(limit: number, items: T[], iterator: (item: T) => Promise<R>) {
const ret: R[] = []
const executing: Promise<void>[] = []
for (const item of items) {
const p = iterator(item).then((result) => {
ret.push(result)
})
let e: Promise<void>
e = p.then(() => {
const idx = executing.indexOf(e)
if (idx >= 0) executing.splice(idx, 1)
})
executing.push(e)
if (executing.length >= limit) {
await Promise.race(executing)
}
}
await Promise.all(executing)
return ret
}
async function main() {
const env = await loadEnv(ENV_FILE)
const s3 = new S3Client({
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
endpoint: `https://${env.S3_ENDPOINT}`,
region: env.S3_REGION || 'auto',
forcePathStyle: true,
})
const bucket = env.S3_BUCKET || 'capgo'
const supabase = createClient<Database>(
env.SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
{ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false } },
)
await Bun.write(`${OUTPUT_DIR}/.gitkeep`, '')
const state = await loadState()
if (state) {
console.log(`Resuming from state: ${STATE_OUT}`)
if (state.phase === 'versions' && state.versions)
console.log(` versions: orgIndex=${state.versions.orgIndex}, offset=${state.versions.offset}`)
if (state.phase === 'manifests' && state.manifests)
console.log(` manifests: chunkIndex=${state.manifests.chunkIndex}, offset=${state.manifests.offset}`)
}
console.log('=== Storage Audit ===')
console.log(`Bucket: ${bucket}`)
console.log(`Supabase: ${env.SUPABASE_URL}`)
console.log(`Page size: ${PAGE_SIZE}, Concurrency: ${CONCURRENCY}`)
console.log(`Use listing: ${USE_LISTING ? 'yes' : 'no'}`)
console.log(`Filter paid/trial orgs: ${FILTER_PAID_TRIAL ? 'yes' : 'no'}`)
if (R2_PREFIX) console.log(`R2 prefix: ${R2_PREFIX}`)
let r2KeySet: Set<string> | null = null
if (USE_LISTING) {
console.log('\nListing R2 keys (this can take a while on very large buckets)...')
r2KeySet = await listAllKeysParallel(s3, bucket, R2_PREFIX)
console.log(` Loaded ${r2KeySet.size} keys into memory`)
}
const versionMap = new Map<string, {
app_id: string
owner_org: string | null
deleted: boolean
r2_path: string | null
manifest_count: number
}>()
let allowedOrgIds: string[] | null = null
if (FILTER_PAID_TRIAL) {
allowedOrgIds = await getPaidOrTrialOrgIds(supabase)
console.log(`\nPaid/trial orgs found: ${allowedOrgIds.length}`)
if (allowedOrgIds.length === 0) {
console.log('No paid/trial orgs found. Exiting.')
return
}
}
console.log('\nLoading app_versions map (filtered)...')
let totalVersionsLoaded = 0
const orgChunks = allowedOrgIds ? chunkArray(allowedOrgIds, ORG_CHUNK) : [null]
for (const orgChunk of orgChunks) {
let from = 0
while (true) {
const to = from + PAGE_SIZE - 1
let query = supabase
.from('app_versions')
.select('id, app_id, owner_org, deleted, r2_path, manifest_count')
.range(from, to)
if (orgChunk) query = query.in('owner_org', orgChunk)
const { data, error } = await query
if (error) {
console.error('Error fetching app_versions:', error)
process.exit(1)
}
if (!data || data.length === 0) break
for (const row of data) {
versionMap.set(row.id, {
app_id: row.app_id,
owner_org: row.owner_org,
deleted: row.deleted,
r2_path: row.r2_path,
manifest_count: row.manifest_count ?? 0,
})
}
totalVersionsLoaded += data.length
from += PAGE_SIZE
if (data.length < PAGE_SIZE) break
}
}
console.log(`Loaded ${totalVersionsLoaded} app_versions into map`)
const missingVersions: Array<{ id: string; app_id: string; owner_org: string | null; name: string; r2_path: string }> =
(await loadJsonArray(VERSIONS_OUT)) ?? []
const missingManifests: Array<{ id: string; app_version_id: string; app_id: string | null; owner_org: string | null; s3_path: string }> =
(await loadJsonArray(MANIFESTS_OUT)) ?? []
const appIdsWithIssues = new Set<string>(await loadJsonArray(APPS_OUT) ?? [])
let currentState: AuditState = state ?? {
phase: 'versions',
versions: { orgIndex: 0, offset: 0 },
manifests: { chunkIndex: 0, offset: 0 },
}
const handleExit = async (signal: string) => {
console.log(`\nReceived ${signal}, saving progress...`)
await saveState(currentState)
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
process.exit(signal === 'SIGINT' ? 130 : 1)
}
process.on('SIGINT', () => { void handleExit('SIGINT') })
process.on('SIGTERM', () => { void handleExit('SIGTERM') })
console.log('\nCounting total versions/manifests to check...')
for (const orgChunk of orgChunks) {
let query = supabase
.from('app_versions')
.select('id', { count: 'exact', head: true })
.eq('deleted', false)
.not('r2_path', 'is', null)
if (orgChunk) query = query.in('owner_org', orgChunk)
const { count, error } = await query
if (error) {
console.error('Error counting app_versions:', error)
process.exit(1)
}
totalVersionsToCheck += count ?? 0
}
const versionIds = Array.from(versionMap.keys())
const versionIdChunks = chunkArray(versionIds, VERSION_ID_CHUNK)
const manifestCountChunks = chunkArray(versionIds, MANIFEST_COUNT_CHUNK)
totalManifestsToCheck = 0
for (let i = 0; i < manifestCountChunks.length; i++) {
const idChunk = manifestCountChunks[i]
const { count, error } = await supabase
.from('manifest')
.select('id', { count: 'exact', head: true })
.in('app_version_id', idChunk)
.not('s3_path', 'is', null)
if (error) {
console.error(`Error counting manifest (chunk ${i + 1}/${manifestCountChunks.length}, size=${idChunk.length}):`, error)
process.exit(1)
}
totalManifestsToCheck += count ?? 0
}
console.log(`Total versions to check: ${totalVersionsToCheck}`)
console.log(`Total manifests to check: ${totalManifestsToCheck}`)
console.log('\nChecking app_versions.r2_path...')
let checkedVersions = 0
for (let orgIndex = state?.versions?.orgIndex ?? 0; orgIndex < orgChunks.length; orgIndex++) {
const orgChunk = orgChunks[orgIndex]
const orgLabel = orgChunk ? `${orgIndex + 1}/${orgChunks.length}` : 'all'
console.log(`\n[versions] Org chunk ${orgLabel} starting at offset ${orgIndex === (state?.versions?.orgIndex ?? 0) ? (state?.versions?.offset ?? 0) : 0}`)
let from = orgIndex === (state?.versions?.orgIndex ?? 0) ? (state?.versions?.offset ?? 0) : 0
while (true) {
const to = from + PAGE_SIZE - 1
let query = supabase
.from('app_versions')
.select('id, app_id, owner_org, name, r2_path, deleted')
.eq('deleted', false)
.not('r2_path', 'is', null)
.range(from, to)
if (orgChunk) query = query.in('owner_org', orgChunk)
const { data, error } = await query
if (error) {
console.error('Error fetching app_versions for audit:', error)
process.exit(1)
}
if (!data || data.length === 0) break
await asyncPool(CONCURRENCY, data, async (row) => {
const key = normalizeKey(row.r2_path as string)
try {
await assertExists(s3, bucket, key, r2KeySet)
}
catch (err) {
if (isMissingError(err)) {
missingVersions.push({
id: row.id,
app_id: row.app_id,
owner_org: row.owner_org,
name: row.name,
r2_path: key,
})
appIdsWithIssues.add(row.app_id)
}
else {
throw err
}
}
})
checkedVersions += data.length
const vPercent = totalVersionsToCheck > 0 ? Math.min(100, Math.round((checkedVersions / totalVersionsToCheck) * 100)) : 100
process.stdout.write(`\r [versions] ${checkedVersions}/${totalVersionsToCheck} (${vPercent}%) missing=${missingVersions.length} orgChunk=${orgLabel} offset=${from}`)
from += PAGE_SIZE
currentState = {
phase: 'versions',
versions: { orgIndex, offset: from },
manifests: state?.manifests ?? null,
}
await saveState(currentState)
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
if (data.length < PAGE_SIZE) break
}
}
process.stdout.write('\n')
console.log('\n=== Versions Check Summary ===')
console.log(`Total versions checked: ${checkedVersions}/${totalVersionsToCheck}`)
console.log(`Missing versions: ${missingVersions.length}`)
console.log(`Apps with issues so far: ${appIdsWithIssues.size}`)
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
console.log('\nChecking manifest.s3_path...')
let checkedManifests = 0
for (let chunkIndex = state?.manifests?.chunkIndex ?? 0; chunkIndex < versionIdChunks.length; chunkIndex++) {
const idChunk = versionIdChunks[chunkIndex]
console.log(`\n[manifests] Chunk ${chunkIndex + 1}/${versionIdChunks.length} starting at offset ${chunkIndex === (state?.manifests?.chunkIndex ?? 0) ? (state?.manifests?.offset ?? 0) : 0}`)
let from = chunkIndex === (state?.manifests?.chunkIndex ?? 0) ? (state?.manifests?.offset ?? 0) : 0
while (true) {
const to = from + PAGE_SIZE - 1
const { data, error } = await supabase
.from('manifest')
.select('id, app_version_id, s3_path')
.in('app_version_id', idChunk)
.not('s3_path', 'is', null)
.range(from, to)
if (error) {
console.error('Error fetching manifest for audit:', error)
process.exit(1)
}
if (!data || data.length === 0) break
await asyncPool(CONCURRENCY, data, async (row) => {
const key = normalizeKey(row.s3_path as string)
const versionInfo = versionMap.get(row.app_version_id)
try {
await assertExists(s3, bucket, key, r2KeySet)
}
catch (err) {
if (isMissingError(err)) {
missingManifests.push({
id: row.id,
app_version_id: row.app_version_id,
app_id: versionInfo?.app_id ?? null,
owner_org: versionInfo?.owner_org ?? null,
s3_path: key,
})
if (versionInfo?.app_id) appIdsWithIssues.add(versionInfo.app_id)
}
else {
throw err
}
}
})
checkedManifests += data.length
const mPercent = totalManifestsToCheck > 0 ? Math.min(100, Math.round((checkedManifests / totalManifestsToCheck) * 100)) : 100
process.stdout.write(`\r [manifests] ${checkedManifests}/${totalManifestsToCheck} (${mPercent}%) missing=${missingManifests.length} chunk=${chunkIndex + 1}/${versionIdChunks.length} offset=${from}`)
from += PAGE_SIZE
currentState = {
phase: 'manifests',
versions: { orgIndex: orgChunks.length, offset: 0 },
manifests: { chunkIndex, offset: from },
}
await saveState(currentState)
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
if (data.length < PAGE_SIZE) break
}
}
process.stdout.write('\n')
console.log('\n=== Manifests Check Summary ===')
console.log(`Total manifests checked: ${checkedManifests}/${totalManifestsToCheck}`)
console.log(`Missing manifests: ${missingManifests.length}`)
console.log(`Apps with issues so far: ${appIdsWithIssues.size}`)
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
// Extra manifest/backup stats
versionsWithManifestEnabled = Array.from(versionMap.values()).filter(v => (v.manifest_count ?? 0) > 0).length
const missingManifestVersionIds = new Set(missingManifests.map(m => m.app_version_id))
versionsWithMissingManifest = missingManifestVersionIds.size
const missingManifestVersions = Array.from(missingManifestVersionIds)
await asyncPool(CONCURRENCY, missingManifestVersions, async (versionId) => {
const info = versionMap.get(versionId)
if (!info?.r2_path) return
const key = normalizeKey(info.r2_path)
try {
await assertExists(s3, bucket, key, r2KeySet)
backupZipAvailable += 1
}
catch {
// ignore missing backup zips
}
})
console.log('\n=== Manifest Integrity / Backup Stats ===')
console.log(`Versions with manifest enabled: ${versionsWithManifestEnabled}`)
console.log(`Versions with missing manifest entries: ${versionsWithMissingManifest}`)
console.log(`Missing manifest rows (safe to delete from DB): ${missingManifests.length}`)
console.log(`Versions with missing manifest but zip still present: ${backupZipAvailable}`)
const missingVersionIds = new Set(missingVersions.map(v => v.id))
for (const versionId of missingVersionIds) {
const info = versionMap.get(versionId)
const hasManifestEnabled = (info?.manifest_count ?? 0) > 0
const hasMissingManifest = missingManifestVersionIds.has(versionId)
if (hasManifestEnabled && !hasMissingManifest) {
missingVersionButManifestOk += 1
}
else if (hasMissingManifest) {
missingVersionAndManifestMissing += 1
}
}
console.log('\n=== Missing Version Breakdown ===')
console.log(`Missing version zip but manifest OK: ${missingVersionButManifestOk}`)
console.log(`Missing version zip and manifest missing: ${missingVersionAndManifestMissing}`)
const brokenVersionIds = new Set<string>()
for (const versionId of missingVersionIds) {
const info = versionMap.get(versionId)
const hasManifestEnabled = (info?.manifest_count ?? 0) > 0
const hasMissingManifest = missingManifestVersionIds.has(versionId)
if (!hasManifestEnabled || hasMissingManifest) {
brokenVersionIds.add(versionId)
}
}
const brokenAppIds = new Set<string>()
for (const versionId of brokenVersionIds) {
const info = versionMap.get(versionId)
if (info?.app_id) brokenAppIds.add(info.app_id)
}
brokenVersionCount = brokenVersionIds.size
brokenAppCount = brokenAppIds.size
console.log('\n=== Totally Broken Versions ===')
console.log(`Totally broken versions (no usable zip or manifest): ${brokenVersionCount}`)
console.log(`Apps with totally broken versions: ${brokenAppCount}`)
const appsList = Array.from(appIdsWithIssues).sort()
const summary = {
generatedAt: new Date().toISOString(),
bucket,
counts: {
missingVersions: missingVersions.length,
missingManifests: missingManifests.length,
appsWithIssues: appsList.length,
},
}
await persistOutputs(missingVersions, missingManifests, appIdsWithIssues, bucket)
await Bun.write(SUMMARY_OUT, JSON.stringify(summary, null, 2))
await Bun.write(STATE_OUT, JSON.stringify({ phase: 'done' }, null, 2))
console.log('\n=== Done ===')
console.log(`Missing versions: ${missingVersions.length}`)
console.log(`Missing manifests: ${missingManifests.length}`)
console.log(`Apps with issues: ${appsList.length}`)
console.log(`Outputs:`)
console.log(` ${VERSIONS_OUT}`)
console.log(` ${MANIFESTS_OUT}`)
console.log(` ${APPS_OUT}`)
console.log(` ${SUMMARY_OUT}`)
}
async function listAllKeys(s3: S3Client, bucket: string, prefix: string) {
let continuationToken: string | undefined
const keys = new Set<string>()
let pages = 0
const startedAt = Date.now()
const label = prefix || 'all'
while (true) {
const response = await s3.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
}))
pages += 1
if (response.Contents) {
for (const obj of response.Contents) {
if (obj.Key) keys.add(obj.Key)
}
}
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
const rate = Math.round(keys.size / seconds)
process.stdout.write(`\r [listing] prefix=${label} pages=${pages} keys=${keys.size} rate=${rate}/s elapsed=${seconds}s`)
if (!response.IsTruncated) break
continuationToken = response.NextContinuationToken
}
if (pages >= 1) process.stdout.write('\n')
return keys
}
async function listAllKeysParallel(s3: S3Client, bucket: string, prefix: string) {
// If we can filter to paid/trial orgs, parallelize listing per org prefix.
const envFile = await Bun.file(ENV_FILE).text()
const env: Record<string, string> = {}
for (const line of envFile.split('\n')) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const eqIndex = trimmed.indexOf('=')
if (eqIndex > 0) env[trimmed.substring(0, eqIndex)] = trimmed.substring(eqIndex + 1)
}
}
const supabase = createClient<Database>(
env.SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
{ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false } },
)
if (!FILTER_PAID_TRIAL || prefix) {
return listAllKeys(s3, bucket, prefix)
}
const orgIds = await getPaidOrTrialOrgIds(supabase)
const prefixes = orgIds.map(id => `orgs/${id}/`)
const allKeys = new Set<string>()
let done = 0
const startedAt = Date.now()
await asyncPool(CONCURRENCY, prefixes, async (pfx) => {
const keys = await listAllKeys(s3, bucket, pfx)
for (const k of keys) allKeys.add(k)
done += 1
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
const rate = Math.round(done / seconds)
process.stdout.write(`\r [listing-parallel] orgs=${done}/${prefixes.length} rate=${rate}/s keys=${allKeys.size}`)
})
process.stdout.write('\n')
return allKeys
}
async function assertExists(s3: S3Client, bucket: string, key: string, keySet: Set<string> | null) {
if (keySet) {
if (!keySet.has(key)) throw { name: 'NotFound', $metadata: { httpStatusCode: 404 } }
return
}
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
}
async function getPaidOrTrialOrgIds(supabase: ReturnType<typeof createClient<Database>>) {
const paidOrTrial: string[] = []
let from = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
while (true) {
const to = from + PAGE_SIZE - 1
const { data, error } = await supabase
.from('orgs')
.select('id, customer_id, stripe_info(status, trial_at)')
.not('customer_id', 'is', null)
.range(from, to)
if (error) {
console.error('Error fetching orgs for paid/trial filter:', error)
process.exit(1)
}
if (!data || data.length === 0) break
for (const row of data) {
const stripeInfo = Array.isArray(row.stripe_info) ? row.stripe_info[0] : row.stripe_info
const status = stripeInfo?.status
const trialAt = stripeInfo?.trial_at ? new Date(stripeInfo.trial_at) : null
const isPaid = status === 'succeeded'
const isTrial = status !== 'succeeded' && trialAt !== null && trialAt >= today
if (isPaid || isTrial) paidOrTrial.push(row.id)
}
from += PAGE_SIZE
if (data.length < PAGE_SIZE) break
}
return paidOrTrial
}
function chunkArray<T>(items: T[], size: number) {
const chunks: T[][] = []
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size))
}
return chunks
}
async function loadJsonArray<T>(path: string): Promise<T[] | null> {
try {
const text = await Bun.file(path).text()
return JSON.parse(text)
}
catch {
return null
}
}
async function persistOutputs(
missingVersions: Array<{ id: string; app_id: string; owner_org: string | null; name: string; r2_path: string }>,
missingManifests: Array<{ id: string; app_version_id: string; app_id: string | null; owner_org: string | null; s3_path: string }>,
appIdsWithIssues: Set<string>,
bucket: string,
) {
await Bun.write(VERSIONS_OUT, JSON.stringify(missingVersions, null, 2))
await Bun.write(MANIFESTS_OUT, JSON.stringify(missingManifests, null, 2))
await Bun.write(APPS_OUT, JSON.stringify(Array.from(appIdsWithIssues).sort(), null, 2))
const summary = {
generatedAt: new Date().toISOString(),
bucket,
counts: {
missingVersions: missingVersions.length,
missingManifests: missingManifests.length,
appsWithIssues: appIdsWithIssues.size,
versionsToCheck: totalVersionsToCheck,
manifestsToCheck: totalManifestsToCheck,
versionsWithManifestEnabled,
versionsWithMissingManifest,
missingManifestRows: missingManifests.length,
versionsWithMissingManifestButZipPresent: backupZipAvailable,
missingVersionButManifestOk,
missingVersionAndManifestMissing,
totallyBrokenVersions: brokenVersionCount,
appsWithTotallyBrokenVersions: brokenAppCount,
},
}
await Bun.write(SUMMARY_OUT, JSON.stringify(summary, null, 2))
}
type AuditState = {
phase: 'versions' | 'manifests' | 'done'
versions: { orgIndex: number; offset: number } | null
manifests: { chunkIndex: number; offset: number } | null
}
async function loadState(): Promise<AuditState | null> {
try {
const text = await Bun.file(STATE_OUT).text()
return JSON.parse(text)
}
catch {
return null
}
}
async function saveState(state: AuditState) {
await Bun.write(STATE_OUT, JSON.stringify(state, null, 2))
}
await main()