feat(approach-c): close env-patch gap for install.sh tenants
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
This commit is contained in:
@@ -31,6 +31,9 @@ export interface ExecutionDriver {
|
||||
basePath: string,
|
||||
files: Array<{ relativePath: string; content: string }>
|
||||
): Promise<Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }>>;
|
||||
// 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<string, string>): Promise<{ patched: string[]; added: string[] }>;
|
||||
mkdir(basePath: string, relativePath: string): Promise<void>;
|
||||
fileExists(basePath: string, relativePath: string): Promise<boolean>;
|
||||
deleteDirectory(dirPath: string): Promise<void>;
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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<void> {
|
||||
await this.request({
|
||||
method: 'POST',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user