Defer promotion snapshots to campaign activation

This commit is contained in:
2026-10-06 00:16:43 -04:00
parent d6ff59b129
commit 9fd448339d
15 changed files with 179 additions and 56 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ export async function GET({ params }: { params: { id: string } }) {
// it opens the diff viewer.
const files = db()
.prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after,
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, snapshot_pending,
LENGTH(content_before) AS size_before,
LENGTH(content_after) AS size_after,
COALESCE((SELECT state FROM staged_file_changes WHERE deployment_file_id = deployment_files.id), 'applied') AS apply_state
@@ -10,7 +10,7 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
}
const row = db()
.prepare(
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after, snapshot_pending
FROM deployment_files
WHERE deployment_id = ? AND id = ?`
)
@@ -22,9 +22,11 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
sha256_after: string | null;
content_before: Buffer | null;
content_after: Buffer | null;
snapshot_pending: number;
}
| undefined;
if (!row) throw error(404, 'file not found');
if (row.snapshot_pending) return json({ error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.' }, { status: 409 });
return json({
verb: row.verb,
+1 -1
View File
@@ -191,7 +191,7 @@
{#if reviewError}<p class="ui-error" role="alert">{reviewError}</p>{/if}
{#if preview}
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
<p>Confirming queues the file changes. Each server waits until empty and restart capacity is available, then installs its files and restarts. Tested servers only need another activation if their configuration changed or earlier queued work could replace the tested files. Eligibility follows each file's current scope.</p>
<p>Confirming creates a campaign and queues the file changes. Each server waits until empty and restart capacity is available, then captures rollback snapshots, installs its files, and restarts. Tested servers keep their pre-beta snapshots and only need another activation if their configuration changed or earlier queued work could replace the tested files. Eligibility follows each file's current scope.</p>
<p class="muted">Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed settings change, you must review again.</p>
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
+7 -5
View File
@@ -23,6 +23,7 @@
sha256_before: string | null; sha256_after: string | null;
size_before: number | null; size_after: number | null;
apply_state: 'pending' | 'applying' | 'applied' | 'cancelled';
snapshot_pending: number;
}
interface ServerRow {
deployment_id: string; server: string; server_deleted_at?: string | null; files_changed: number;
@@ -97,7 +98,7 @@
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
try {
const response = await fetch('/api/campaigns/' + id + '/file/' + f.id);
if (!response.ok) throw new Error(response.status === 410 ? 'File contents expired under the retention policy.' : 'Could not load this file comparison. Close and select it again to retry.');
if (!response.ok) throw new Error(response.status === 410 ? 'File contents expired under the retention policy.' : response.status === 409 ? 'The rollback snapshot is not available yet. Refresh after activation to compare files.' : 'Could not load this file comparison. Close and select it again to retry.');
const body = await response.json();
if (id === $page.params.id && current === diffRequest) openFile = {id:f.id, ...body};
} catch (error) { if (id === $page.params.id && current === diffRequest) diffError = (error as Error).message; }
@@ -144,7 +145,7 @@
<section class="outcome" aria-label="Campaign outcome">
<h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{withdrawn ? ` · ${withdrawn} withdrawn` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2>
<p>{detail.watchdogIncidentId ? 'Maintenance repairs drift by deleting the existing instance files and rebuilding with the latest promoted setup after verifying a safe server state.' : 'File changes wait for an empty server and restart capacity. They are applied immediately before restart, then server health is verified.'}</p>
<p>{detail.watchdogIncidentId ? 'Maintenance repairs drift by deleting the existing instance files and rebuilding with the latest promoted setup after verifying a safe server state.' : 'File changes wait for an empty server and restart capacity. The campaign captures rollback snapshots immediately before applying files, then restarts the server and verifies health. Tested servers keep their original pre-beta snapshots.'}</p>
{#if unresolved || deletedIncomplete}<p>Review the recorded results below.{#if deletedIncomplete} Deleted servers retain their last recorded progress.{/if}{#if unresolved} Diagnose connectivity or inspect startup logs from the affected row.{/if}</p>
{:else if withdrawn}<p>Withdrawn changes will not be activated by this campaign.</p>
{:else}<p>Every recorded server completed activation.</p>{/if}
@@ -211,8 +212,9 @@
<h3><HistoricalServerIdentity name={srv} deletedAt={detail.servers.find(server => server.server === srv)?.server_deleted_at} /></h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" disabled={detail.fileContentsExpired} onclick={() => loadDiff(f)}>
<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>
<button class="file-row" disabled={detail.fileContentsExpired || !!f.snapshot_pending} onclick={() => loadDiff(f)}>
{#if f.snapshot_pending}<Badge variant="neutral" size="sm">{f.apply_state === 'cancelled' ? 'Snapshot not captured' : 'Snapshot pending'}</Badge>
{:else}<Badge variant={verbVariant(f.verb)} size="sm" uppercase>{f.verb}</Badge>{/if}
{#if f.apply_state !== 'applied'}<Badge variant="neutral" size="sm">{f.apply_state === 'pending' ? 'Queued' : f.apply_state === 'applying' ? 'Needs inspection' : 'Cancelled'}</Badge>{/if}
<span class="path">{f.rel_path}</span>
<span class="sizes">
@@ -228,7 +230,7 @@
{/if}
</section>
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'))} />
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at), detail.files)} events={detail.events} active={detail.servers.some(s => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'))} />
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
{:else if diffLoading}<EmptyState title="Loading comparison…" />