live compose output during deploys, plus a per-stack deploy history tab

This commit is contained in:
Jarek Krochmalski committed 2026-09-08 07:47:30 +05:30
1 parent b2eb0b5dcb
commit 44295ea931
41 files changed
+2093 -1159

No files matched your search

+49
View File
@@ -4277,6 +4277,32 @@ export async function saveDashboardPreferences(data: {
// =============================================================================
export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore' | 'stack_deploy' | 'deploy_log_reconcile';
// Runtime list of every ScheduleType. Used to bound a no-type-filter executions
// query to the caller's viewable types (viewableScheduleTypes). The two type
// assertions below make it a real drift-guard: `satisfies` rejects a bogus value,
// and the bidirectional _exhaustive check fails to compile if a union member is
// missing from (or extra in) this array.
export const ALL_SCHEDULE_TYPES = [
'container_update',
'git_stack_sync',
'system_cleanup',
'env_update_check',
'image_prune',
'backup',
'restore',
'stack_deploy',
'deploy_log_reconcile'
] as const satisfies readonly ScheduleType[];
// Compile error if ALL_SCHEDULE_TYPES and ScheduleType ever diverge.
type _ScheduleTypeExhaustive =
Exclude<ScheduleType, (typeof ALL_SCHEDULE_TYPES)[number]> extends never
? Exclude<(typeof ALL_SCHEDULE_TYPES)[number], ScheduleType> extends never
? true
: never
: never;
const _scheduleTypeExhaustive: _ScheduleTypeExhaustive = true;
void _scheduleTypeExhaustive;
export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup';
export type ScheduleStatus =
| 'queued'
@@ -4334,8 +4360,19 @@ export interface ScheduleExecutionUpdateData {
export interface ScheduleExecutionFilters {
scheduleType?: ScheduleType;
// Enterprise per-resource RBAC: restrict to this allow-list of types when the
// caller lacks view on every resource (see viewableScheduleTypes). An empty
// array matches nothing (drizzle inArray([]) -> false), which is the correct
// "caller may see no types" result.
scheduleTypes?: ScheduleType[];
scheduleId?: number;
environmentId?: number | null;
// Enterprise env-scoped RBAC: restrict to these environment ids. A row with a
// NULL environmentId (system schedules, local-env deploys) is always included
// -- those are not attributed to any environment, matching how the per-run
// deploy access checks treat null (deploy-run-access.ts). Ignored when the
// caller has all-environment access (pass undefined).
environmentIds?: number[];
// Powers "runs for this stack" lookups (schedule_executions_entity_env_idx) --
// paired with environmentId, an exact match on the (entity_name, environment_id)
// index this filter was added for.
@@ -4459,6 +4496,9 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
if (filters.scheduleType) {
conditions.push(eq(scheduleExecutions.scheduleType, filters.scheduleType));
}
if (filters.scheduleTypes !== undefined) {
conditions.push(inArray(scheduleExecutions.scheduleType, filters.scheduleTypes));
}
if (filters.scheduleId !== undefined) {
conditions.push(eq(scheduleExecutions.scheduleId, filters.scheduleId));
}
@@ -4469,6 +4509,15 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId));
}
}
// Enterprise env-scoping: accessible envs OR a null (unattributed) env.
if (filters.environmentIds !== undefined) {
conditions.push(
or(
isNull(scheduleExecutions.environmentId),
inArray(scheduleExecutions.environmentId, filters.environmentIds)
)
);
}
if (filters.entityName !== undefined) {
conditions.push(eq(scheduleExecutions.entityName, filters.entityName));
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import type { DeploySummary } from './deploy-summary-core';
import type { DeploySummary } from '$lib/utils/deploy-summary-core';
/**
* Assembles the `details` JSON stored on a stack_deploy schedule_executions row.
+15 -3
View File
@@ -4,7 +4,7 @@ import {
type ScheduleTrigger
} from './db';
import { appendRunLog, runLogFileName, SizeBudgetTracker } from './deploy-log-store';
import { summarize } from './deploy-summary-core';
import { summarize } from '$lib/utils/deploy-summary-core';
import { buildRunDetails, type DeployRunOptions } from './deploy-run-record-core';
import { redactLine } from './secret-redaction';
import type { RunRecorder } from './sse';
@@ -43,8 +43,11 @@ export class DeployRunRecorder implements RunRecorder {
private readonly runId: string;
private readonly startedAtMs: number;
private readonly options: DeployRunOptions;
private readonly composeHash: string;
private readonly envHash: string;
/** Not readonly: setContentHashes() fills these after construction for callers that
* create the recorder before the compose content is known (the UI git-deploy path,
* which records the clone/read stages before it has the compose to hash). */
private composeHash: string;
private envHash: string;
private readonly userId?: number;
/** F5 fix: which environment this run belongs to (or null) -- determines the ON-DISK
* directory its log file lives in and which environment's size budget it counts
@@ -145,6 +148,15 @@ export class DeployRunRecorder implements RunRecorder {
}
}
/** Set the compose/env content hashes once they are known -- for callers that create
* the recorder before the compose content is available (the UI git-deploy path). The
* hashes are only read at end() (into the stored details), so this is safe any time
* before the run closes. */
setContentHashes(composeHash: string, envHash: string): void {
this.composeHash = composeHash;
this.envHash = envHash;
}
/**
* Called on every path out of the deploy (success, failure, or throw) -- including
* when no line() ever arrived, e.g. a deploy that fails before compose produces any
-70
View File
@@ -1,70 +0,0 @@
/**
* Condenses compose output into the few facts an operator actually asks after a deploy:
* was anything built or did it all come from cache, was an image pulled or was it already
* there, were containers recreated or merely restarted, and which digest is running now.
*
* Deliberately dependency-free so it can be unit tested under Bun, which cannot import
* the modules that pull in the native database binding.
*
* Compose repeats state transitions when no TTY is attached -- the same "Created" line can
* appear twice -- so every count is over a Set of names, never over line occurrences.
*/
export interface DeploySummary {
containersCreated: number;
containersRecreated: number;
containersStarted: number;
imagesBuilt: string[];
imagesPulled: string[];
buildSteps: number;
buildStepsCached: number;
digest?: string;
}
const CONTAINER = /^\s*Container\s+(\S+)\s+(Created|Recreated|Started)\s*$/;
const IMAGE = /^\s*Image\s+(\S+)\s+(Built|Pulled)\s*$/;
const BUILD_DONE = /^#\d+\s+DONE\s/;
const BUILD_CACHED = /^#\d+\s+CACHED\s*$/;
const DIGEST = /exporting manifest list (sha256:[0-9a-f]{64})/;
export function summarize(lines: string[]): DeploySummary {
const created = new Set<string>();
const recreated = new Set<string>();
const started = new Set<string>();
const built = new Set<string>();
const pulled = new Set<string>();
let buildSteps = 0;
let buildStepsCached = 0;
let digest: string | undefined;
for (const line of lines) {
const c = CONTAINER.exec(line);
if (c) {
const [, name, verb] = c;
if (verb === 'Created') created.add(name);
else if (verb === 'Recreated') recreated.add(name);
else started.add(name);
continue;
}
const i = IMAGE.exec(line);
if (i) {
const [, name, verb] = i;
(verb === 'Built' ? built : pulled).add(name);
continue;
}
if (BUILD_CACHED.test(line)) { buildSteps++; buildStepsCached++; continue; }
if (BUILD_DONE.test(line)) { buildSteps++; continue; }
const d = DIGEST.exec(line);
if (d) digest = d[1];
}
return {
containersCreated: created.size,
containersRecreated: recreated.size,
containersStarted: started.size,
imagesBuilt: [...built],
imagesPulled: [...pulled],
buildSteps,
buildStepsCached,
digest
};
}
+155 -61
View File
@@ -1378,6 +1378,29 @@ export async function deployGitStack(
if (!syncResult.success) {
console.log(`${logPrefix} Sync failed:`, syncResult.error);
const failResult = { success: false, error: syncResult.error };
// Record the sync failure as a failed stack_deploy run so it shows in the Deploys
// tab (webhook/scheduled/manual all reach here). Best-effort and fully isolated:
// a recorder error must never change the notify/return workflow below. Skip the
// benign "Sync already in progress" overlap -- it's a concurrency skip, not a
// deploy failure, and recording it would clutter history with false failures.
if (!(syncResult.error ?? '').includes('Sync already in progress')) {
try {
const failRecorder = await createRunRecorder({
stackName: gitStack.stackName,
envId: gitStack.environmentId ?? null,
userId: options?.userId,
triggeredBy,
options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: !!(options?.force ?? true) },
composeHash: '',
envHash: '',
secrets: []
});
failRecorder.line(`fatal: ${syncResult.error ?? 'git sync failed'}`);
await failRecorder.end(false, undefined, syncResult.error ?? 'git sync failed');
} catch (e) {
console.error(`${logPrefix} Failed to record git sync failure:`, e);
}
}
await notifyGitSync(gitStack.stackName, gitStack.environmentId, failResult);
return failResult;
}
@@ -1433,19 +1456,32 @@ export async function deployGitStack(
// from syncResult.composeContent, the EXACT string handed to deployStack right
// below, not a redundant re-read off disk.
//
// envHash/secrets mirror the same (deliberately approximate) resolution the
// non-git routes already use: getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord
// keyed by stackName+envId -- the same DB-sourced values deployStack itself
// resolves again internally at deploy time (stacks.ts). This does NOT include
// secret-provider-injected vars or the git repo's own .env file content; that's
// fine for the redaction list because deployStack's onLine output is
// independently redacted against ITS OWN, more complete secrets set
// (makeLineForwarder in stacks.ts) -- a leaked value in the log is still caught
// even if it isn't one of the vars hashed here.
const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const effectiveEnvVars = { ...nonSecretVars, ...secretVars };
// envHash/secrets mirror the same resolution the non-git routes use
// (getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord keyed by stackName+envId,
// the DB-sourced values deployStack resolves again at deploy time in stacks.ts),
// PLUS the repo's own .env values below so the redaction set is complete for what
// end() stores. Secret-provider-injected vars are still only in deployStack's own
// makeLineForwarder set (stacks.ts), which redacts the streamed lines; end()'s
// stored errorMessage is the one that also needs the .env values folded in here.
// The repo's own .env values are passed to compose via --env-file and never
// flow through the DB-sourced var records above, so without this they'd be in
// NEITHER redaction set -- a repo-.env-only secret echoed to stderr would land
// unredacted in the stored errorMessage (which the executions API returns). Fold
// them into the recorder's secrets so recorder.end()'s redactLine catches them.
// Best-effort: these reads exist only to seed the recorder, so a decrypt/DB failure
// must not abort the deploy -- fall back to whatever we could read.
let effectiveEnvVars: Record<string, string> = { ...(syncResult.envFileVars ?? {}) };
try {
const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
effectiveEnvVars = { ...nonSecretVars, ...secretVars, ...(syncResult.envFileVars ?? {}) };
} catch (e) {
console.error(`${logPrefix} Failed to read env vars for run-record redaction (deploy continues):`, e);
}
// The recorder is a BEST-EFFORT side channel: its DB/FS I/O must never abort the
// deploy nor flip its outcome. Swallow a createRunRecorder failure so a record-store
// hiccup degrades to "no run record", never a failed or aborted deploy.
const recorder = await createRunRecorder({
stackName: gitStack.stackName,
envId: gitStack.environmentId ?? null,
@@ -1455,13 +1491,16 @@ export async function deployGitStack(
composeHash: hashComposeContent(syncResult.composeContent!),
envHash: hashEnvFingerprint(effectiveEnvVars),
secrets: Object.values(effectiveEnvVars)
}).catch((e) => {
console.error(`${logPrefix} Failed to create deploy run recorder (continuing without one):`, e);
return null;
});
// Mirrors every redacted output line into the run record's log file, in addition
// to (not instead of) forwarding it to the caller's own onLine (SSE progress) --
// see the recorder doc comment above for why this can't be "record OR stream".
const onLine = (line: string) => {
recorder.line(line);
recorder?.line(line);
options?.onLine?.(line);
};
@@ -1485,8 +1524,13 @@ export async function deployGitStack(
} catch (error) {
// deployStack has never been observed to throw here (every existing caller of
// deployGitStack calls it without a try/catch around this point), but the
// record must not be left "running" forever on the day that changes.
await recorder.end(false, undefined, error instanceof Error ? error.message : String(error));
// record must not be left "running" forever on the day that changes. Best-effort:
// a recorder-close failure must not mask the original deploy error we re-throw.
try {
await recorder?.end(false, undefined, error instanceof Error ? error.message : String(error));
} catch (e) {
console.error(`${logPrefix} Failed to close run recorder on deploy error (original error preserved):`, e);
}
throw error;
}
@@ -1497,12 +1541,18 @@ export async function deployGitStack(
// leaking into result.error (a real possibility: cron/webhook-triggered git
// deploys are exactly the unattended runs most likely to use a bound provider)
// would bypass end()'s redaction entirely. See deploy-run-record.ts.
recorder.addSecrets(result.resolvedSecrets ?? []);
recorder?.addSecrets(result.resolvedSecrets ?? []);
// Closed right after the result is known, before the post-success bookkeeping
// below (deletion sync, upsertStackSource) -- those never fail the deploy itself,
// so the recorded duration reflects the deploy, not the bookkeeping around it.
await recorder.end(result.success, undefined, result.success ? undefined : result.error);
// Best-effort: a close failure (log FS / DB write) must NOT invert the already-known
// deploy result -- the deploy succeeded regardless of whether we could record it.
try {
await recorder?.end(result.success, undefined, result.success ? undefined : result.error);
} catch (e) {
console.error(`${logPrefix} Failed to close run recorder (deploy outcome unaffected):`, e);
}
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} DEPLOY GIT STACK RESULT`);
@@ -1624,6 +1674,10 @@ type ProgressCallback = (data: {
step?: number;
totalSteps?: number;
error?: string;
// A raw (already secret-redacted) compose output line, forwarded alongside the
// discrete stages so the UI can show the full log under the step list. Carried on
// its own field, not `message`, so a log line is never rendered as a stage.
logLine?: string;
}) => void;
/**
@@ -1668,14 +1722,53 @@ export async function deployGitStackWithProgress(
const totalSteps = 5;
// Created just before the deployStack() call below, same as deployGitStack --
// undefined here means the run never got that far (e.g. clone failed), exactly
// mirroring deployGitStack's own "only record a run that is actually happening".
let recorder: Awaited<ReturnType<typeof createRunRecorder>> | undefined;
// Create the run recorder up front (not at the deploy step) so a failure at ANY
// stage -- clone/fetch/read or the deploy -- produces a Deploys-tab record with the
// git-stage log lines, instead of leaving no trace. Compose/env hashes start empty
// and are irrelevant for a failed pre-compose run; a successful run's real summary is
// still derived from the compose output streamed into the recorder below.
//
// The recorder is a BEST-EFFORT side channel: its DB/FS I/O must never abort the
// deploy nor flip its outcome. createRunRecorder does DB writes that can throw --
// swallow that here so a record-store hiccup degrades to "no run record", never a
// failed or aborted deploy.
const recorder = await createRunRecorder({
stackName: gitStack.stackName,
envId: gitStack.environmentId ?? null,
triggeredBy: 'manual',
options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: false },
composeHash: '',
envHash: '',
secrets: []
}).catch((e) => {
console.error('Failed to create git deploy run recorder (continuing without one):', e);
return null;
});
// Mirror every git-stage message into the run log so the record reads like the live
// dialog (Connecting / Cloning / Fetching / ...), and forward progress to the caller.
const onProgressAndRecord: ProgressCallback = (data) => {
if (typeof data.message === 'string') recorder?.line(data.message);
onProgress(data);
};
// end() writes the DB row and is not idempotent; guard so the deploy-throw path and
// the outer catch don't both close the same record. Best-effort: a close failure is
// logged, never propagated -- it must not invert a successful deploy's result.
let recorderEnded = false;
const endRecorder = async (ok: boolean, error?: string) => {
if (recorderEnded || !recorder) return;
recorderEnded = true;
try {
await recorder.end(ok, undefined, error);
} catch (e) {
console.error('Failed to close git deploy run recorder (deploy outcome unaffected):', e);
}
};
try {
// Step 1: Connecting
onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
onProgressAndRecord({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null });
let updated = false;
@@ -1687,7 +1780,7 @@ export async function deployGitStackWithProgress(
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
onProgressAndRecord({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
@@ -1697,7 +1790,7 @@ export async function deployGitStackWithProgress(
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand)
onProgress({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps });
onProgressAndRecord({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps });
const cloneResult = await execGit(
['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath],
process.cwd(),
@@ -1738,7 +1831,7 @@ export async function deployGitStackWithProgress(
currentCommit = commitResult.stdout.substring(0, 7);
// Step 4: Reading compose file
onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps });
onProgressAndRecord({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps });
const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path");
if (!existsSync(composePath)) {
throw new Error(`Compose file not found: ${gitStack.composePath}`);
@@ -1813,14 +1906,14 @@ export async function deployGitStackWithProgress(
deletionData.plan.skipped
)
);
onProgress({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps });
onProgressAndRecord({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps });
for (const line of changeTable.slice(1)) {
onProgress({ status: 'deploying', message: line, step: 5, totalSteps });
onProgressAndRecord({ status: 'deploying', message: line, step: 5, totalSteps });
}
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
onProgressAndRecord({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
if (deletionData.plan.toDelete.length > 0) {
onProgress({
status: 'deploying',
@@ -1839,32 +1932,20 @@ export async function deployGitStackWithProgress(
}
}
// Same stack_deploy run record shape deployGitStack builds (see its doc
// comment above deployGitStack) -- this manual "Deploy" button path runs its
// own clone/read/deploy pipeline instead of calling deployGitStack (it needs
// this function's own discrete onProgress stages, not deployGitStack's onLine
// streaming), so it was the one caller of deployStack that never went through
// createRunRecorder at all -- unlike every other trigger (webhook,
// config-update-then-redeploy, and cron via runGitStackSync, all of which
// funnel through deployGitStack). Wired in here, right before the SAME
// deployStack() call deployGitStack wires it around, so it records without
// touching onProgress's own streaming.
const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const effectiveEnvVars = { ...nonSecretVars, ...secretVars };
recorder = await createRunRecorder({
stackName: gitStack.stackName,
envId: gitStack.environmentId ?? null,
triggeredBy: 'manual',
// forceRecreate is never passed to deployStack below (unlike deployGitStack,
// which computes it from forceRedeploy/gitUpdated) -- this path always omits
// it, so the record should say so too rather than guessing.
options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: false },
composeHash: hashComposeContent(composeContent),
envHash: hashEnvFingerprint(effectiveEnvVars),
secrets: Object.values(effectiveEnvVars)
});
// The recorder was created up front with empty hashes (for failure coverage before
// the compose is known); now that we have the compose + env, fill in the real
// content hashes and feed the env values in as redaction secrets. All best-effort:
// these reads/updates exist only for the recorder, so a decrypt/DB failure must not
// abort the deploy -- swallow it and proceed with whatever we could read.
try {
const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId);
const effectiveEnvVars = { ...nonSecretVars, ...secretVars };
recorder?.setContentHashes(hashComposeContent(composeContent), hashEnvFingerprint(effectiveEnvVars));
recorder?.addSecrets(Object.values(effectiveEnvVars));
} catch (e) {
console.error('Failed to read env vars for run-record redaction (deploy continues):', e);
}
let result: Awaited<ReturnType<typeof deployStack>>;
try {
@@ -1879,16 +1960,20 @@ export async function deployGitStackWithProgress(
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: deletionData.plan.toDelete,
// Mirrors deploy output into the run record's log file. Not forwarded to
// onProgress -- that callback's contract is the 5 discrete stage messages
// this function already sends above, not raw compose output lines.
onLine: (line) => recorder?.line(line)
// Each `line` is already secret-redacted by deployStack (makeLineForwarder
// in stacks.ts). Mirror it into the run record's log file AND forward it to
// onProgress as a logLine so the UI shows the full compose log under the
// step list -- the same single redacted stream, two sinks.
onLine: (line) => {
recorder?.line(line);
onProgress({ status: 'deploying', logLine: line, step: 5, totalSteps });
}
});
} catch (error) {
// deployStack has never been observed to throw here, but the record must
// not be left "running" forever on the day that changes (same posture
// deployGitStack takes around its own deployStack call).
await recorder.end(false, undefined, error instanceof Error ? error.message : String(error));
await endRecorder(false, error instanceof Error ? error.message : String(error));
throw error;
}
@@ -1897,8 +1982,8 @@ export async function deployGitStackWithProgress(
// `recorder` above was already built from the DB-only vars read before calling
// it. Feed the provider-resolved values in now, before recorder.end(), so a
// provider secret leaking into result.error is still redacted.
recorder.addSecrets(result.resolvedSecrets ?? []);
await recorder.end(result.success, undefined, result.success ? undefined : result.error);
recorder?.addSecrets(result.resolvedSecrets ?? []);
await endRecorder(result.success, result.success ? undefined : result.error);
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
@@ -1949,6 +2034,15 @@ export async function deployGitStackWithProgress(
syncStatus: 'error',
syncError: error.message
});
// Close the run record as failed. The recorder exists from the start, so a failure
// at ANY stage -- clone/fetch/read or the deploy -- leaves a record with the
// git-stage log lines captured above. Only log+close if the record isn't already
// closed (the deploy-throw inner catch closes it first); appending after close
// would write an orphan line the summarized row never reflects.
if (!recorderEnded) {
recorder?.line(`fatal: ${error.message}`);
await endRecorder(false, error.message);
}
onProgress({ status: 'error', error: error.message });
return { success: false, error: error.message };
}
+22 -11
View File
@@ -22,29 +22,40 @@ export function collectProcess(
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let lineBuffer = '';
// One buffer PER stream. A single shared buffer would splice a partial
// stdout line (no trailing '\n') onto the next stderr chunk (and vice
// versa) when the two streams interleave, producing corrupted lines.
let stdoutBuffer = '';
let stderrBuffer = '';
const emitLines = (chunk: string) => {
if (!onLine) return;
lineBuffer += chunk;
const parts = lineBuffer.split('\n');
lineBuffer = parts.pop() ?? ''; // last part may be incomplete
// Emit each complete line in `buf + chunk`, return the trailing partial.
const emitLines = (buf: string, chunk: string): string => {
if (!onLine) return buf;
const parts = (buf + chunk).split('\n');
const rest = parts.pop() ?? ''; // last part may be incomplete
for (const line of parts) onLine(line);
return rest;
};
proc.stdout?.on('data', (chunk: Buffer) => {
stdoutChunks.push(chunk);
emitLines(chunk.toString());
stdoutBuffer = emitLines(stdoutBuffer, chunk.toString());
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk);
emitLines(chunk.toString());
stderrBuffer = emitLines(stderrBuffer, chunk.toString());
});
proc.on('error', reject);
proc.on('close', (code) => {
if (onLine && lineBuffer.length > 0) {
onLine(lineBuffer);
lineBuffer = '';
// Flush each stream's trailing partial line (stdout before stderr for a
// deterministic order).
if (onLine && stdoutBuffer.length > 0) {
onLine(stdoutBuffer);
stdoutBuffer = '';
}
if (onLine && stderrBuffer.length > 0) {
onLine(stderrBuffer);
stderrBuffer = '';
}
resolve({
exitCode: code ?? 1,
@@ -0,0 +1,61 @@
/**
* Pure permission mapping for reading schedule_executions rows. No imports, so
* it stays unit-testable without the DB/auth module chain.
*
* A schedule execution's row (its errorMessage, details, and full logs) is only
* as sensitive as the thing it ran, so reading it is gated by the SAME resource
* permission that gates the underlying feature -- NOT a single blanket
* 'schedules:view'. That would either over-restrict (a backups-only viewer
* loses backup run stats) or under-restrict (a schedules-only viewer reads
* compose stderr from a stack deploy they can't otherwise see). Deploy stderr
* can carry redacted-but-not-guaranteed-clean output, hence the care.
*/
export type ScheduleExecResource = 'stacks' | 'backups' | 'schedules';
/** Every schedule type that maps to a NON-generic resource. Types not listed here
* fall through to 'schedules' in resourceForScheduleType. Kept here (not inlined)
* so the list gate can enumerate which types a resource-scoped caller may see. */
const NON_GENERIC_TYPES: Record<string, ScheduleExecResource> = {
stack_deploy: 'stacks',
git_stack_sync: 'stacks',
backup: 'backups',
restore: 'backups'
};
/**
* The resource whose `:view` permission a caller must hold to read executions of
* this schedule type. Unknown/unrecognized types fall back to 'schedules' -- the
* generic bucket, never a weaker one.
*/
export function resourceForScheduleType(scheduleType: string): ScheduleExecResource {
return NON_GENERIC_TYPES[scheduleType] ?? 'schedules';
}
/**
* The concrete schedule types whose executions a caller may read given which
* resource :view permissions they hold. Used to bound a NO-scheduleType-filter
* list query so a caller never receives errorMessage/details of a type they lack
* permission for.
*
* Returns null when 'schedules' AND 'stacks' AND 'backups' are all viewable --
* i.e. every type is allowed, so no type filter is needed (null = "do not
* filter", matching getScheduleExecutions' scheduleTypes semantics). Otherwise
* returns the explicit allow-list: the always-generic types when 'schedules' is
* held, plus each NON_GENERIC type whose resource is held.
*
* `allKnownTypes` is passed in (the caller has the ScheduleType union) so this
* stays dependency-free and testable.
*/
export function viewableScheduleTypes(
viewable: { schedules: boolean; stacks: boolean; backups: boolean },
allKnownTypes: readonly string[]
): string[] | null {
if (viewable.schedules && viewable.stacks && viewable.backups) return null;
const allowed: string[] = [];
for (const t of allKnownTypes) {
const resource = resourceForScheduleType(t);
if (viewable[resource]) allowed.push(t);
}
return allowed;
}
+27 -3
View File
@@ -27,6 +27,10 @@ export interface RunRecorder {
* the only method that reads the accumulated secret list -- see deploy-run-record.ts.
*/
addSecrets(values: string[]): void;
/** Sets the compose/env content hashes after construction, for callers that create the
* recorder before the compose content is known (the UI git-deploy path records the
* clone/read stages first). Read only at end(), so safe any time before the run closes. */
setContentHashes(composeHash: string, envHash: string): void;
/** Called exactly once, on every path out of the operation (success, failure, or throw). */
end(ok: boolean, exitCode?: number, error?: string): Promise<void>;
}
@@ -98,9 +102,16 @@ export function createJobResponse(
// Awaited here, before enqueue/close: this async start() is the
// only place on the JSON path where "the run is over" exists as a
// moment in time, so the recorder's close is placed right on it.
// Best-effort: the recorder is an optional side effect (writes the run
// log), so a failure to close it must never block the API response
// the client is waiting for.
if (recorder) {
const { ok, error } = endFromResult(resultData);
await recorder.end(ok, undefined, error);
try {
await recorder.end(ok, undefined, error);
} catch (e) {
console.error('Failed to close deploy run recorder (JSON path):', e);
}
}
}
controller.enqueue(encoder.encode(JSON.stringify(resultData)));
@@ -144,16 +155,29 @@ export function createJobResponse(
// the path every browser call takes (neither +page.svelte nor StackModal.svelte
// send an Accept header) -- reuse the JSON path's endFromResult() instead of
// hardcoding end(true), or every such run was recorded as a success.
// Best-effort: a failure to close the recorder must not throw into .catch()
// below, which would fail an already-succeeded job just because the log
// write failed.
if (recorder) {
recorderEnded = true;
const { ok, error } = endFromResult(resultData);
await recorder.end(ok, undefined, error);
try {
await recorder.end(ok, undefined, error);
} catch (e) {
console.error('Failed to close deploy run recorder (stream path):', e);
}
}
})
.catch(async (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
failJob(job, message);
if (recorder && !recorderEnded) await recorder.end(false, undefined, message);
if (recorder && !recorderEnded) {
try {
await recorder.end(false, undefined, message);
} catch (e) {
console.error('Failed to close deploy run recorder (stream error path):', e);
}
}
});
return json({ jobId: job.id });