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
116 lines
4.0 KiB
TypeScript
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);
|
|
});
|