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:
parent
97444645cb
commit
abb4034e4b
@ -40,6 +40,7 @@ import {
|
|||||||
DisconnectOutlined,
|
DisconnectOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
@ -582,6 +583,53 @@ export default function InstanceDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Release upgrade (Approach C): CCP re-renders templates with new image tag,
|
||||||
|
// writes them to the tenant, then composePull + composeUp. For releases
|
||||||
|
// that change orchestration (new services, compose config) in addition
|
||||||
|
// to image versions. Tenant content (mkdocs/, customized configs/) is
|
||||||
|
// never touched.
|
||||||
|
const [releaseUpgradeModalOpen, setReleaseUpgradeModalOpen] = useState(false);
|
||||||
|
const [releaseImageTag, setReleaseImageTag] = useState<string>('');
|
||||||
|
const [releasePreview, setReleasePreview] = useState<{
|
||||||
|
files: Array<{ path: string; status: string; diff: string | null; sizeBefore: number; sizeAfter: number }>;
|
||||||
|
envCoverage?: { requiredVars: string[]; presentInTenantEnv: string[]; missingInTenantEnv: string[] };
|
||||||
|
} | null>(null);
|
||||||
|
const [releasePreviewLoading, setReleasePreviewLoading] = useState(false);
|
||||||
|
|
||||||
|
const handlePreviewReleaseUpgrade = async () => {
|
||||||
|
setReleasePreviewLoading(true);
|
||||||
|
setReleasePreview(null);
|
||||||
|
try {
|
||||||
|
const body = releaseImageTag.trim() ? { imageTag: releaseImageTag.trim() } : {};
|
||||||
|
const { data } = await api.post(`/instances/${id}/upgrade-release/preview`, body);
|
||||||
|
setReleasePreview(data.data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response
|
||||||
|
?.data?.error;
|
||||||
|
message.error(resp?.message || 'Preview failed');
|
||||||
|
} finally {
|
||||||
|
setReleasePreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartReleaseUpgrade = async () => {
|
||||||
|
setUpgradingInstance(true);
|
||||||
|
try {
|
||||||
|
const body = releaseImageTag.trim() ? { imageTag: releaseImageTag.trim() } : {};
|
||||||
|
const { data } = await api.post(`/instances/${id}/upgrade-release`, body);
|
||||||
|
setCurrentUpgrade(data.data);
|
||||||
|
setReleaseUpgradeModalOpen(false);
|
||||||
|
setReleasePreview(null);
|
||||||
|
message.success('Release upgrade started');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const resp = (err as { response?: { data?: { error?: { message?: string } } } })?.response
|
||||||
|
?.data?.error;
|
||||||
|
message.error(resp?.message || 'Failed to start release upgrade');
|
||||||
|
} finally {
|
||||||
|
setUpgradingInstance(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
const handleAcknowledgeEvent = async (eventId: string) => {
|
const handleAcknowledgeEvent = async (eventId: string) => {
|
||||||
try {
|
try {
|
||||||
@ -1670,6 +1718,17 @@ export default function InstanceDetailPage() {
|
|||||||
Quick Upgrade
|
Quick Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
<Button
|
||||||
|
icon={<CloudUploadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setReleaseImageTag(instance.imageTag || '');
|
||||||
|
setReleasePreview(null);
|
||||||
|
setReleaseUpgradeModalOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={instance.status !== 'RUNNING' && instance.status !== 'STOPPED'}
|
||||||
|
>
|
||||||
|
Upgrade to Release
|
||||||
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Start full upgrade?"
|
title="Start full upgrade?"
|
||||||
description="This will pull the latest code, run database migrations, and restart all services. Brief downtime is expected."
|
description="This will pull the latest code, run database migrations, and restart all services. Brief downtime is expected."
|
||||||
@ -2256,6 +2315,117 @@ export default function InstanceDetailPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Approach C: Release upgrade modal (CCP template re-render) */}
|
||||||
|
<Modal
|
||||||
|
title="Upgrade to Release"
|
||||||
|
open={releaseUpgradeModalOpen}
|
||||||
|
onCancel={() => setReleaseUpgradeModalOpen(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={() => setReleaseUpgradeModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="preview"
|
||||||
|
onClick={handlePreviewReleaseUpgrade}
|
||||||
|
loading={releasePreviewLoading}
|
||||||
|
>
|
||||||
|
Preview Changes
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="apply"
|
||||||
|
type="primary"
|
||||||
|
danger={
|
||||||
|
!!releasePreview?.envCoverage?.missingInTenantEnv?.length
|
||||||
|
}
|
||||||
|
loading={upgradingInstance}
|
||||||
|
onClick={handleStartReleaseUpgrade}
|
||||||
|
>
|
||||||
|
Apply Upgrade
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={900}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>Image Tag:</Typography.Text>
|
||||||
|
<Input
|
||||||
|
value={releaseImageTag}
|
||||||
|
onChange={(e) => setReleaseImageTag(e.target.value)}
|
||||||
|
placeholder="e.g. v2.10.3 (blank = use current env.IMAGE_TAG default)"
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Re-renders docker-compose.yml + env + nginx configs with this tag. Tenant content
|
||||||
|
(mkdocs/, custom configs/) is never touched. Click <em>Preview Changes</em> to see the
|
||||||
|
per-file diff before applying.
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{releasePreview && (
|
||||||
|
<>
|
||||||
|
{releasePreview.envCoverage?.missingInTenantEnv && releasePreview.envCoverage.missingInTenantEnv.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Missing env vars in tenant .env"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>The new docker-compose.yml references vars the tenant's .env does NOT define:</div>
|
||||||
|
<code style={{ display: 'block', marginTop: 8, fontSize: 11 }}>
|
||||||
|
{releasePreview.envCoverage.missingInTenantEnv.join(', ')}
|
||||||
|
</code>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
Applying without these vars may break services. Add them to the tenant's .env
|
||||||
|
first, or reconcile the template.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography.Text strong>
|
||||||
|
Files: {releasePreview.files.length} total, {releasePreview.files.filter(f => f.status === 'modified').length} modified, {releasePreview.files.filter(f => f.status === 'created').length} created, {releasePreview.files.filter(f => f.status === 'unchanged').length} unchanged
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid #303030', borderRadius: 4 }}>
|
||||||
|
{releasePreview.files.map((f) => (
|
||||||
|
<div key={f.path} style={{ padding: 8, borderBottom: '1px solid #303030' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<code style={{ fontSize: 12 }}>{f.path}</code>
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
f.status === 'unchanged' ? 'green' :
|
||||||
|
f.status === 'modified' ? 'gold' :
|
||||||
|
'blue'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.status} {f.sizeBefore !== undefined && `(${f.sizeBefore} → ${f.sizeAfter} B)`}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{f.diff && (
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: '#1e1e1e',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
padding: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 11,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.diff}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export interface Instance {
|
|||||||
composeProject: string;
|
composeProject: string;
|
||||||
gitBranch: string;
|
gitBranch: string;
|
||||||
gitCommit?: string;
|
gitCommit?: string;
|
||||||
|
imageTag?: string;
|
||||||
portConfig: Record<string, number>;
|
portConfig: Record<string, number>;
|
||||||
enableMedia: boolean;
|
enableMedia: boolean;
|
||||||
enableChat: boolean;
|
enableChat: boolean;
|
||||||
|
|||||||
@ -24,6 +24,19 @@ router.post('/instance/:slug/files', async (req: Request, res: Response) => {
|
|||||||
res.json({ written: files.length });
|
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
|
// POST /instance/:slug/mkdir — Create directory
|
||||||
router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
|
router.post('/instance/:slug/mkdir', async (req: Request, res: Response) => {
|
||||||
const entry = await getSlugEntry(param(req, 'slug'));
|
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> {
|
export async function mkdirp(basePath: string, relativePath: string): Promise<void> {
|
||||||
const dirPath = path.join(basePath, relativePath);
|
const dirPath = path.join(basePath, relativePath);
|
||||||
assertWithin(dirPath, basePath);
|
assertWithin(dirPath, basePath);
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- Approach C: per-instance image tag override.
|
||||||
|
-- NULL means "use env.IMAGE_TAG default". Set via CCP "Upgrade to Release"
|
||||||
|
-- flow when operator chooses a tag for a specific tenant.
|
||||||
|
ALTER TABLE "instances" ADD COLUMN "image_tag" TEXT;
|
||||||
@ -70,6 +70,13 @@ model Instance {
|
|||||||
gitBranch String @default("v2") @map("git_branch")
|
gitBranch String @default("v2") @map("git_branch")
|
||||||
gitCommit String? @map("git_commit")
|
gitCommit String? @map("git_commit")
|
||||||
|
|
||||||
|
// Per-instance image tag override (Approach C release upgrades).
|
||||||
|
// NULL = fall back to env.IMAGE_TAG (the CCP-wide default). When set,
|
||||||
|
// CCP renders this value into the tenant's .env IMAGE_TAG, and the
|
||||||
|
// compose template's ${IMAGE_TAG:-latest} substitution picks it up at
|
||||||
|
// compose-up time. Each tenant rolls forward on its own cadence.
|
||||||
|
imageTag String? @map("image_tag")
|
||||||
|
|
||||||
// Allocated host ports (JSON: { api: 14001, admin: 13001, postgres: 15401, nginx: 10001 })
|
// Allocated host ports (JSON: { api: 14001, admin: 13001, postgres: 15401, nginx: 10001 })
|
||||||
portConfig Json @map("port_config")
|
portConfig Json @map("port_config")
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import { prisma } from '../../lib/prisma';
|
import { prisma } from '../../lib/prisma';
|
||||||
import { authenticate, requireRole } from '../../middleware/auth';
|
import { authenticate, requireRole } from '../../middleware/auth';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, startImageUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
|
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, startImageUpgradeSchema, startReleaseUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
|
||||||
import * as instancesService from './instances.service';
|
import * as instancesService from './instances.service';
|
||||||
import * as healthService from '../../services/health.service';
|
import * as healthService from '../../services/health.service';
|
||||||
import * as backupService from '../../services/backup.service';
|
import * as backupService from '../../services/backup.service';
|
||||||
@ -381,6 +381,41 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Release upgrade (Approach C). Re-renders templates via CCP and applies
|
||||||
|
// them to the tenant, then composePull + composeUp. Used when a release
|
||||||
|
// changes orchestration in addition to image versions.
|
||||||
|
router.post(
|
||||||
|
'/:id/upgrade-release',
|
||||||
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||||
|
validate(startReleaseUpgradeSchema),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { imageTag } = req.body || {};
|
||||||
|
const upgrade = await upgradeService.startReleaseUpgrade(
|
||||||
|
req.params.id as string,
|
||||||
|
req.user!.id,
|
||||||
|
req.ip,
|
||||||
|
{ imageTag }
|
||||||
|
);
|
||||||
|
res.status(201).json({ data: upgrade });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Approach C pre-flight: preview what re-render would change before applying.
|
||||||
|
// READ-ONLY — tenant disk is not touched.
|
||||||
|
router.post(
|
||||||
|
'/:id/upgrade-release/preview',
|
||||||
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||||
|
validate(startReleaseUpgradeSchema),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { imageTag } = req.body || {};
|
||||||
|
const preview = await upgradeService.previewReleaseUpgrade(
|
||||||
|
req.params.id as string,
|
||||||
|
{ imageTag }
|
||||||
|
);
|
||||||
|
res.json({ data: preview });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/upgrade-status',
|
'/:id/upgrade-status',
|
||||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||||
|
|||||||
@ -132,6 +132,19 @@ export const startImageUpgradeSchema = z.object({
|
|||||||
.optional(),
|
.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({
|
export const setupRemoteTunnelSchema = z.object({
|
||||||
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
|
// 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
|
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
|
||||||
|
|||||||
@ -24,6 +24,13 @@ export interface ExecutionDriver {
|
|||||||
// ─── Filesystem Operations ──────────────────────────────────
|
// ─── Filesystem Operations ──────────────────────────────────
|
||||||
readEnvFile(basePath: string): Promise<Record<string, string> | null>;
|
readEnvFile(basePath: string): Promise<Record<string, string> | null>;
|
||||||
writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void>;
|
writeFiles(basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void>;
|
||||||
|
// Approach C pre-flight: diff proposed file contents against on-disk current.
|
||||||
|
// Returns per-file status (unchanged | modified | created) + unified diff for modified.
|
||||||
|
// Read-only.
|
||||||
|
diffFiles(
|
||||||
|
basePath: string,
|
||||||
|
files: Array<{ relativePath: string; content: string }>
|
||||||
|
): Promise<Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }>>;
|
||||||
mkdir(basePath: string, relativePath: string): Promise<void>;
|
mkdir(basePath: string, relativePath: string): Promise<void>;
|
||||||
fileExists(basePath: string, relativePath: string): Promise<boolean>;
|
fileExists(basePath: string, relativePath: string): Promise<boolean>;
|
||||||
deleteDirectory(dirPath: string): Promise<void>;
|
deleteDirectory(dirPath: string): Promise<void>;
|
||||||
|
|||||||
@ -80,6 +80,35 @@ export class LocalDriver implements ExecutionDriver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approach C pre-flight diff. Reads current file contents at basePath +
|
||||||
|
// relativePath, returns per-file status + diff. Local implementation
|
||||||
|
// mirrors the agent-side diffFiles helper.
|
||||||
|
async diffFiles(basePath: string, files: Array<{ relativePath: string; content: string }>) {
|
||||||
|
const results: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }> = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(basePath, file.relativePath);
|
||||||
|
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 });
|
||||||
|
} else if (current === file.content) {
|
||||||
|
results.push({ path: file.relativePath, status: 'unchanged', diff: null, sizeBefore: Buffer.byteLength(current), sizeAfter });
|
||||||
|
} else {
|
||||||
|
// Minimal diff for local: full new content. Local mode is dev-only;
|
||||||
|
// detailed diffs come from the agent-side implementation.
|
||||||
|
results.push({
|
||||||
|
path: file.relativePath,
|
||||||
|
status: 'modified',
|
||||||
|
diff: `--- a/${file.relativePath}\n+++ b/${file.relativePath}\n(local-driver: showing new content only)\n${file.content}`,
|
||||||
|
sizeBefore: Buffer.byteLength(current),
|
||||||
|
sizeAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async mkdir(basePath: string, relativePath: string) {
|
async mkdir(basePath: string, relativePath: string) {
|
||||||
await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
|
await fs.mkdir(path.join(basePath, relativePath), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -309,6 +309,17 @@ export class RemoteDriver implements ExecutionDriver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approach C pre-flight diff via agent.
|
||||||
|
async diffFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>) {
|
||||||
|
const resp = await this.request<{ files: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }> }>({
|
||||||
|
method: 'POST',
|
||||||
|
path: `/instance/${this.slug}/files/diff`,
|
||||||
|
body: { files },
|
||||||
|
timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return resp.files;
|
||||||
|
}
|
||||||
|
|
||||||
async mkdir(_basePath: string, relativePath: string): Promise<void> {
|
async mkdir(_basePath: string, relativePath: string): Promise<void> {
|
||||||
await this.request({
|
await this.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -135,6 +135,8 @@ export interface InstanceForTemplate {
|
|||||||
smtpFrom: string | null;
|
smtpFrom: string | null;
|
||||||
emailTestMode: boolean;
|
emailTestMode: boolean;
|
||||||
gitBranch: string;
|
gitBranch: string;
|
||||||
|
// Per-instance image tag override (Approach C). NULL falls back to env.IMAGE_TAG.
|
||||||
|
imageTag: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -208,7 +210,9 @@ export function buildTemplateContext(
|
|||||||
gitBranch: instance.gitBranch,
|
gitBranch: instance.gitBranch,
|
||||||
registryUrl: env.GITEA_REGISTRY,
|
registryUrl: env.GITEA_REGISTRY,
|
||||||
useRegistry: env.USE_REGISTRY_IMAGES,
|
useRegistry: env.USE_REGISTRY_IMAGES,
|
||||||
imageTag: env.IMAGE_TAG,
|
// Approach C: per-instance imageTag overrides the CCP-wide env default.
|
||||||
|
// NULL on the Instance row falls back to env.IMAGE_TAG (typically 'latest').
|
||||||
|
imageTag: instance.imageTag || env.IMAGE_TAG,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { logger } from '../utils/logger';
|
|||||||
import { createEvent } from './event.service';
|
import { createEvent } from './event.service';
|
||||||
import { getRemoteDriverForInstance } from './execution-driver';
|
import { getRemoteDriverForInstance } from './execution-driver';
|
||||||
import type { AgentUpdateStatus } from './remote-driver';
|
import type { AgentUpdateStatus } from './remote-driver';
|
||||||
|
import { buildTemplateContext, clearTemplateCache, renderAllTemplatesInMemory } from './template-engine';
|
||||||
|
import { decryptJson } from '../utils/encryption';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell-injection guards. Any user- or DB-controlled value that flows into
|
* Shell-injection guards. Any user- or DB-controlled value that flows into
|
||||||
@ -382,6 +384,313 @@ export async function startImageUpgrade(
|
|||||||
return upgrade;
|
return upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Approach C: Release upgrade (template re-render) ────────────────────────
|
||||||
|
|
||||||
|
export interface StartReleaseUpgradeOptions {
|
||||||
|
imageTag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_IMAGE_TAG = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files that should NOT be re-rendered for tenants without encryptedSecrets
|
||||||
|
* (install.sh-registered tenants). Their .env was provisioned at install
|
||||||
|
* time and contains real secrets we can't reproduce.
|
||||||
|
*/
|
||||||
|
const REGISTERED_TENANT_SKIP_FILES = new Set(['.env']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter rendered file list for tenants without secrets. For install.sh
|
||||||
|
* tenants we keep the existing .env on disk (CCP can't render env without
|
||||||
|
* secrets in DB). Compose, nginx, pangolin etc. still render correctly
|
||||||
|
* because they only reference instance fields, not secrets directly.
|
||||||
|
*/
|
||||||
|
function filterRenderedFilesForRegisteredTenant(
|
||||||
|
files: Array<{ relativePath: string; content: string }>
|
||||||
|
): Array<{ relativePath: string; content: string }> {
|
||||||
|
return files.filter(f => !REGISTERED_TENANT_SKIP_FILES.has(f.relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract env var names referenced by the rendered docker-compose.yml.
|
||||||
|
* Used to compute envCoverage for install.sh tenants — operator needs to
|
||||||
|
* know if any ${VAR} references won't have a value in the tenant's .env.
|
||||||
|
*/
|
||||||
|
function extractComposeEnvVars(composeYaml: string): string[] {
|
||||||
|
const vars = new Set<string>();
|
||||||
|
// Match ${VAR} or ${VAR:-default} or ${VAR:?required}
|
||||||
|
const re = /\$\{([A-Z_][A-Z0-9_]*)(?:[:-?][^}]*)?\}/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(composeYaml)) !== null) {
|
||||||
|
vars.add(m[1]);
|
||||||
|
}
|
||||||
|
return Array.from(vars).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approach C pre-flight preview. Renders templates with the proposed
|
||||||
|
* imageTag override and diffs against the tenant's current files. Also
|
||||||
|
* computes envCoverage for install.sh tenants so the operator can see
|
||||||
|
* if the new compose needs any env vars their .env doesn't have.
|
||||||
|
* READ-ONLY — touches nothing on the tenant.
|
||||||
|
*/
|
||||||
|
export async function previewReleaseUpgrade(
|
||||||
|
instanceId: string,
|
||||||
|
options?: StartReleaseUpgradeOptions
|
||||||
|
): Promise<{
|
||||||
|
files: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }>;
|
||||||
|
envCoverage?: {
|
||||||
|
requiredVars: string[];
|
||||||
|
presentInTenantEnv: string[];
|
||||||
|
missingInTenantEnv: string[];
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||||
|
if (!instance) throw new Error('Instance not found');
|
||||||
|
if (!instance.isRemote) {
|
||||||
|
throw new Error('Release upgrade preview is currently supported only for remote instances');
|
||||||
|
}
|
||||||
|
if (options?.imageTag && !SAFE_IMAGE_TAG.test(options.imageTag)) {
|
||||||
|
throw new Error('Invalid imageTag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build context with proposed imageTag override (not persisted)
|
||||||
|
const previewInstance = { ...instance, imageTag: options?.imageTag ?? instance.imageTag };
|
||||||
|
const secrets = instance.encryptedSecrets
|
||||||
|
? decryptJson<Record<string, string>>(instance.encryptedSecrets)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
clearTemplateCache();
|
||||||
|
const context = buildTemplateContext(previewInstance, secrets);
|
||||||
|
let files = await renderAllTemplatesInMemory(context);
|
||||||
|
|
||||||
|
// Skip .env for registered tenants (no secrets to render against)
|
||||||
|
if (!instance.encryptedSecrets) {
|
||||||
|
files = filterRenderedFilesForRegisteredTenant(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = await getRemoteDriverForInstance({
|
||||||
|
id: instance.id,
|
||||||
|
slug: instance.slug,
|
||||||
|
isRemote: instance.isRemote,
|
||||||
|
agentUrl: instance.agentUrl,
|
||||||
|
});
|
||||||
|
const diffResults = await driver.diffFiles(instance.basePath, files);
|
||||||
|
|
||||||
|
// For registered tenants: report envCoverage so operator knows if any
|
||||||
|
// ${VAR} from the new compose isn't in their tenant .env. Required check
|
||||||
|
// because CCP isn't rendering their env file.
|
||||||
|
let envCoverage: { requiredVars: string[]; presentInTenantEnv: string[]; missingInTenantEnv: string[] } | undefined;
|
||||||
|
if (!instance.encryptedSecrets) {
|
||||||
|
const composeFile = files.find(f => f.relativePath === 'docker-compose.yml');
|
||||||
|
if (composeFile) {
|
||||||
|
const requiredVars = extractComposeEnvVars(composeFile.content);
|
||||||
|
// Read tenant's current .env via the agent's readEnvFile
|
||||||
|
const tenantEnv = await driver.readEnvFile(instance.basePath);
|
||||||
|
const presentKeys = new Set(Object.keys(tenantEnv || {}));
|
||||||
|
const presentInTenantEnv = requiredVars.filter(v => presentKeys.has(v));
|
||||||
|
const missingInTenantEnv = requiredVars.filter(v => !presentKeys.has(v));
|
||||||
|
envCoverage = { requiredVars, presentInTenantEnv, missingInTenantEnv };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files: diffResults, envCoverage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approach C apply path. Persists imageTag, re-renders templates, writes
|
||||||
|
* them to the tenant, then composePull + composeUp --remove-orphans.
|
||||||
|
* Fire-and-forget; status visible via the existing getUpgradeStatus() poll.
|
||||||
|
*/
|
||||||
|
export async function startReleaseUpgrade(
|
||||||
|
instanceId: string,
|
||||||
|
userId: string,
|
||||||
|
ipAddress?: string,
|
||||||
|
options?: StartReleaseUpgradeOptions
|
||||||
|
) {
|
||||||
|
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||||
|
if (!instance) throw new Error('Instance not found');
|
||||||
|
|
||||||
|
if (!instance.isRemote) {
|
||||||
|
throw new Error('Release 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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.imageTag && !SAFE_IMAGE_TAG.test(options.imageTag)) {
|
||||||
|
throw new Error('Invalid imageTag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared in-progress guard across all upgrade types.
|
||||||
|
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');
|
||||||
|
|
||||||
|
const upgrade = await prisma.instanceUpgrade.create({
|
||||||
|
data: {
|
||||||
|
instanceId,
|
||||||
|
status: UpgradeStatus.PENDING,
|
||||||
|
previousCommit: instance.imageTag ?? instance.gitCommit,
|
||||||
|
branch: instance.gitBranch,
|
||||||
|
triggeredById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
instanceId,
|
||||||
|
action: AuditAction.INSTANCE_UPGRADE,
|
||||||
|
details: {
|
||||||
|
upgradeId: upgrade.id,
|
||||||
|
previousImageTag: instance.imageTag,
|
||||||
|
newImageTag: options?.imageTag,
|
||||||
|
source: 'remote',
|
||||||
|
mode: 'release-template',
|
||||||
|
} as unknown as Prisma.InputJsonValue,
|
||||||
|
ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget runner. Distinct from runRemoteUpgrade because we don't
|
||||||
|
// shell out to upgrade.sh — CCP does the render + compose orchestration
|
||||||
|
// directly through the mTLS driver. No agent-side script involved.
|
||||||
|
runReleaseUpgrade(upgrade.id, instance, options).catch((err) => {
|
||||||
|
logger.error(`[release-upgrade] Orchestration failed for ${instance.slug}: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: do the actual Approach C work. Updates DB, renders, writes,
|
||||||
|
* pulls, recreates, verifies. All non-progress reporting comes via DB
|
||||||
|
* status updates on the InstanceUpgrade row.
|
||||||
|
*/
|
||||||
|
async function runReleaseUpgrade(
|
||||||
|
upgradeId: string,
|
||||||
|
instance: Instance,
|
||||||
|
options?: StartReleaseUpgradeOptions
|
||||||
|
) {
|
||||||
|
const slug = instance.slug;
|
||||||
|
const newImageTag = options?.imageTag;
|
||||||
|
|
||||||
|
const updateStatus = async (data: Prisma.InstanceUpgradeUpdateInput) => {
|
||||||
|
await prisma.instanceUpgrade.update({ where: { id: upgradeId }, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateStatus({
|
||||||
|
status: UpgradeStatus.IN_PROGRESS,
|
||||||
|
currentPhase: 1,
|
||||||
|
phaseName: 'Render',
|
||||||
|
percentage: 10,
|
||||||
|
progressMessage: 'Rendering templates with new image tag...',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist new imageTag before render so buildTemplateContext picks it up.
|
||||||
|
if (newImageTag) {
|
||||||
|
await prisma.instance.update({ where: { id: instance.id }, data: { imageTag: newImageTag } });
|
||||||
|
}
|
||||||
|
const refreshed = await prisma.instance.findUniqueOrThrow({ where: { id: instance.id } });
|
||||||
|
|
||||||
|
const secrets = refreshed.encryptedSecrets
|
||||||
|
? decryptJson<Record<string, string>>(refreshed.encryptedSecrets)
|
||||||
|
: {};
|
||||||
|
clearTemplateCache();
|
||||||
|
const context = buildTemplateContext(refreshed, secrets);
|
||||||
|
let files = await renderAllTemplatesInMemory(context);
|
||||||
|
if (!refreshed.encryptedSecrets) {
|
||||||
|
files = filterRenderedFilesForRegisteredTenant(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = await getRemoteDriverForInstance({
|
||||||
|
id: instance.id,
|
||||||
|
slug: instance.slug,
|
||||||
|
isRemote: instance.isRemote,
|
||||||
|
agentUrl: instance.agentUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: write rendered files
|
||||||
|
await updateStatus({
|
||||||
|
currentPhase: 2,
|
||||||
|
phaseName: 'Write Files',
|
||||||
|
percentage: 30,
|
||||||
|
progressMessage: `Writing ${files.length} rendered file(s)...`,
|
||||||
|
});
|
||||||
|
await driver.writeFiles(instance.basePath, files);
|
||||||
|
|
||||||
|
// Phase 3: pull images per new compose
|
||||||
|
await updateStatus({
|
||||||
|
currentPhase: 3,
|
||||||
|
phaseName: 'Pull Images',
|
||||||
|
percentage: 55,
|
||||||
|
progressMessage: 'Pulling images from registry...',
|
||||||
|
});
|
||||||
|
await driver.composePull(instance.basePath, instance.composeProject);
|
||||||
|
|
||||||
|
// Phase 4: recreate services
|
||||||
|
await updateStatus({
|
||||||
|
currentPhase: 4,
|
||||||
|
phaseName: 'Recreate Services',
|
||||||
|
percentage: 80,
|
||||||
|
progressMessage: 'Recreating services with new orchestration...',
|
||||||
|
});
|
||||||
|
await driver.composeUp(instance.basePath, instance.composeProject);
|
||||||
|
|
||||||
|
// Phase 5: verify (best-effort; soft warnings only)
|
||||||
|
await updateStatus({
|
||||||
|
currentPhase: 5,
|
||||||
|
phaseName: 'Verify',
|
||||||
|
percentage: 95,
|
||||||
|
progressMessage: 'Verifying container health...',
|
||||||
|
});
|
||||||
|
const warnings: string[] = [];
|
||||||
|
try {
|
||||||
|
const containers = await driver.composePs(instance.basePath, instance.composeProject);
|
||||||
|
const unhealthy = containers.filter(c => c.status && /restarting|exited/i.test(c.status));
|
||||||
|
if (unhealthy.length > 0) {
|
||||||
|
warnings.push(`${unhealthy.length} container(s) not healthy after upgrade: ${unhealthy.map(c => c.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push('composePs verification failed (services may still be starting)');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStatus({
|
||||||
|
status: UpgradeStatus.COMPLETED,
|
||||||
|
currentPhase: 5,
|
||||||
|
phaseName: 'Complete',
|
||||||
|
percentage: 100,
|
||||||
|
progressMessage: `Release upgrade complete${newImageTag ? ` (imageTag: ${newImageTag})` : ''}`,
|
||||||
|
newCommit: newImageTag ?? refreshed.imageTag,
|
||||||
|
commitCount: 0,
|
||||||
|
warnings: warnings.length ? (warnings as unknown as Prisma.InputJsonValue) : undefined,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
logger.info(`[release-upgrade] ${slug}: completed${newImageTag ? ` → ${newImageTag}` : ''}`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = (err as Error).message || 'Release upgrade failed';
|
||||||
|
await updateStatus({
|
||||||
|
status: UpgradeStatus.FAILED,
|
||||||
|
errorMessage: message,
|
||||||
|
progressMessage: `Failed: ${message}`,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
await createEvent(
|
||||||
|
instance.id,
|
||||||
|
'ERROR',
|
||||||
|
'upgrade',
|
||||||
|
'Release upgrade failed',
|
||||||
|
message,
|
||||||
|
{ upgradeId, source: 'remote', mode: 'release-template' }
|
||||||
|
);
|
||||||
|
logger.error(`[release-upgrade] ${slug}: failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Async REMOTE upgrade runner.
|
* Async REMOTE upgrade runner.
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user