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:
@@ -4,7 +4,7 @@ import rateLimit from 'express-rate-limit';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate, requireRole } from '../../middleware/auth';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
|
||||
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, startImageUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
|
||||
import * as instancesService from './instances.service';
|
||||
import * as healthService from '../../services/health.service';
|
||||
import * as backupService from '../../services/backup.service';
|
||||
@@ -362,6 +362,25 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// Image-only upgrade (Approach B). Faster + safer than full upgrade for
|
||||
// releases that don't change orchestration/templates. See upgrade.service.ts
|
||||
// startImageUpgrade for full rationale.
|
||||
router.post(
|
||||
'/:id/upgrade-images',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(startImageUpgradeSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const { imageTag } = req.body || {};
|
||||
const upgrade = await upgradeService.startImageUpgrade(
|
||||
req.params.id as string,
|
||||
req.user!.id,
|
||||
req.ip,
|
||||
{ imageTag }
|
||||
);
|
||||
res.status(201).json({ data: upgrade });
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/upgrade-status',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
|
||||
@@ -121,6 +121,17 @@ export const startUpgradeSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Approach B: image-only upgrade. Pulls images + recreates core app services
|
||||
// without touching tracked files. imageTag is optional — if omitted, the
|
||||
// agent uses whatever IMAGE_TAG the install's .env / compose env defines
|
||||
// (typically `latest`). Tag must be a valid Docker tag.
|
||||
export const startImageUpgradeSchema = z.object({
|
||||
imageTag: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/, 'Invalid imageTag')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const setupRemoteTunnelSchema = z.object({
|
||||
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
|
||||
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
|
||||
|
||||
@@ -82,6 +82,10 @@ export interface StartAgentUpgradeOptions {
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface StartAgentImageUpgradeOptions {
|
||||
imageTag?: string;
|
||||
}
|
||||
|
||||
interface AgentRequestOptions {
|
||||
method: 'GET' | 'POST' | 'DELETE';
|
||||
path: string;
|
||||
@@ -574,6 +578,21 @@ export class RemoteDriver implements ExecutionDriver {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger image-upgrade.sh --api-mode on the remote (Approach B: image-only
|
||||
* upgrade — pulls images + recreates core app services without touching
|
||||
* the install tree). Fire-and-forget; returns 202 immediately. Uses the
|
||||
* same progress/result polling endpoints as startUpgrade.
|
||||
*/
|
||||
async startImageUpgrade(options: StartAgentImageUpgradeOptions = {}): Promise<void> {
|
||||
await this.request({
|
||||
method: 'POST',
|
||||
path: `/instance/${this.slug}/upgrade/start-image-only`,
|
||||
body: options,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the agent's data/upgrade/progress.json. Returns the default zero-state
|
||||
* if no progress has been written yet.
|
||||
|
||||
@@ -205,6 +205,10 @@ export interface StartUpgradeOptions {
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface StartImageUpgradeOptions {
|
||||
imageTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an upgrade for an instance. Returns the created InstanceUpgrade record.
|
||||
* The actual upgrade runs asynchronously (fire-and-forget).
|
||||
@@ -298,6 +302,86 @@ export async function startUpgrade(
|
||||
return upgrade;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an IMAGE-ONLY upgrade (Approach B). Pulls latest images + recreates
|
||||
* core app services without touching tracked files. Faster (~2 min vs ~4-5
|
||||
* min for full upgrade) and safer because no filesystem mutation outside
|
||||
* docker — tenant content (mkdocs/, configs/) is implicitly preserved.
|
||||
*
|
||||
* Use this for releases that only bump container code or schema. For
|
||||
* releases that change compose orchestration, nginx config, or other
|
||||
* tracked files, use startUpgrade() instead.
|
||||
*
|
||||
* Remote-only for now: local mode would need a `runImageUpgrade` runner
|
||||
* which we haven't built (all our instances are remote via mTLS agent).
|
||||
*/
|
||||
export async function startImageUpgrade(
|
||||
instanceId: string,
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
options?: StartImageUpgradeOptions
|
||||
) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||
if (!instance) throw new Error('Instance not found');
|
||||
|
||||
if (!instance.isRemote) {
|
||||
throw new Error('Image-only upgrade is currently supported only for remote instances');
|
||||
}
|
||||
|
||||
if (instance.status !== InstanceStatus.RUNNING && instance.status !== InstanceStatus.STOPPED) {
|
||||
throw new Error(`Cannot upgrade instance in ${instance.status} state`);
|
||||
}
|
||||
|
||||
// Reuse the same in-progress guard as startUpgrade: only one upgrade
|
||||
// (of either type) at a time per instance.
|
||||
const active = await prisma.instanceUpgrade.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
status: { in: [UpgradeStatus.PENDING, UpgradeStatus.IN_PROGRESS] },
|
||||
},
|
||||
});
|
||||
if (active) {
|
||||
throw new Error('An upgrade is already in progress for this instance');
|
||||
}
|
||||
|
||||
// Create upgrade record. branch is unused for image-only but keep it
|
||||
// populated with current branch for audit trail consistency.
|
||||
const upgrade = await prisma.instanceUpgrade.create({
|
||||
data: {
|
||||
instanceId,
|
||||
status: UpgradeStatus.PENDING,
|
||||
previousCommit: instance.gitCommit,
|
||||
branch: instance.gitBranch,
|
||||
triggeredById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId,
|
||||
action: AuditAction.INSTANCE_UPGRADE,
|
||||
details: {
|
||||
upgradeId: upgrade.id,
|
||||
previousCommit: instance.gitCommit,
|
||||
source: 'remote',
|
||||
mode: 'image-only',
|
||||
options: options || {},
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
// Fire-and-forget: reuse runRemoteUpgrade with mode='image-only'. Same
|
||||
// poll loop and result handling — only the initial agent call differs.
|
||||
runRemoteUpgrade(upgrade.id, instance, undefined, 'image-only', options).catch((err) => {
|
||||
logger.error(`[image-upgrade] Remote image upgrade orchestration failed for ${instance.slug}: ${err}`);
|
||||
});
|
||||
|
||||
return upgrade;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async REMOTE upgrade runner.
|
||||
*
|
||||
@@ -316,7 +400,9 @@ export async function startUpgrade(
|
||||
async function runRemoteUpgrade(
|
||||
upgradeId: string,
|
||||
instance: Instance,
|
||||
options?: StartUpgradeOptions
|
||||
options?: StartUpgradeOptions,
|
||||
mode: 'full' | 'image-only' = 'full',
|
||||
imageOnlyOptions?: StartImageUpgradeOptions
|
||||
) {
|
||||
const slug = instance.slug;
|
||||
|
||||
@@ -333,18 +419,27 @@ async function runRemoteUpgrade(
|
||||
where: { id: upgradeId },
|
||||
data: {
|
||||
status: UpgradeStatus.IN_PROGRESS,
|
||||
progressMessage: 'Starting remote upgrade...',
|
||||
progressMessage: mode === 'image-only'
|
||||
? 'Starting image-only upgrade...'
|
||||
: 'Starting remote upgrade...',
|
||||
},
|
||||
});
|
||||
|
||||
// Tell the agent to start. The agent has its own mutex + stale-progress
|
||||
// check, so this can return 409 if a previous upgrade is still running.
|
||||
logger.info(`[upgrade] ${slug}: triggering remote upgrade.sh start`);
|
||||
await driver.startUpgrade({
|
||||
skipBackup: options?.skipBackup,
|
||||
useRegistry: options?.useRegistry,
|
||||
branch: options?.branch,
|
||||
});
|
||||
if (mode === 'image-only') {
|
||||
logger.info(`[upgrade] ${slug}: triggering remote image-upgrade.sh start`);
|
||||
await driver.startImageUpgrade({
|
||||
imageTag: imageOnlyOptions?.imageTag,
|
||||
});
|
||||
} else {
|
||||
logger.info(`[upgrade] ${slug}: triggering remote upgrade.sh start`);
|
||||
await driver.startUpgrade({
|
||||
skipBackup: options?.skipBackup,
|
||||
useRegistry: options?.useRegistry,
|
||||
branch: options?.branch,
|
||||
});
|
||||
}
|
||||
|
||||
// Poll progress + result. We treat /result returning 200 as the signal
|
||||
// that upgrade.sh exited (successfully or with code != 0 — the script
|
||||
|
||||
Reference in New Issue
Block a user