bunker-admin 97444645cb chore(approach-c): Phase 0 complete - templates byte-equivalent to canonical
This commit completes Phase 0 of Approach C: the CCP template/env/static
files now produce output structurally byte-identical to canonical
docker-compose.prod.yml + .env.example. Verified by rendering against
marcelle, linda, and pia and diffing against their actual files — all
three show only the 30-line CCP-tenant header comment differing,
zero service/env-var structural differences.

Changes:

- templates/docker-compose.yml.hbs: reverted {{imageTag}} substitutions
  back to ${IMAGE_TAG:-latest} so the compose template is now byte-
  equivalent to docker-compose.prod.yml (modulo header). CCP controls
  per-instance image tag selection via the rendered .env's IMAGE_TAG,
  which compose-up picks up at runtime. This single-source-of-truth
  via env-substitution matches install.sh tenants exactly.

- templates/env.hbs: rewritten as a near-mirror of .env.example. Adds
  27 missing keys (IMAGE_TAG, GITEA_REGISTRY, COMPOSE_PROFILES,
  ENABLE_CCP_AGENT, GITEA_ADMIN_*, ENABLE_HLS_TRANSCODE, TZ, etc.)
  plus 15 CCP-specific extras (embed ports, dev-mode helpers, etc.).
  All 145 compose-template env-var references are now covered.

- templates/nginx/nginx.conf: synced from canonical. Includes recent
  security additions: redacted access-log format for token/secret
  query params, rate-limit zones (api_global, api_auth, upload),
  conditional HSTS via X-Forwarded-Proto map.

- api/scripts/render-for-instance.ts (new): one-off CLI that loads
  an Instance row, decrypts secrets if present (or uses empty object
  for isRegistered=true tenants), and calls renderAllTemplates() to
  a scratch dir. Used in Phase 0.4 to verify the template-vs-prod
  contract per tenant.

  Usage:
    docker compose exec ccp-api npx tsx scripts/render-for-instance.ts \
      --slug changemakerlite

Phase 0 acceptance gate met:
  - marcelle (release v2.10.2 install): 30-line diff, header-only
  - linda (release v2.9.14 install):    30-line diff, header-only
  - pia (release v2.9.10 install):      30-line diff, header-only
  - env.hbs key coverage: 0 missing vs marcelle's .env

Next phases unblocked:
  - Phase 1: add Instance.imageTag column (Prisma migration)
  - Phase 2: pre-flight diff endpoint
  - Phase 3: startReleaseUpgrade runner
  - Phase 4: routes + schemas
  - Phase 5: CCP UI "Upgrade to Release" button
  - Phase 6: E2E test on marcelle (v2.10.2 -> v2.10.3)

Bunker Admin
2026-05-22 09:35:30 -06:00

116 lines
4.0 KiB
TypeScript

#!/usr/bin/env tsx
/**
* render-for-instance.ts — Approach C Phase 0 verification harness.
*
* Loads a CCP-tracked Instance row, builds its template context, and renders
* all templates to a scratch directory under /tmp/render-<slug>/. Operator
* then diffs the rendered output against the tenant's actual on-disk files
* to verify the template-vs-prod-compose equivalence contract.
*
* Usage (run inside ccp-api container):
* docker compose exec ccp-api npx tsx scripts/render-for-instance.ts --slug changemakerlite
* docker compose exec ccp-api npx tsx scripts/render-for-instance.ts --id <uuid>
*
* Output: prints scratch dir path; exits 0 on success, 1 on failure.
*
* This script does NOT touch any tenant. It only reads from the CCP database
* and writes to /tmp on the CCP api container.
*/
import { prisma } from '../src/lib/prisma';
import { decryptJson } from '../src/utils/encryption';
import {
buildTemplateContext,
renderAllTemplates,
} from '../src/services/template-engine';
import path from 'node:path';
import fs from 'node:fs/promises';
interface Args {
slug?: string;
id?: string;
outDir?: string;
}
function parseArgs(argv: string[]): Args {
const args: Args = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--slug' && argv[i + 1]) { args.slug = argv[++i]; continue; }
if (a === '--id' && argv[i + 1]) { args.id = argv[++i]; continue; }
if (a === '--out' && argv[i + 1]) { args.outDir = argv[++i]; continue; }
if (a === '-h' || a === '--help') {
console.log('usage: render-for-instance.ts (--slug X | --id Y) [--out /tmp/render-X]');
process.exit(0);
}
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.slug && !args.id) {
console.error('error: --slug or --id is required');
process.exit(1);
}
const instance = await prisma.instance.findUnique({
where: args.id ? { id: args.id } : { slug: args.slug! },
});
if (!instance) {
console.error(`error: instance not found (slug=${args.slug ?? '?'}, id=${args.id ?? '?'})`);
process.exit(1);
}
// For isRegistered tenants there are no encrypted secrets. Use empty stubs
// so buildTemplateContext doesn't crash; env.hbs values that read from
// {{secrets.*}} will render as blank, which is fine for diff purposes
// because the tenant's own .env still has the real values via install.sh.
let secrets: Record<string, string> = {};
if (instance.encryptedSecrets) {
try {
secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
} catch (err) {
console.warn(`warn: decryptJson failed (${(err as Error).message}); using empty secrets`);
}
} else {
console.log(`(isRegistered=true tenant; using empty secrets for compose/nginx render — env.hbs values will be blank)`);
}
const outDir = args.outDir ?? path.join('/tmp', `render-${instance.slug}`);
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
const context = buildTemplateContext(instance, secrets);
await renderAllTemplates(context, outDir);
// Summarize what we rendered
const entries: string[] = [];
async function walk(dir: string, rel = '') {
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
const full = path.join(dir, item.name);
const r = path.join(rel, item.name);
if (item.isDirectory()) await walk(full, r);
else entries.push(r);
}
}
await walk(outDir);
console.log(`\n=== rendered ${entries.length} files to: ${outDir} ===`);
for (const e of entries.sort()) {
const stat = await fs.stat(path.join(outDir, e));
console.log(` ${e} (${stat.size} bytes)`);
}
console.log(`\nTo diff against the live tenant:`);
console.log(` ssh <tenant> 'cat <basePath>/docker-compose.yml' | diff -u - ${outDir}/docker-compose.yml`);
console.log(``);
await prisma.$disconnect();
}
main().catch((err) => {
console.error('render-for-instance.ts failed:', err);
process.exit(1);
});