From bf997e84c1923f41649ef26cce76be4c69c0dd93 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Fri, 22 May 2026 19:18:37 -0600 Subject: [PATCH] feat(approach-c): close env-patch gap for install.sh tenants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach C persists imageTag in Instance.imageTag and renders the full .env for CCP-provisioned tenants. For install.sh-registered tenants (isRegistered=true, no encryptedSecrets), the .env was filtered out of the rendered file set — so the new imageTag never reached the tenant's compose, leaving install.sh tenants unable to bump image versions via Approach C. Closes the gap with an in-place .env key patch: - agent/services/file.service.ts: patchEnv(basePath, vars) — reads .env, finds existing keys and replaces values, appends unknown keys at end under a "# Added by CCP env-patch" comment. Preserves comments and unrelated keys. Validates ENV_KEY_RE + rejects newlines in values. - agent/routes/files.routes.ts: POST /instance/:slug/env/patch. - api/services/execution-driver.ts: patchEnv added to interface. - api/services/local-driver.ts + remote-driver.ts: patchEnv methods. - api/services/upgrade.service.ts:runReleaseUpgrade — for isRegistered tenants with newImageTag, calls driver.patchEnv({ IMAGE_TAG }) after writeFiles and before composePull. Non-fatal on failure (logs warn). This makes Approach C functional for the existing install.sh fleet (marcelle, linda, pia + future). CCP-provisioned tenants still get the full .env render — unchanged behavior. All three projects type-check cleanly. Bunker Admin --- .../agent/src/routes/files.routes.ts | 14 ++++ .../agent/src/services/file.service.ts | 72 +++++++++++++++++++ .../api/src/services/execution-driver.ts | 3 + .../api/src/services/local-driver.ts | 32 +++++++++ .../api/src/services/remote-driver.ts | 11 +++ .../api/src/services/upgrade.service.ts | 19 +++++ 6 files changed, 151 insertions(+) diff --git a/changemaker-control-panel/agent/src/routes/files.routes.ts b/changemaker-control-panel/agent/src/routes/files.routes.ts index dcb83c1..0cee618 100644 --- a/changemaker-control-panel/agent/src/routes/files.routes.ts +++ b/changemaker-control-panel/agent/src/routes/files.routes.ts @@ -37,6 +37,20 @@ router.post('/instance/:slug/files/diff', async (req: Request, res: Response) => res.json({ files: results }); }); +// POST /instance/:slug/env/patch — Approach C: patch specific .env keys in place. +// Used for isRegistered=true tenants where CCP can't re-render the full .env +// but needs to update IMAGE_TAG / other values from instance.imageTag etc. +router.post('/instance/:slug/env/patch', async (req: Request, res: Response) => { + const entry = await getSlugEntry(param(req, 'slug')); + const { vars } = req.body; + if (!vars || typeof vars !== 'object' || Array.isArray(vars)) { + res.status(400).json({ error: 'VALIDATION', message: 'vars object required' }); + return; + } + const result = await fileService.patchEnv(entry.basePath, vars as Record); + res.json(result); +}); + // POST /instance/:slug/mkdir — Create directory router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => { const entry = await getSlugEntry(param(req, 'slug')); diff --git a/changemaker-control-panel/agent/src/services/file.service.ts b/changemaker-control-panel/agent/src/services/file.service.ts index ea9d879..2ec34d2 100644 --- a/changemaker-control-panel/agent/src/services/file.service.ts +++ b/changemaker-control-panel/agent/src/services/file.service.ts @@ -142,6 +142,78 @@ export async function diffFiles( return results; } +/** + * Patch specific keys in the tenant's .env file in place. Used by Approach C + * upgrade for install.sh tenants where CCP can't re-render the full .env + * (no encryptedSecrets in DB) but still needs to update Instance.imageTag- + * derived values like IMAGE_TAG. Preserves comments, blank lines, and key + * order; replaces existing keys, appends new ones at the end. + * + * Keys are validated against ENV_KEY_RE; values are written verbatim + * (no shell escaping beyond what dotenv expects — newlines in values + * are rejected to prevent .env smuggling). + */ +const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; + +export async function patchEnv( + basePath: string, + vars: Record +): Promise<{ patched: string[]; added: string[] }> { + const envPath = path.join(basePath, '.env'); + assertWithin(envPath, basePath); + + // Validate inputs before touching disk + for (const [k, v] of Object.entries(vars)) { + if (!ENV_KEY_RE.test(k)) { + throw new AgentError(400, `Invalid env key: ${k}`, 'VALIDATION'); + } + if (/[\r\n]/.test(v)) { + throw new AgentError(400, `env value for ${k} contains newline`, 'VALIDATION'); + } + } + + let current = ''; + try { + current = await fs.readFile(envPath, 'utf-8'); + } catch (err) { + throw new AgentError(404, `.env not found at ${envPath}`, 'NOT_FOUND'); + } + + const lines = current.split('\n'); + const patched: string[] = []; + const remaining = new Set(Object.keys(vars)); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Match KEY=... (allowing leading whitespace? .env conventionally doesn't but be defensive) + const m = line.match(/^([A-Z_][A-Z0-9_]*)=/); + if (!m) continue; + const key = m[1]; + if (remaining.has(key)) { + lines[i] = `${key}=${vars[key]}`; + patched.push(key); + remaining.delete(key); + } + } + + // Append any keys that didn't exist in the file + const added: string[] = []; + if (remaining.size > 0) { + // Trim trailing blank lines to avoid accumulating empties on repeated patches + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + lines.push('', '# Added by CCP env-patch'); + for (const k of remaining) { + lines.push(`${k}=${vars[k]}`); + added.push(k); + } + lines.push(''); // trailing newline + } + + await fs.writeFile(envPath, lines.join('\n'), 'utf-8'); + logger.info(`[files] env-patch ${envPath}: patched=${patched.length} added=${added.length}`); + return { patched, added }; +} + export async function mkdirp(basePath: string, relativePath: string): Promise { const dirPath = path.join(basePath, relativePath); assertWithin(dirPath, basePath); diff --git a/changemaker-control-panel/api/src/services/execution-driver.ts b/changemaker-control-panel/api/src/services/execution-driver.ts index e99f56e..ecda257 100644 --- a/changemaker-control-panel/api/src/services/execution-driver.ts +++ b/changemaker-control-panel/api/src/services/execution-driver.ts @@ -31,6 +31,9 @@ export interface ExecutionDriver { basePath: string, files: Array<{ relativePath: string; content: string }> ): Promise>; + // Approach C: patch specific .env keys in place (install.sh-tenant path + // where CCP can't re-render the full .env). + patchEnv(basePath: string, vars: Record): Promise<{ patched: string[]; added: string[] }>; mkdir(basePath: string, relativePath: string): Promise; fileExists(basePath: string, relativePath: string): Promise; deleteDirectory(dirPath: string): Promise; diff --git a/changemaker-control-panel/api/src/services/local-driver.ts b/changemaker-control-panel/api/src/services/local-driver.ts index 435adb9..d3c8721 100644 --- a/changemaker-control-panel/api/src/services/local-driver.ts +++ b/changemaker-control-panel/api/src/services/local-driver.ts @@ -109,6 +109,38 @@ export class LocalDriver implements ExecutionDriver { return results; } + // Approach C: patch specific .env keys in place (local mirror of the + // agent-side patchEnv helper). Preserves comments and key order. + async patchEnv(basePath: string, vars: Record) { + const envPath = path.join(basePath, '.env'); + const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; + for (const [k, v] of Object.entries(vars)) { + if (!ENV_KEY_RE.test(k)) throw new Error(`Invalid env key: ${k}`); + if (/[\r\n]/.test(v)) throw new Error(`env value for ${k} contains newline`); + } + const current = await fs.readFile(envPath, 'utf-8'); + const lines = current.split('\n'); + const patched: string[] = []; + const remaining = new Set(Object.keys(vars)); + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^([A-Z_][A-Z0-9_]*)=/); + if (m && remaining.has(m[1])) { + lines[i] = `${m[1]}=${vars[m[1]]}`; + patched.push(m[1]); + remaining.delete(m[1]); + } + } + const added: string[] = []; + if (remaining.size > 0) { + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + lines.push('', '# Added by CCP env-patch'); + for (const k of remaining) { lines.push(`${k}=${vars[k]}`); added.push(k); } + lines.push(''); + } + await fs.writeFile(envPath, lines.join('\n'), 'utf-8'); + return { patched, added }; + } + async mkdir(basePath: string, relativePath: string) { await fs.mkdir(path.join(basePath, relativePath), { recursive: true }); } diff --git a/changemaker-control-panel/api/src/services/remote-driver.ts b/changemaker-control-panel/api/src/services/remote-driver.ts index 4f4f100..73ad83d 100644 --- a/changemaker-control-panel/api/src/services/remote-driver.ts +++ b/changemaker-control-panel/api/src/services/remote-driver.ts @@ -320,6 +320,17 @@ export class RemoteDriver implements ExecutionDriver { return resp.files; } + // Approach C: patch specific .env keys in place via agent. Used for + // isRegistered=true tenants where CCP can't re-render the full .env. + async patchEnv(_basePath: string, vars: Record) { + return this.request<{ patched: string[]; added: string[] }>({ + method: 'POST', + path: `/instance/${this.slug}/env/patch`, + body: { vars }, + timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS, + }); + } + async mkdir(_basePath: string, relativePath: string): Promise { await this.request({ method: 'POST', diff --git a/changemaker-control-panel/api/src/services/upgrade.service.ts b/changemaker-control-panel/api/src/services/upgrade.service.ts index 9b843d0..9350a26 100644 --- a/changemaker-control-panel/api/src/services/upgrade.service.ts +++ b/changemaker-control-panel/api/src/services/upgrade.service.ts @@ -623,6 +623,25 @@ async function runReleaseUpgrade( }); await driver.writeFiles(instance.basePath, files); + // For isRegistered=true tenants we skip rendering .env (no secrets in DB). + // But we still need to propagate the new imageTag into their existing .env + // so compose's ${IMAGE_TAG:-latest} substitution picks it up. Patch in place. + if (!refreshed.encryptedSecrets && newImageTag) { + await updateStatus({ + currentPhase: 2, + phaseName: 'Patch Env', + percentage: 45, + progressMessage: `Patching IMAGE_TAG=${newImageTag} in tenant .env...`, + }); + try { + await driver.patchEnv(instance.basePath, { IMAGE_TAG: newImageTag }); + } catch (err) { + // Non-fatal but loud: the tenant may already have the desired tag + // in .env, or env-patch isn't supported on their agent. + logger.warn(`[release-upgrade] ${slug}: env patch failed: ${(err as Error).message}`); + } + } + // Phase 3: pull images per new compose await updateStatus({ currentPhase: 3,