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
165 lines
7.1 KiB
TypeScript
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>;
|