bunker-admin abb4034e4b feat(upgrade): Approach C - CCP-driven release upgrade (template re-render)
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
2026-05-22 09:45:37 -06:00

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