teqo-backup/scripts/ops-global.mjs
Francisco Solla b1a721418b feat(ops): CLI ops:global para flags de globais públicos (C247)
Grava um único campo do global via Local API e busta a tag no processo Next vivo pelo POST /api/revalidate (allowlist ampliada com REVALIDATE_PHOTO_ALBUM_CACHE_TAG), com guardas de escrita, plan/apply/verify, recibo com rollback e int pin do update parcial.
2026-10-02 09:47:26 -03:00

258 lines
8.4 KiB
JavaScript

/**
* C247 — ops CLI to flip an operational public global outside the admin:
*
* pnpm ops:global photoAlbum --selfie-search on
*
* Why this exists: the global's `afterChange` revalidates its `unstable_cache`
* tag only inside the deployed Next process, so a CLI/DB write left the public
* cache frozen and the ops had to click the admin to open/close the selfie
* search (C242 friction). This CLI writes through the Local API and busts the
* tag on the live server through the allowlisted `POST /api/revalidate`.
*
* Modes:
* (sem flag) plan/dry-run (default) — previous → new; no write
* --apply writes the single field and busts the tag;
* requires OPS_GLOBAL_CONFIRM=1 outside local
* dev and TEQO_ENV matching the exact database
* --verify rereads the flag; exit 1 when it diverges
*
* The write surface is the closed table in `scripts/lib/opsGlobal.mjs` — a
* flag only becomes writable by editing it there. The receipt records the
* previous/new value and the rollback (the same command with the previous
* value); the endpoint secret never enters the receipt or the log.
*
* Runbook recipe: `docs/ops/teqo-1313-deploy.md` (C247).
*/
import { join } from 'node:path'
import { getPayload } from 'payload'
import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts'
import { getGlobalCacheTag } from '../src/utilities/globals.ts'
import { resolveRevalidateTag } from '../src/utilities/revalidateRequest.ts'
import {
TEQO_ENV_DATABASE_BY_ENV,
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
writeRepoFile,
} from './lib/cli.mjs'
import {
OPS_GLOBAL_DEFAULT_OUT_DIR,
OPS_GLOBAL_WRITE_CONFIRM_FLAG,
buildOpsGlobalWrite,
formatOpsGlobalCommand,
formatOpsGlobalOperations,
formatOpsGlobalRecoveryCommand,
formatOpsGlobalReport,
opsGlobalReportStamp,
parseOpsGlobalCliArgs,
} from './lib/opsGlobal.mjs'
loadCliEnv()
const die = dieWithLabel('ops:global')
const BUST_TIMEOUT_MS = 15_000
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
.map(([environment, database]) => `${environment} (${database})`)
.join(' | ')
const HELP = `
Uso: pnpm ops:global <global> --<flag> <on|off> [opções]
Liga/desliga uma flag operacional de global público pela CLI, gravando pelo
Local API e revalidando a tag no servidor vivo (sem restart e sem admin).
Modos:
(sem flag) plano/dry-run — valor anterior → novo; sem escrita
--apply grava e revalida (exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 fora do dev local)
--verify relê a flag; exit 1 se divergir do valor pedido
Operações (tabela fechada):
${formatOpsGlobalOperations().join('\n')}
Ambiente:
DATABASE_URL alvo (obrigatório em todos os modos)
NEXT_PUBLIC_SITE_URL / REVALIDATE_SECRET exigidos no --apply (alvo da revalidação)
--apply em alvo não-local/produção exige ${OPS_GLOBAL_WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
casando o banco exato. Plano e verify são read-only.
`
const writeReport = async (report) => {
const stamp = opsGlobalReportStamp(report.runAt)
const relativePath = join(OPS_GLOBAL_DEFAULT_OUT_DIR, 'reports', `ops-global-${stamp}.json`)
await writeRepoFile({
label: 'ops:global',
root: process.cwd(),
relativePath,
body: JSON.stringify(report, null, 2),
})
return join(process.cwd(), relativePath)
}
/**
* Bust the global's tag on the live server; a failure is returned, never
* silent. The same shape as the Instagram import helper — except here the
* caller fails the run on `ok: false`, because the bust is the outcome.
*/
const revalidateGlobalOnServer = async (tag) => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { ok: false, tag, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const response = await fetch(`${baseUrl}/api/revalidate?tag=${encodeURIComponent(tag)}`, {
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(BUST_TIMEOUT_MS),
})
return response.ok
? { ok: true, tag, reason: null }
: { ok: false, tag, reason: `HTTP ${response.status}` }
} catch (error) {
return {
ok: false,
tag,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}
const printReport = (report, reportPath) => {
for (const line of formatOpsGlobalReport(report)) console.log(line)
console.log(`\n[ops:global] recibo JSON: ${reportPath}`)
}
async function main() {
const options = parseOpsGlobalCliArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const target = databaseTarget()
const tag = getGlobalCacheTag(options.slug)
console.log(`[ops:global] alvo: ${target} | modo: ${options.mode}`)
if (options.mode === 'apply') {
assertWriteConfirm({
label: 'ops:global',
flag: OPS_GLOBAL_WRITE_CONFIRM_FLAG,
command: formatOpsGlobalCommand(options.slug, options.operation, options.value),
})
assertEnvironmentDatabaseTarget()
if (!process.env.NEXT_PUBLIC_SITE_URL?.trim() || !process.env.REVALIDATE_SECRET?.trim()) {
die(
'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes — sem eles não há como revalidar a tag no servidor vivo. Rode no ambiente do alvo (env file do stack).',
)
}
// Fail-closed BEFORE the write: a tag outside the endpoint allowlist would
// only surface after the global had already changed.
const allowed = resolveRevalidateTag(tag, null)
if (!allowed.ok) {
die(`tag "${tag}" fora da allowlist do /api/revalidate — ${allowed.error}.`)
}
}
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const startedAt = Date.now()
const currentDoc = await payload.findGlobal({
slug: options.slug,
depth: 0,
// Intentional bypass: the ops CLI is a trusted operator with no session.
overrideAccess: true,
})
const write = buildOpsGlobalWrite({
slug: options.slug,
operation: options.operation,
value: options.value,
currentDoc,
})
const baseReport = {
runAt: new Date().toISOString(),
mode: options.mode,
target,
slug: write.slug,
operation: write.operation,
field: write.field,
previousValue: write.previousValue,
newValue: write.newValue,
changed: write.changed,
rollbackCommand: write.rollbackCommand,
}
if (options.mode !== 'apply') {
const report = {
...baseReport,
write: 'none',
revalidation: null,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(report)
printReport(report, reportPath)
process.exit(options.mode === 'verify' && write.changed ? 1 : 0)
}
if (
write.changed &&
write.field === 'published' &&
write.newValue === true &&
!isArchivePhotoRemovalChannelUrl(currentDoc?.removalChannelUrl)
) {
die(
'canal de remoção não configurado no global Álbum de fotos — configure-o pelo admin antes de publicar (o hook fail-closed recusaria a escrita).',
)
}
if (write.changed) {
await payload.updateGlobal({
slug: options.slug,
data: write.data,
depth: 0,
// Intentional bypass: same trusted operator.
overrideAccess: true,
})
}
const revalidation = await revalidateGlobalOnServer(tag)
const report = {
...baseReport,
write: write.changed ? 'applied' : 'skipped',
revalidation,
durationMs: Date.now() - startedAt,
}
const reportPath = await writeReport(report)
printReport(report, reportPath)
if (!revalidation.ok) {
die(
`global ${write.changed ? 'atualizado' : 'já estava no valor pedido'}, mas a revalidação da tag falhou (${revalidation.reason}).\n` +
`Recupere manualmente:\n ${formatOpsGlobalRecoveryCommand({
baseUrl: process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '') ?? '',
tag,
})}\n` +
`Rollback: ${write.rollbackCommand}\n` +
`Recibo: ${reportPath}`,
)
}
process.exit(0)
}
main().catch((error) => {
die(error?.message || String(error))
})