feat(upgrade): Approach B - image-only upgrade mode
Add a "Quick Upgrade" path that pulls latest container images and recreates only the core app services (api, admin, media-api, nginx) without touching any tracked files. Tenant content (mkdocs/, configs/, scripts/) is implicitly preserved because the script never writes outside docker. Faster (~2 min vs ~4-5 min for full upgrade) and structurally safer for releases that don't change orchestration/templates. Pieces: - scripts/image-upgrade.sh: new ~350-line script. Phases: pre-flight + mkdocs snapshot, image pull, targeted recreate (broad up -d would cascade on misconfigured infra containers — proven on marcelle), light health checks, deferred ccp-agent restart. Writes the same progress.json + result.json schema as upgrade.sh so the CCP poll loop is unchanged. - agent/src/routes/upgrade.routes.ts: POST /instance/:slug/upgrade/start-image-only. Same lock + staleness guards as the existing /upgrade/start endpoint. - api/src/services/remote-driver.ts: RemoteDriver.startImageUpgrade(). - api/src/services/upgrade.service.ts: startImageUpgrade() entry point; reuses runRemoteUpgrade with mode='image-only' (only the initial agent call differs — result schema and polling are identical). - api/src/modules/instances/instances.routes.ts: POST /:id/upgrade-images + startImageUpgradeSchema. - admin/src/pages/InstanceDetailPage.tsx: secondary "Quick Upgrade" button next to "Upgrade Now" on the Updates tab. Tooltip explains when to use it. Tested locally on marcelle (v2.10.2 idempotent run): 1m 49s, mkdocs.yml md5 unchanged, file count unchanged, only api/admin/media-api/nginx touched. Subtle bug found and fixed: `set -o pipefail` + `grep -q` shorts pipe and SIGPIPEs the writer — captured services list once instead. Bunker Admin
This commit is contained in:
@@ -188,6 +188,85 @@ router.post('/instance/:slug/upgrade/start', async (req: Request, res: Response)
|
||||
res.status(202).json({ started: true });
|
||||
});
|
||||
|
||||
// POST /instance/:slug/upgrade/start-image-only — Run image-upgrade.sh in background
|
||||
//
|
||||
// Image-only upgrade: pulls latest images + recreates services without touching
|
||||
// tracked files (no git pull, no tarball extract, no VERSION mutation). Tenant
|
||||
// content is implicitly safe because the script never writes outside data/upgrade.
|
||||
// See scripts/image-upgrade.sh for full rationale.
|
||||
//
|
||||
// Schema-compatible with /upgrade/start: writes the same progress.json + result.json
|
||||
// so the CCP poll loop in runRemoteUpgrade() works unchanged.
|
||||
router.post('/instance/:slug/upgrade/start-image-only', async (req: Request, res: Response) => {
|
||||
const slug = param(req, 'slug');
|
||||
const entry = await getSlugEntry(slug);
|
||||
const { imageTag } = req.body || {};
|
||||
|
||||
// SECURITY: imageTag flows into bash via --image-tag. Constrain to a safe
|
||||
// subset of docker tag chars (semver, SHA, named tags). Reject anything
|
||||
// that could shell-escape.
|
||||
if (imageTag && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(String(imageTag))) {
|
||||
res.status(400).json({ error: 'VALIDATION', message: 'Invalid imageTag' });
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = path.join(entry.basePath, 'scripts', 'image-upgrade.sh');
|
||||
try {
|
||||
await fs.access(scriptPath);
|
||||
} catch {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'image-upgrade.sh not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Same concurrency guards as the full /upgrade/start endpoint — uses the
|
||||
// same lock + on-disk staleness check + backup/restore mutex.
|
||||
if (isSlugLocked(slug, 'upgrade') || await isUpgradeRunningOnDisk(entry.basePath)) {
|
||||
res.status(409).json({ error: 'SLUG_BUSY', message: 'An upgrade is already in progress' });
|
||||
return;
|
||||
}
|
||||
if (isSlugLocked(slug, 'backup') || isSlugLocked(slug, 'restore')) {
|
||||
res.status(409).json({ error: 'SLUG_BUSY', message: 'A backup or restore is currently running' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear stale progress/result files (same convention as /upgrade/start)
|
||||
const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
|
||||
const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
|
||||
await fs.mkdir(path.dirname(progressPath), { recursive: true });
|
||||
await fs.rm(progressPath, { force: true });
|
||||
await fs.rm(resultPath, { force: true });
|
||||
|
||||
const args: string[] = [scriptPath, '--api-mode'];
|
||||
if (imageTag) args.push('--image-tag', String(imageTag));
|
||||
|
||||
void withSlugLock(slug, 'upgrade', async () => {
|
||||
logger.info(`[image-upgrade] ${slug}: spawning ${args.join(' ')} (cwd=${entry.basePath})`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('bash', args, {
|
||||
cwd: entry.basePath,
|
||||
env: { ...process.env, COMPOSE_ANSI: 'never' },
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`image-upgrade.sh exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
logger.info(`[image-upgrade] ${slug}: image-upgrade.sh completed`);
|
||||
} catch (err) {
|
||||
logger.error(`[image-upgrade] ${slug}: ${(err as Error).message}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!(err instanceof SlugBusyError)) {
|
||||
logger.error(`[image-upgrade] ${slug}: lock or background error: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(202).json({ started: true, mode: 'image-only' });
|
||||
});
|
||||
|
||||
// GET /instance/:slug/upgrade/progress — Read progress.json
|
||||
router.get('/instance/:slug/upgrade/progress', async (req: Request, res: Response) => {
|
||||
const entry = await getSlugEntry(param(req, 'slug'));
|
||||
|
||||
Reference in New Issue
Block a user