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

103 lines
4.5 KiB
TypeScript

import type { ContainerInfo } from './docker.service';
/**
* Abstraction layer for instance operations.
* LocalDriver wraps docker.service.ts + filesystem.
* RemoteDriver makes HTTPS calls to the remote agent.
*/
export interface ExecutionDriver {
// ─── Docker Compose Operations ──────────────────────────────
composeUp(projectDir: string, project: string, services?: string[]): Promise<string>;
composeDown(projectDir: string, project: string, removeVolumes?: boolean): Promise<string>;
composeStop(projectDir: string, project: string): Promise<string>;
composeRestart(projectDir: string, project: string, service?: string): Promise<string>;
composePull(projectDir: string, project: string): Promise<string>;
composeBuild(projectDir: string, project: string): Promise<string>;
composePs(projectDir: string, project: string): Promise<ContainerInfo[]>;
composeLogs(projectDir: string, project: string, service?: string, tail?: number, since?: string): Promise<string>;
composeExec(projectDir: string, project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string>;
// ─── Container Health ───────────────────────────────────────
waitForHealthy(containerName: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
waitForHttp(url: string, timeoutMs?: number, pollIntervalMs?: number): Promise<boolean>;
// ─── Filesystem Operations ──────────────────────────────────
readEnvFile(basePath: string): Promise<Record<string, string> | null>;
writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void>;
// Approach C pre-flight: diff proposed file contents against on-disk current.
// Returns per-file status (unchanged | modified | created) + unified diff for modified.
// Read-only.
diffFiles(
basePath: string,
files: Array<{ relativePath: string; content: string }>
): Promise<Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }>>;
mkdir(basePath: string, relativePath: string): Promise<void>;
fileExists(basePath: string, relativePath: string): Promise<boolean>;
deleteDirectory(dirPath: string): Promise<void>;
cloneSource(basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void>;
}
/**
* Error thrown when a remote agent is unreachable.
*/
export class AgentUnreachableError extends Error {
constructor(public agentUrl: string, cause?: Error) {
super(`Remote agent at ${agentUrl} is not reachable`);
this.name = 'AgentUnreachableError';
if (cause) this.cause = cause;
}
}
/**
* Minimal instance shape needed to resolve a driver.
*/
export interface DriverInstance {
id: string;
slug: string;
isRemote: boolean;
agentUrl: string | null;
}
/**
* Resolve the correct execution driver for an instance.
* Returns LocalDriver for local instances, RemoteDriver for remote ones.
*/
export async function getDriverForInstance(instance: DriverInstance): Promise<ExecutionDriver> {
if (!instance.isRemote) {
const { getLocalDriver } = await import('./local-driver');
return getLocalDriver();
}
return getRemoteDriverForInstance(instance);
}
/**
* Resolve a RemoteDriver for a remote instance. Throws if the instance is
* local, missing an agent URL, or has no valid mTLS certificate.
*
* Use this when you need to call RemoteDriver-specific methods like
* createBackup() that don't exist on the ExecutionDriver interface.
*/
export async function getRemoteDriverForInstance(instance: DriverInstance) {
if (!instance.isRemote) {
throw new Error(`Instance ${instance.slug} is not remote`);
}
if (!instance.agentUrl) {
throw new Error(`Remote instance ${instance.slug} has no agent URL configured`);
}
const { getAgentClientMaterials } = await import('./certificate.service');
const materials = await getAgentClientMaterials(instance.id);
if (!materials) {
throw new Error(`No valid certificate found for remote instance ${instance.slug}`);
}
const { RemoteDriver } = await import('./remote-driver');
return new RemoteDriver(
instance.agentUrl,
instance.slug,
Buffer.from(materials.agentCertPem),
Buffer.from(materials.agentKeyPem),
Buffer.from(materials.caCertPem)
);
}