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:
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user