bunker-admin abb4034e4b 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
2026-05-22 09:45:37 -06:00

165 lines
7.1 KiB
TypeScript

import { z } from 'zod';
export const createInstanceSchema = z.object({
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters'),
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
domain: z.string().min(3).max(255).regex(/^[a-zA-Z0-9.\-]+$/, 'Domain must be a valid hostname'),
adminEmail: z.string().email(),
enableMedia: z.boolean().default(false),
enableChat: z.boolean().default(false),
enableGancio: z.boolean().default(false),
enableListmonk: z.boolean().default(false),
enableMonitoring: z.boolean().default(false),
enableDevTools: z.boolean().default(false),
enablePayments: z.boolean().default(false),
enableMeet: z.boolean().default(false),
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
smtpUser: z.string().optional(),
smtpFrom: z.string().optional(),
emailTestMode: z.boolean().default(true),
enablePangolin: z.boolean().default(false),
pangolinEndpoint: z.string().url().optional(),
pangolinNewtId: z.string().optional(),
pangolinNewtSecret: z.string().optional(),
notes: z.string().optional(),
});
export const updateInstanceSchema = z.object({
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters').optional(),
enableMedia: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableGancio: z.boolean().optional(),
enableListmonk: z.boolean().optional(),
enableMonitoring: z.boolean().optional(),
enableDevTools: z.boolean().optional(),
enablePayments: z.boolean().optional(),
enableMeet: z.boolean().optional(),
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
jvbAdvertiseIp: z.string().ip({ version: 'v4' }).nullable().optional(),
smtpHost: z.string().regex(/^[a-zA-Z0-9.\-]+$/, 'SMTP host must be a valid hostname').optional(),
smtpPort: z.coerce.number().optional(),
smtpUser: z.string().optional(),
smtpFrom: z.string().optional(),
emailTestMode: z.boolean().optional(),
notes: z.string().nullable().optional(),
});
export const registerInstanceSchema = z.object({
name: z.string().min(2).max(100).regex(/^[a-zA-Z0-9 '_\-.]+$/, 'Name contains invalid characters'),
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
domain: z.string().min(3).max(255).regex(/^[a-zA-Z0-9.\-]+$/, 'Domain must be a valid hostname'),
basePath: z.string().min(1),
composeProject: z.string().min(1),
portConfig: z.object({
api: z.coerce.number().int().min(1).max(65535),
admin: z.coerce.number().int().min(1).max(65535),
postgres: z.coerce.number().int().min(1).max(65535),
nginx: z.coerce.number().int().min(1).max(65535),
embed: z.coerce.number().int().min(1).max(65535).optional(),
}),
adminEmail: z.string().email().optional().default('admin@localhost'),
enableMedia: z.boolean().default(false),
enableChat: z.boolean().default(false),
enableGancio: z.boolean().default(false),
enableListmonk: z.boolean().default(false),
enableMonitoring: z.boolean().default(false),
enableDevTools: z.boolean().default(false),
enablePayments: z.boolean().default(false),
enableMeet: z.boolean().default(false),
enableSms: z.boolean().default(false),
enableSocial: z.boolean().default(false),
enablePeople: z.boolean().default(false),
enableAnalytics: z.boolean().default(false),
emailTestMode: z.boolean().default(true),
notes: z.string().optional(),
});
export const reconfigureInstanceSchema = z.object({
enableMedia: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableGancio: z.boolean().optional(),
enableListmonk: z.boolean().optional(),
enableMonitoring: z.boolean().optional(),
enableDevTools: z.boolean().optional(),
enablePayments: z.boolean().optional(),
enableMeet: z.boolean().optional(),
enableSms: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enablePeople: z.boolean().optional(),
enableAnalytics: z.boolean().optional(),
});
export const configureTunnelSchema = z.object({
pangolinEndpoint: z.string().url(),
pangolinNewtId: z.string().min(1),
pangolinNewtSecret: z.string().min(1).optional(),
});
export const importInstancesSchema = z.object({
instances: z.array(registerInstanceSchema).min(1).max(50),
});
// SECURITY: branch name is interpolated into a shell command string in the
// local `runUpgrade` path (exec, not spawn), so we must enforce the same
// strict allow-list the agent uses on its own end. This blocks names starting
// with `-` (avoiding flag confusion), shell metachars, and anything exotic.
export const startUpgradeSchema = z.object({
skipBackup: z.boolean().optional(),
useRegistry: z.boolean().optional(),
branch: z
.string()
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/, 'Invalid branch name')
.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(),
});
// Approach C: release upgrade via CCP template re-render. CCP renders the
// docker-compose.yml + nginx confs + pangolin resources etc. against the
// tenant's context (with the proposed imageTag), writes them to the tenant,
// then composePull + composeUp. Used when a release changes orchestration
// in addition to image versions. imageTag is the new value for the
// per-instance Instance.imageTag column (NULL falls back to env default).
export const startReleaseUpgradeSchema = 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
subdomainPrefix: z
.string()
.max(50)
.regex(/^[a-z0-9-]*$/, 'Prefix must be lowercase alphanumeric with hyphens')
.optional(),
});
export type CreateInstanceInput = z.infer<typeof createInstanceSchema>;
export type UpdateInstanceInput = z.infer<typeof updateInstanceSchema>;
export type RegisterInstanceInput = z.infer<typeof registerInstanceSchema>;
export type ReconfigureInstanceInput = z.infer<typeof reconfigureInstanceSchema>;
export type ConfigureTunnelInput = z.infer<typeof configureTunnelSchema>;
export type ImportInstancesInput = z.infer<typeof importInstancesSchema>;
export type StartUpgradeInput = z.infer<typeof startUpgradeSchema>;