live compose output during deploys, plus a per-stack deploy history tab
This commit is contained in:
41 files changed
+2093
-1159
No files matched your search
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user