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:
parent
35175a7136
commit
bf997e84c1
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user