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
This commit is contained in:
2026-05-22 09:45:37 -06:00
parent 97444645cb
commit abb4034e4b
13 changed files with 712 additions and 2 deletions

View File

@@ -24,6 +24,19 @@ router.post('/instance/:slug/files', async (req: Request, res: Response) => {
res.json({ written: files.length });
});
// POST /instance/:slug/files/diff — Approach C pre-flight: diff proposed
// rendered files against on-disk current content. Read-only.
router.post('/instance/:slug/files/diff', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));
const { files } = req.body;
if (!Array.isArray(files)) {
res.status(400).json({ error: 'VALIDATION', message: 'files array required' });
return;
}
const results = await fileService.diffFiles(entry.basePath, files);
res.json({ files: results });
});
// POST /instance/:slug/mkdir — Create directory
router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
const entry = await getSlugEntry(param(req, 'slug'));

View File

@@ -35,6 +35,113 @@ export async function writeFiles(
}
}
/**
* Diff proposed files against current on-disk contents at basePath.
* For Approach C pre-flight preview: operator sees per-file change summary
* before applying re-rendered templates. Returns one DiffResult per proposed
* file. Uses a small inline LCS-based unified diff to avoid new deps.
*/
export interface DiffResult {
path: string;
status: 'unchanged' | 'modified' | 'created';
diff: string | null;
sizeBefore: number;
sizeAfter: number;
}
const DIFF_MAX_LINES = 500;
function unifiedDiff(oldText: string, newText: string, relativePath: string): string {
// Compact unified-diff: line-level LCS, emit context + changed lines.
// Not a full GNU diff — adequate for compose/env/conf inspection in the UI.
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
// Build LCS table (line-level). For files up to ~1500 lines this is O(N*M)
// which is fine; we truncate output length not algorithm runtime.
const m = oldLines.length, n = newLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
// Backtrack to emit unified-style hunks
const out: string[] = [`--- a/${relativePath}`, `+++ b/${relativePath}`];
let i = 0, j = 0, oldStart = 0, newStart = 0;
const hunk: string[] = [];
let emittedLines = 0;
while ((i < m || j < n) && emittedLines < DIFF_MAX_LINES) {
if (i < m && j < n && oldLines[i] === newLines[j]) {
hunk.push(` ${oldLines[i]}`);
i++; j++;
} else if (j < n && (i === m || dp[i][j + 1] >= dp[i + 1][j])) {
hunk.push(`+${newLines[j]}`);
j++; newStart++;
} else {
hunk.push(`-${oldLines[i]}`);
i++; oldStart++;
}
emittedLines++;
}
if (emittedLines >= DIFF_MAX_LINES) hunk.push(`... (diff truncated at ${DIFF_MAX_LINES} lines)`);
out.push(...hunk);
return out.join('\n');
}
export async function diffFiles(
basePath: string,
files: Array<{ relativePath: string; content: string }>
): Promise<DiffResult[]> {
const results: DiffResult[] = [];
for (const file of files) {
const filePath = path.join(basePath, file.relativePath);
assertWithin(filePath, basePath);
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,
});
continue;
}
const sizeBefore = Buffer.byteLength(current, 'utf-8');
if (current === file.content) {
results.push({
path: file.relativePath,
status: 'unchanged',
diff: null,
sizeBefore,
sizeAfter,
});
continue;
}
results.push({
path: file.relativePath,
status: 'modified',
diff: unifiedDiff(current, file.content, file.relativePath),
sizeBefore,
sizeAfter,
});
}
return results;
}
export async function mkdirp(basePath: string, relativePath: string): Promise<void> {
const dirPath = path.join(basePath, relativePath);
assertWithin(dirPath, basePath);