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
103 lines
4.5 KiB
TypeScript
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)
|
|
);
|
|
}
|