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:
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user