Adds the third upgrade path alongside Approach A (full upgrade.sh) and B
(image-only). For releases that change orchestration (new services, new
nginx routes, new compose env vars) in addition to image versions, CCP
re-renders templates server-side, sends the rendered files to the tenant
via the existing mTLS agent, then composePull + composeUp. Tenant content
(mkdocs/, custom configs/) is never touched.
Pieces:
PHASE 1 — Schema + per-instance imageTag
- prisma/schema.prisma: new Instance.imageTag column (NULL = fall back
to env.IMAGE_TAG default).
- prisma/migrations/20260522093400_add_instance_image_tag/: SQL.
- services/template-engine.ts:
- buildTemplateContext now uses instance.imageTag || env.IMAGE_TAG.
- InstanceForTemplate interface gains imageTag: string | null.
PHASE 2 — Pre-flight diff (read-only "what would change?")
- agent/services/file.service.ts: new diffFiles() helper with a small
inline LCS-based unified-diff (no new deps). Returns per-file status
('unchanged' | 'modified' | 'created') + truncated unified diff.
- agent/routes/files.routes.ts: POST /instance/:slug/files/diff.
- api/services/execution-driver.ts: diffFiles added to interface.
- api/services/local-driver.ts + remote-driver.ts: diffFiles methods
(local mirrors agent helper inline; remote POSTs to the agent endpoint).
- api/services/upgrade.service.ts: previewReleaseUpgrade() — renders
templates in-memory with the proposed imageTag, filters out .env for
isRegistered=true tenants, calls driver.diffFiles, computes envCoverage
(which env vars the new compose needs vs which the tenant's .env has).
PHASE 3 — Apply path (the actual upgrade)
- api/services/upgrade.service.ts: startReleaseUpgrade() and the inner
runReleaseUpgrade() runner. Distinct from runRemoteUpgrade because CCP
does the work directly via the mTLS driver (no agent-side script).
Flow: persist imageTag in DB → render → writeFiles → composePull →
composeUp → composePs verify. Status reported via InstanceUpgrade
rows (same shape the existing CCP polling UI already uses).
- Failure handling: instance.imageTag stays at the new value on failure
so operator can retry. Manual rollback only.
PHASE 4 — Routes + schemas
- instances.schemas.ts: startReleaseUpgradeSchema (imageTag regex).
- instances.routes.ts:
- POST /:id/upgrade-release (apply)
- POST /:id/upgrade-release/preview (read-only diff)
PHASE 5 — CCP admin UI
- admin/pages/InstanceDetailPage.tsx: third "Upgrade to Release" button
next to Quick Upgrade + Upgrade Now. Opens a modal with imageTag input,
Preview button (calls /preview), and Apply button. Preview modal shows:
- Red alert if envCoverage.missingInTenantEnv is non-empty (compose
needs vars the tenant's .env doesn't define).
- Per-file status tags (unchanged / modified / created) + truncated
unified diff for modified files.
- admin/types/api.ts: Instance.imageTag added.
Constraints applied:
- Remote-only initial scope: throws "currently supported only for remote
instances" if instance.isRemote === false.
- isRegistered=true tenants (install.sh fleet): .env is filtered out
of the render set (CCP can't render env without secrets in DB), the
tenant's existing .env stays as-is. envCoverage warns the operator
if the new compose references env vars their .env doesn't define.
- Shared in-progress guard with Approach A/B (one upgrade at a time).
Per the plan: see ~/.claude/plans/insight-temporal-bachman.md.
All three projects type-check cleanly (api, agent, admin).
Bunker Admin
160 lines
6.4 KiB
TypeScript
160 lines
6.4 KiB
TypeScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { promisify } from 'util';
|
|
import { parse as parseDotenv } from 'dotenv';
|
|
import * as docker from './docker.service';
|
|
import type { ExecutionDriver } from './execution-driver';
|
|
import { logger } from '../utils/logger';
|
|
|
|
/**
|
|
* LocalDriver wraps existing docker.service.ts functions and filesystem operations.
|
|
* This is a zero-behavior-change adapter — all existing local instance operations
|
|
* pass through unchanged.
|
|
*/
|
|
export class LocalDriver implements ExecutionDriver {
|
|
// ─── Docker Compose Operations ──────────────────────────────
|
|
|
|
composeUp(projectDir: string, project: string, services?: string[]) {
|
|
return docker.composeUp(projectDir, project, services);
|
|
}
|
|
|
|
composeDown(projectDir: string, project: string, removeVolumes?: boolean) {
|
|
return docker.composeDown(projectDir, project, removeVolumes);
|
|
}
|
|
|
|
composeStop(projectDir: string, project: string) {
|
|
return docker.composeStop(projectDir, project);
|
|
}
|
|
|
|
composeRestart(projectDir: string, project: string, service?: string) {
|
|
return docker.composeRestart(projectDir, project, service);
|
|
}
|
|
|
|
composePull(projectDir: string, project: string) {
|
|
return docker.composePull(projectDir, project);
|
|
}
|
|
|
|
composeBuild(projectDir: string, project: string) {
|
|
return docker.composeBuild(projectDir, project);
|
|
}
|
|
|
|
composePs(projectDir: string, project: string) {
|
|
return docker.composePs(projectDir, project);
|
|
}
|
|
|
|
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string) {
|
|
return docker.composeLogs(projectDir, project, service, tail, since);
|
|
}
|
|
|
|
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>) {
|
|
return docker.composeExec(projectDir, project, service, command, timeoutMs, envVars);
|
|
}
|
|
|
|
// ─── Container Health ───────────────────────────────────────
|
|
|
|
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number) {
|
|
return docker.waitForHealthy(containerName, timeoutMs, pollIntervalMs);
|
|
}
|
|
|
|
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number) {
|
|
return docker.waitForHttp(url, timeoutMs, pollIntervalMs);
|
|
}
|
|
|
|
// ─── Filesystem Operations ──────────────────────────────────
|
|
|
|
async readEnvFile(basePath: string): Promise<Record<string, string> | null> {
|
|
try {
|
|
const content = await fs.readFile(path.join(basePath, '.env'), 'utf-8');
|
|
return parseDotenv(Buffer.from(content));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
|
|
for (const file of files) {
|
|
const filePath = path.join(basePath, file.relativePath);
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, file.content, 'utf-8');
|
|
logger.debug(`[local-driver] Wrote ${filePath}`);
|
|
}
|
|
}
|
|
|
|
// Approach C pre-flight diff. Reads current file contents at basePath +
|
|
// relativePath, returns per-file status + diff. Local implementation
|
|
// mirrors the agent-side diffFiles helper.
|
|
async diffFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
|
|
const results: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }> = [];
|
|
for (const file of files) {
|
|
const filePath = path.join(basePath, file.relativePath);
|
|
const sizeAfter = Buffer.byteLength(file.content, 'utf-8');
|
|
let current: string | null = null;
|
|
try { current = await fs.readFile(filePath, 'utf-8'); } catch { current = null; }
|
|
if (current === null) {
|
|
results.push({ path: file.relativePath, status: 'created', diff: null, sizeBefore: 0, sizeAfter });
|
|
} else if (current === file.content) {
|
|
results.push({ path: file.relativePath, status: 'unchanged', diff: null, sizeBefore: Buffer.byteLength(current), sizeAfter });
|
|
} else {
|
|
// Minimal diff for local: full new content. Local mode is dev-only;
|
|
// detailed diffs come from the agent-side implementation.
|
|
results.push({
|
|
path: file.relativePath,
|
|
status: 'modified',
|
|
diff: `--- a/${file.relativePath}\n+++ b/${file.relativePath}\n(local-driver: showing new content only)\n${file.content}`,
|
|
sizeBefore: Buffer.byteLength(current),
|
|
sizeAfter,
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async mkdir(basePath: string, relativePath: string) {
|
|
await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
|
|
}
|
|
|
|
async fileExists(basePath: string, relativePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(path.join(basePath, relativePath));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async deleteDirectory(dirPath: string) {
|
|
await fs.rm(dirPath, { recursive: true, force: true });
|
|
}
|
|
|
|
async cloneSource(basePath: string, _gitRepo: string, _gitBranch: string, excludes?: string[]) {
|
|
// Local provisioning uses rsync from CML_SOURCE_PATH
|
|
const { CML_SOURCE_PATH } = await import('../config/env').then((m) => m.env);
|
|
if (!CML_SOURCE_PATH) {
|
|
throw new Error('CML_SOURCE_PATH not configured — cannot clone source');
|
|
}
|
|
|
|
// SECURITY: Validate exclude entries — reject anything with shell metacharacters
|
|
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;
|
|
const safeExcludes = (excludes || [
|
|
'node_modules', '.git', '.env', 'changemaker-control-panel', '.claude',
|
|
'api/dist', 'admin/dist', 'uploads', 'data',
|
|
]).filter((e) => SAFE_EXCLUDE.test(e));
|
|
|
|
// SECURITY: Use execFile with args array — no shell interpolation
|
|
const { execFile: execFileCb } = await import('child_process');
|
|
const execFileAsync = promisify(execFileCb);
|
|
const args = ['-a', ...safeExcludes.flatMap((e) => ['--exclude', e]), `${CML_SOURCE_PATH}/`, `${basePath}/`];
|
|
await execFileAsync('rsync', args, { timeout: 120_000 });
|
|
}
|
|
}
|
|
|
|
/** Singleton local driver instance. */
|
|
let _localDriver: LocalDriver | null = null;
|
|
|
|
export function getLocalDriver(): LocalDriver {
|
|
if (!_localDriver) {
|
|
_localDriver = new LocalDriver();
|
|
}
|
|
return _localDriver;
|
|
}
|