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:
bunker-admin 2026-05-22 19:18:37 -06:00
parent 35175a7136
commit bf997e84c1
6 changed files with 151 additions and 0 deletions

View File

@ -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<string, string>);
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'));

View File

@ -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<string, string>
): 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<void> {
const dirPath = path.join(basePath, relativePath);
assertWithin(dirPath, basePath);

View File

@ -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>;

View File

@ -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 });
}

View File

@ -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',

View File

@ -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,