feat(stacks): live compose output and deploy history (#1499)
* feat(security): add exact-value redaction for compose output lines
* feat(stacks): emit compose output line by line via optional callback
* test(stacks): isolate collectProcess test from the DB init side effect
stacks.ts transitively imports db.ts and db/drizzle.ts, which run a
top-level seedDatabase() against better-sqlite3 -- unsupported under
Bun (oven-sh/bun#4290). Importing stacks.ts for a plain unit test
crashed at import time and, worse, leaked into unrelated test files
sharing the same bun test process (reproduced against
tests/semver-generic-v2.test.ts). Mock both modules' full export
surface before importing, mirroring tests/backups/route-guards.test.ts's
established mock.module(./db) pattern.
* refactor(stacks): extract collectProcess into a dependency-free core module
Testing it required importing stacks.ts, which transitively pulls in
db.ts and db/drizzle.ts -- both run a top-level seedDatabase() against
better-sqlite3, which does not load under Bun. That crash was cached
process-wide and broke an unrelated, previously-green test file
(tests/semver-generic-v2.test.ts) whenever the full suite ran.
Following the repo's established -core module pattern (nav-preferences-core,
ws-exec-core, etc.), collectProcess now lives in its own dependency-free
file and is re-exported from stacks.ts for its existing callers.
* feat(security): bridge line callbacks through redaction
makeLineForwarder wraps a raw line consumer with redactLine, dropping
withheld lines silently. This is the connector collectProcess-emitted
lines will pass through before reaching the job store.
* feat(stacks): thread onLine through the local compose execution path
executeLocalCompose, executeComposeViaHawser, and executeComposeCommand
all accept an optional onLine callback now, forwarded to whichever
execution path is chosen. The local path (executeLocalCompose) wires it
into collectProcess via makeLineForwarder, redacting against envVars and
secretVars -- deliberately not spawnEnv, which also carries PATH, HOME,
DOCKER_CONFIG and DOCKER_API_VERSION and would over-redact (HOME often
resolves to "/root", five characters, below the safe-replace threshold,
which would withhold every line containing it).
executeComposeViaHawser accepts the parameter for signature parity but
does not use it yet: its /_hawser/compose call is a single
request/response, not a stream, so it cannot report lines as they
happen. That lands in a follow-up task.
startStack, stopStack, restartStack, downStack, and deployStack gained
an onLine parameter (deployStack via a new DeployStackOptions field) and
pass it through to every executeComposeCommand call in their body.
redeployStackFromDir, removeStack, pullStackImages, pullStackService,
and updateStackService are deliberately left unchanged -- they don't run
behind the streaming job endpoints.
* feat(stacks): forward redacted compose output lines to the job store
Each of the five job endpoints (deploy/start/stop/restart/down) now
passes send as the onLine callback, wrapping each line in a
{type: 'line', line} progress event. The 'line' type distinguishes
these from the existing static {status} progress events the endpoints
already send.
* chore(tests): use English throughout, matching the repository language
* refactor(tests): rename identifiers to English
* feat(ui): readJobResponse forwards line messages to an optional callback
* feat(ui): show compose output while stack operations run
* feat(stacks): surface the final output block when an agent sends no lines
* feat(stacks): thread onLine through the create and compose endpoints
POST /api/stacks (create & start) and PUT /api/stacks/{name}/compose
(save & redeploy) both run deployStack inside createJobResponse but never
passed onLine, so no 'line' progress messages were emitted and any live
output view stayed empty until the job settled.
Both now forward lines exactly like the deploy endpoint does. A source-level
guard asserts every deployStack call under src/routes/api carries onLine —
the omission is invisible at runtime, so nothing else would catch the next one.
* feat(ui): show compose output for Create & Start and Save & redeploy
The five stack-row actions opened the output window; the stack modal's two
deploying paths did not, leaving the most common route after an edit —
Save & redeploy — with no visible progress.
The modal takes three optional callbacks and forwards to them. It holds no
output state and renders no window of its own: the loop stays on the page, so
closing the modal cannot tear down a running deploy. Callers that don't pass
them are unaffected.
* feat(hawser): route agent line messages to non-streaming compose requests
* feat(stacks): consume streamed compose lines from the agent
* refactor(logs): make LogViewer's title optional and path-safe
* fix(logs): strip the escape sequences compose actually emits
The first pattern accepted only digits and semicolons as CSI parameter bytes,
so it left private-mode sequences untouched -- including ESC[?25l and ESC[?25h,
which compose's progress display writes on every redraw. A stripper that misses
the sequences of the output it exists to clean does not do its job.
Match the CSI grammar instead: ESC [ , parameter bytes (0x30-0x3F, which is
where '?' lives), intermediate bytes (0x20-0x2F), one final byte (0x40-0x7E).
Also cover the backslash branch of the path-separator test, which the previous
case did not exercise.
* feat(stacks): show compose output in the full log viewer
* feat(stacks): summarise compose output into deploy facts
* feat(stacks): add a status line with duration to the compose output window
Measure wall-clock duration client-side (Date.now() at start, diff on
result) and format it with formatRunStatus() in ComposeOutputModal's
description: "Running…", "Succeeded · 6.2s", or "Failed · exit 1 · 0.9s".
Wires all five +page.svelte stack actions (start/stop/restart/redeploy/
down) plus StackModal's two own action paths (create+start, redeploy on
save) through the same startComposeOutput/finishComposeOutput state, so
every caller of the modal reports success/failure consistently. Without
the StackModal fix, its two actions would have reported every success as
"Failed" (ok defaulting to undefined).
exitCode is read defensively from the job result but StackOperationResult
carries no such field today, so it stays undefined until the server adds
one.
* feat(deploy-logs): add filesystem-backed log store for deploy runs
Provides appendRunLog/readRunLog/deleteRunLog/listRunLogIds/runLogPath
for per-run log files under DATA_DIR/deploy-logs. The run id is
validated (not sanitised) into a filename, so a route-parameter run id
can never resolve outside the log directory. usedBytes/budgetExceeded
compute directory usage against a 2 GiB budget for a later task to
enforce.
* fix(security): keep deploy logs out of the file browser
* test(fs-guard): stop the deploy-log test from leaking DATA_DIR
Bun runs every test file in one process. Setting DATA_DIR at module scope leaked
it into unrelated suites: selfhst-icons builds and creates its cache directory
under DATA_DIR, which fails when that points at a path the machine does not
have. Both selfhst tests went red once this file was added, while each file
passed on its own.
protectedPaths() reads the variable on every call rather than at import time, so
a plain import plus beforeAll/afterAll is enough -- the dynamic import was never
needed, and it was what made the leak module-scoped in the first place.
* fix(logs): let auto-scroll pause while the user reads earlier output
LogViewer's auto-scroll effect jumped to the bottom on every logs update
whenever autoScroll was true, with no way to opt out. In the compose
deploy output window autoScroll stays true for the whole run, so
scrolling up to read an earlier line got immediately overridden by the
next line arriving.
Restore the pause/resume behaviour the removed ComposeOutputModal
output pane used to have: track how close the viewport is to the
bottom on scroll, and skip the auto-scroll effect while the user is
more than 50px away from it. Scrolling back within range resumes it.
The auto-scroll effect's own scrollTop write triggers a 'scroll' event
too, so a flag suppresses handleLogScroll for the duration of that
write -- otherwise a fast enough stream of new lines could grow
scrollHeight again between the write and the event firing, misreading
our own jump as the user having scrolled up.
The distance check is extracted into isScrolledToBottom(), a pure
function, so it has a real test (including the exact-threshold
boundary case).
Also hide the Clear/Refresh buttons when no onClear/onRefresh handler
is supplied -- LogViewer's only current caller (ComposeOutputModal)
passes neither, so both buttons were rendered as inert dead clicks.
* feat(stacks): record only redacted output lines, never the result block
* fix(logs): lift a scroll-up pause when a fresh run starts
userScrolledUp was only ever set, never reset -- so scrolling up once
during a deploy paused auto-scroll for every later run of the same
page session, measured live: heights grew (392->1130px, 446->1130px)
but scrollTop stayed frozen once a prior scroll-up had set the flag,
even on a run nobody had scrolled during.
autoScroll going from false to true is the "a fresh run is starting"
signal (ComposeOutputModal's autoScroll={running} flips false->true at
the exact moment its parent clears composeOutputLines and starts a new
compose run) -- so that edge lifts the pause. Deliberately an edge, not
a level: reacting to autoScroll simply being true would re-lift the
pause on every single update while a run is already in progress,
defeating it entirely. shouldResetScrollPause() is a pure function
again, with a test for exactly that distinction (true -> true must stay
false) alongside the three other transitions.
* fix(logs): avoid state_referenced_locally on previousAutoScroll
npm run check flagged the previous run's seeding line (`let
previousAutoScroll = autoScroll`) as reading a prop in a plain
top-level initializer, which only captures its value once and usually
signals a bug. Leave it unset instead and let the effect seed it on
its first run, where reading autoScroll is tracked -- no behaviour
change, just where the initial read happens.
* feat(sse): hook an optional RunRecorder into both createJobResponse send paths
createJobResponse has two independent send() implementations: the JSON path
(Accept: application/json) and the streaming/job-poll path. A recorder wired
into only one of them would silently miss every deploy driven through the
other -- the UI keeps working, so nobody notices.
Add an optional third parameter, RunRecorder, with line() and end(). Both
send() closures now forward shouldRecord-qualifying lines to recorder.line();
end() is called on every path out of the operation (JSON: in a finally,
awaited before the response is enqueued; streaming: from the .then()/.catch()
that already drive completeJob/failJob, unawaited since the {jobId} response
already went out by the time the operation settles).
The interface is declared here, not built here: constructing a real recorder
needs db.ts, and importing that into sse.ts would make it unimportable under
Bun. Task 10 builds the recorder in the route and imports RunRecorder from
here. Until then both callers omit the third argument, which is exactly the
prior behaviour.
* feat(db): index schedule_executions by entity and environment
Powers per-stack/container deploy-run lookups without a full table scan.
* feat(schedules): register stack_deploy and deploy_log_reconcile types
Widen every schedule-type union that the run record (stack_deploy) and
the reconcile job (deploy_log_reconcile, upcoming) will need: the two
independent unions in src/lib/server/db.ts (ScheduleType) and
src/routes/api/schedules/+server.ts (ScheduleInfo.type, duplicated in
+page.svelte), plus SystemScheduleInfo.type in scheduler/index.ts so the
reconcile job can register as a system schedule alongside system_cleanup.
Give both types real labels in the schedules page instead of falling
into the generic system-job branches: the single-selected filter label,
the two new filter dropdown entries, and the execution detail dialog's
title chain. stack_deploy has no schedule until M3, so its entries are
inert until then -- added now so they aren't forgotten later.
Also cover the two places whose correct behaviour depends on treating
deploy_log_reconcile like the other system schedules: hiding it from
the filter list when "hide system jobs" is toggled, and rejecting an
attempt to pause/resume it through the per-item toggle endpoint the
same way system_cleanup already is (it's a global, settings-driven
schedule, not a per-item cron).
The manual-run endpoint's two type switches are deliberately left
untouched: stack_deploy has no schedule to run, and deploy_log_reconcile
has no trigger function yet (that lands with its own scheduler task).
Wiring a case now would either call nothing or reference a module that
doesn't exist.
npm run check: same 687 pre-existing errors/152 warnings before and
after (confirmed via git stash), only shifted line numbers where lines
were inserted -- no new problems introduced.
* fix(stacks): size the compose output window for build logs
The window was max-w-3xl (48rem) at 60vh. Compose and BuildKit lines are wide
-- step lines, image digests, container names -- so most of them wrapped or
needed horizontal scrolling, which defeats the point of showing them.
Widened to 90rem and 85vh, clamped with min() against the viewport so it still
fits on a laptop screen with a margin instead of overflowing.
* feat(schedules): record every stack_deploy run as a schedule_executions row
Add the run record that the rest of milestone 2 depends on: without it, deploy
logs land on disk with nothing pointing at them, execution history stays empty,
and the upcoming reconciler would treat every log file as orphaned and delete it.
deploy-run-record-core.ts (buildRunDetails, importfree) assembles the details
JSON -- options, summary, exit code, compose/env hash, log file name -- and
refuses to build it without a log file name, since that name is what ties the
record to its file. deploy-run-record.ts (createRunRecorder) is the
database-touching half: creates the schedule_type='stack_deploy',
schedule_id=0 row up front, chains line() calls onto appendRunLog so
concurrent writes can't scramble the log's order, and closes the row in
end() -- which must work even when no line ever arrived, since a deploy can
fail before compose produces any output.
Also switches on deploy-log-store's total-size budget (defined in an earlier
task, never called anywhere until now): once a run's log crosses it, further
lines are dropped, the file gets a line saying so, and details.truncated is
set.
Wires the recorder into both places that call deployStack from an HTTP
handler: the dedicated deploy endpoint and stack-create-with-immediate-start.
Both now read/compute the compose and effective env up front so a compose
hash, env hash, and the schedule_executions row all exist before the SSE job
stream opens -- createJobResponse takes the recorder as an already-resolved
value, not something it can await for itself.
stacks.ts gains an exitCode field on StackOperationResult, populated by the
local/direct compose path (which already had the number, just wasn't
returning it) and left unset on a timeout or over Hawser (protocol doesn't
carry one) -- the recorder falls back to a value consistent with success/
failure when it's missing.
bun test tests/: 2828 pass, 0 fail (baseline 2826 + this task's 2 new tests).
* fix(schedules): keep the deploy summary in sync with what actually got written
line() pushed its argument onto the in-memory buffer synchronously, but only
queued the file write onto the async chain. Called several times before the
chain gets to run any of them (the ordinary case -- compose output arrives in
bursts), a line already sitting in the buffer could still get skipped by the
file write once truncation kicks in partway through the queue, leaving the
eventual summary counting a line that was never actually persisted.
Move the push into the same guarded step as the write so both share one
fate: a line only ever ends up in the summary if it also ended up in the
file. Caught by re-reading the chaining logic after writing it, not by a
test -- verified instead with a live run against a real SQLite db (tsx,
temp DATA_DIR) confirming the three-line/no-line lifecycles still land
correctly. bun test tests/: still 2828 pass, 0 fail.
* feat(stacks): add deploy-run endpoints (list, single, log, delete)
Three routes under api/stacks/[name]/deploys/ each check stacks:view/edit for
themselves -- they do not inherit /api/jobs/{id}'s no-auth model. A shared
loadOwnedDeployRun helper confirms a run actually belongs to the requested
stack and checks environment access against the run's OWN environmentId
(never a caller-supplied env query param), closing the same cross-env bypass
class backups/route-guards.ts already closed for snapshot/config lookups.
getScheduleExecutions gained an entityName filter, the actual consumer of the
(entity_name, environment_id) index from the previous commit.
Also: extracted tests/helpers/db-fake.ts, a shared mock.module() registration
point for $lib/server/db, and moved tests/backups/route-guards.test.ts onto
it. Two test files each wholesale-mocking that module collided (confirmed:
whichever wins clobbers the other's needed export, deterministically) -- the
new helper avoids that by pre-declaring every export name any test needs as a
stable dispatcher, so later-registered implementations are still reachable
regardless of which file's import resolves the specifier first.
* feat(schedules): reconcile deploy-log files against stack_deploy records
Adds a dedicated deploy_log_reconcile schedule, modelled on system-cleanup.ts's
static jobs (own cron + enabled setting, own schedule_executions row) rather
than tacking it onto system_cleanup: the operator can see and disable it
independently.
planReconcile() is a pure decision core: a file with no matching record is
deleted (an orphan with nothing left to explain it); a record with no file is
only marked via details.logMissing, never deleted, since the run metadata
(when, who, what was built) stays valuable once the log text is gone. The
asymmetry is the point -- over-eager deletion here would destroy exactly the
record this job exists to protect.
run/+server.ts gained a deploy_log_reconcile case in both switches (env
resolution and dispatch) -- without it every manual trigger 400s, since both
switches already have a default that rejects unknown types.
ASSUMPTION flagged in deploy-log-reconcile.ts: the record side reads
schedule_executions rows with scheduleType 'stack_deploy' and treats
String(id) as the deploy-log run id. Nothing in this branch creates such a
row yet -- that is Task 10's createRunRecorder, not landed here (confirmed:
no t10/done ref exists). This is read-only against the existing, already
scheduled ScheduleType union, not a substitute for Task 10's write path, but
it is unverified against Task 10's actual output and needs a look once that
lands.
triggerDeployLogReconcile() is a separate function rather than a branch in
triggerSystemJob(): that function dispatches by a flat, type-less jobId
string, and DEPLOY_LOG_RECONCILE_ID (1) would otherwise collide with
SYSTEM_SCHEDULE_CLEANUP_ID (also 1, different scheduleType).
* fix(schedules): don't mark an in-progress deploy's record as logMissing
Read deploy-run-record.ts directly (Task 10's real code, in a sibling clone
not yet merged onto this branch) to check the assumption flagged in the
previous commit -- and it confirmed the record-id linkage is exactly right
(scheduleType 'stack_deploy', String(schedule_executions.id) as the deploy-log
run id), but also surfaced a race this job didn't guard against: the row is
created with status 'running' before the deploy has written a single line,
and the log file only comes into existence on the first line() call. A
reconcile firing in that window would mark an actively-running deploy as
logMissing, and nothing here ever re-checks or clears that flag once the file
does appear -- so the record would carry a false "log missing" permanently.
getScheduleExecutionIdsByType() now also returns status; records still
'queued'/'running' are excluded from markMissing candidacy (a later run will
catch them correctly once the deploy has actually finished) while remaining
in recordIds, so an already-written file for them stays protected from
deletion either way.
* test(schedules): extract the in-progress guard into a tested pure function
isEligibleForMissingMark() replaces the inline NON_TERMINAL_STATUSES array
check in deploy-log-reconcile.ts -- same decision, now importfree and covered
by its own tests (both non-terminal statuses excluded, every terminal status
included), with a mutation counter-test confirmed to go red for the right
reason before being reverted.
* fix(stacks): keep compose output visible below the editor on deploy
Save & redeploy and Create & Start used to hand live output off to a
separate window and close the stack modal immediately, so the editor
and the output were never visible at the same time -- forcing the user
to close the output window just to get back to the compose they were
editing.
The modal now keeps its own output state (lines, running, ok, ms, exit
code) and renders it inline below the editor instead of forwarding to
the page's shared ComposeOutputModal. Whether the modal closes after a
save is now a small pure function, shouldCloseAfterSave(deployed): a
plain save still closes, a deploy stays open so the output stays
visible.
Also fixes a pre-existing bug in the same code path: the error branch
called finishOutput(undefined, false) unconditionally, clobbering the
real ok/exitCode a prior successful finishOutput call had already
recorded when the job itself reported failure. Now guarded on
outputRunning so a real result from the server is never overwritten.
GitStackModal.svelte does not use the onOutput* props and needed no
changes.
* fix(stacks): clear docked deploy output when the modal reopens
StackModal is persistently mounted -- one instance is reused for every
stack. The new output* state introduced for the docked deploy output
was only ever assigned in startOutput(), so it survived close/reopen
just like the pre-existing compose-validate state does. Reopening the
modal for a different stack could show the previous stack's title,
lines and "Succeeded"/"Failed" status under the new stack's editor,
which reads as if something just ran for it.
Reset all seven output* fields in the same open-effect block that
already clears validate panel state on each open, for the same reason.
A reopen of the *same* stack while its own deploy is still running
loses the lines gathered so far, but the poll loop in sse-fetch.ts
keeps running regardless of the modal's open state (same as
ComposeOutputModal), so new lines resume arriving within one poll
interval; and if the job finishes before any new line does,
finishOutput's existing "outputLines.length === 0 && output" fallback
refills the whole transcript from the job's final output. That brief,
self-correcting gap was judged preferable to a stale result silently
misattributed to the wrong stack.
* fix(deploys): require env on the list endpoint to stop cross-environment leaks
GET /api/stacks/[name]/deploys treated `env` as optional. Omitting it left
envIdNum undefined, which skipped the enterprise environment-access check,
made auth.can() evaluate the caller's globally-merged permission instead of
their permission for one specific environment, and dropped the environment
filter from getScheduleExecutions() entirely -- returning stack_deploy runs
(timestamps, status, error text, details.userId) from every environment that
happens to have a same-named stack, regardless of what the caller's role
actually grants.
A stack name alone doesn't identify one environment, so there's no correct
behavior for a missing `env` beyond rejecting the request: parse and validate
it before the permission check runs, so auth.can() and canAccessEnvironment()
always see a definite id. Sibling routes under .../deploys already close this
class via loadOwnedDeployRun (env derived from the loaded run); this was the
one route deriving it from an optional query parameter instead.
* fix(schedules): isolate per-element failures in the reconcile job
Reproduced independently: one throwing deleteRunLog/updateScheduleExecution
call aborted the whole run -- and since the failing element is never
removed/marked, every LATER run failed at the exact same element again,
permanently. A harmless orphan file ordered before the failing one was still
removed; everything after it was not; the failed run's own `details` came
back null, so the partial progress wasn't even visible.
Both loops now wrap each element in its own try/catch (same shape as
scheduler/index.ts's scannerCleanupAllEnvs, which does this per-environment
for the same reason). Failures are counted and logged individually instead
of aborting.
Counts now reflect what actually happened, not what was planned:
deletedFiles/markedRecords only increment on success, plus a new `failed`
count. Previously markedCount was derived from plan.markMissing.length minus
skippedInProgress -- correct only by coincidence, since nothing before this
commit could fail mid-loop; a failing element would have inflated it to the
planned size.
A run with per-element failures is recorded as 'warning' rather than
'success' -- this repo's existing convention for "completed with a caveat"
(see the ScheduleStatus doc comment in db.ts), not 'failed', which stays
reserved for the run itself throwing (the outer catch, unchanged).
Tests (tests/deploy-log-reconcile-job.test.ts, new): one per loop, each with
a mutation counter-test confirmed to fail for the right reason (the element
after the failure was unreached; the reported count matched the plan instead
of the outcome), then reverted. $lib/server/db faked via
tests/helpers/db-fake.ts's shared registry (extended with the five exports
this job needs); $lib/server/deploy-log-store mocked directly since no other
test file claims it.
* fix(sse): reuse endFromResult on the streaming path so a failed run is recorded as failed
A failed docker compose run sends result:{success:false,error} and returns
normally -- it never throws. On the fire-and-forget streaming path (no Accept
header, the path every browser call takes), the .then() branch hardcoded
recorder.end(true) regardless of the result payload, so every such run was
persisted with status:"success" and errorMessage:null.
The JSON path already derives ok/error from the held-back result via
endFromResult(); reuse it here instead of duplicating success/failure logic,
using the result line the .then() branch already reads for completeJob().
* fix(deploy-run-record): redact known secret values out of the stored error text
DeployRunRecorder.end() wrote result.error verbatim into schedule_executions'
errorMessage/details on a failed run. That string is compose's raw, unredacted
stdout/stderr (deploy-recorder.ts's shouldRecord() deliberately excludes the
'result' event from the log file for this exact reason). GET
/api/schedules/executions returns errorMessage/details to every authenticated
user, with no stack/environment permission check -- so a secret that leaked
into compose's error output was readable across permission boundaries.
createRunRecorder() now takes the same merged secrets list its callers already
build for envHash, and end() runs the error text through the same redactLine()
the streamed lines already use before storing it. When redactLine withholds
the whole line (a too-short value matched), the row still closes "failed" with
a safe placeholder instead of no message at all -- a run stuck as "running"
forever is worse than one with a generic error.
* fix(stacks): auto-close after a successful deploy, stay open on failure
Operator decision (30.08.2026), after seeing the docked output live:
always keeping the modal open post-deploy was as wrong as always closing
it used to be. A plain save still closes as before. A deploy that
succeeded now closes too, just after a longer delay so the result is
still glimpsed. A deploy that failed keeps staying open.
shouldCloseAfterSave(deployed: boolean): boolean is replaced by
saveCloseTiming(deployed: boolean, ok: boolean): 'close' | 'close-delayed'
| 'stay-open', so the three operator-specified outcomes are each
individually representable and testable, instead of collapsing "closes"
into one boolean that couldn't distinguish "right away" from "after a
flash".
Verified there is no path at the two call sites where a failed deploy
resolves without throwing first: both POST /api/stacks and PUT
/api/stacks/{name}/compose always send an explicit `result` event with
success:false on failure, with an explicit `return` right after it, so
neither send() call falls through to the success path afterwards. The
'stay-open' branches at both call sites are therefore dead code today,
kept only so the switch stays exhaustive if that ever changes.
* feat(stack-modal): make the live output panel's height resizable
The output panel below the compose editor had a fixed height. Add a
horizontal drag divider (mirroring the existing vertical compose/env
split) so the panel can be resized between 15% and 70% of the combined
editor+output area, persisted to its own localStorage key.
Extract the clamp used by both splitters into clampSplitRatio() and fix
a latent bug along the way: Math.max(min, Math.min(max, value)) does
not clamp when value is NaN (Math.min/Math.max propagate NaN), which
happens for real when a container's measured size is briefly 0 during
a drag. The existing compose/env splitter had the same bug and now
uses the same fixed clamp.
* fix(stacks): record Save & redeploy (compose PUT) as a stack_deploy run
PUT /api/stacks/[name]/compose with restart:true redeployed the stack but
never built a run recorder, unlike the dedicated deploy endpoint and
stack-create-with-immediate-start. After a successful redeploy the modal
now auto-closes (recent change), so this was the only path where the
output of a successful deploy was fully lost -- no schedule_executions
row, no log file, nothing to look back at.
Wires createRunRecorder in identically to deploy/+server.ts: switches the
authoritative-paths lookup from getStackComposeFile to requireComposeFile
(same call, now also resolving secretVars/nonSecretVars), hashes the
just-submitted `content` (not a re-read off disk -- saveStackComposeFile
already wrote this exact string, and it's the same string handed to
deployStack), and hashes the merged effective env for envHash. Skipped
entirely when requireComposeFile unexpectedly fails right after a
successful save, so a run is never recorded with an empty secrets list.
restart:false (save only) is untouched -- no recorder is built, no row is
created.
Adds tests/helpers/authorize-fake.ts, mirroring db-fake.ts's dispatcher
pattern, and moves tests/deploy-endpoints.test.ts's inline authorize mock
onto it -- a second direct mock.module('$lib/server/authorize', ...) in
the new test file would have collided with that file's existing one.
bun test tests/: 2878 pass, 0 fail (baseline 2870 + this task's 8 new
tests). npm run check not yet run.
* fix(stack-modal): clamp the validate-panel width the same way, and rename accordingly
validatePanelWidth = Math.max(320, Math.min(560, w)) had the same NaN
gap as the two split-ratio clamps fixed earlier: if editorRowRef's
measured width is briefly 0 during a drag, w becomes NaN and the
"clamp" stays NaN.
clampSplitRatio never assumed a percentage, only value-vs-bounds
ordering, but its name did -- applying it to a pixel width buried that
under a misleading name. Renamed it (and its module/test file) to
clampNumber, with a doc comment that says plainly it works for both
percentage ratios and pixel distances, and a dedicated test proving
the pixel bounds (320-560) clamp exactly like the percentage bounds.
* feat(stack-modal): add a Deploys tab showing recorded run history
Adds a third history-style tab next to Backups: each recorded
stack-deploy run (GET /api/stacks/{name}/deploys, env required) as a
collapsed row -- status, relative time, duration, and a one-line
container/build summary -- expandable for the full picture: container
counts, whether the build came from cache or not, images built/pulled,
the deploy options used, a truncated-log notice, and on failure the
shortened last error line.
All of that text composition lives in deploy-run-view.ts as pure,
import-free functions (mirrors deploy-summary-core.ts's own reasoning:
no $lib/server import, so the test file runs under Bun without
dragging in better-sqlite3). The one case load-bearing enough to name
in its own doc comment: a run with no summary at all (compose produced
zero output lines) must read as "unknown", never silently as "zero
containers, nothing built" -- those are different facts.
No component test exists for DeploysPanel.svelte itself -- this repo
has no Svelte component test harness (no @testing-library/svelte, no
jsdom; @playwright/test is a dependency with zero test files). Manual
verification only for the component; the 37 new unit tests cover every
branch of the text-composition layer instead.
* perf(deploy-log-store): stop re-scanning deploy-logs/ on every appended line
DeployRunRecorder.line() called budgetExceeded() (a full readdir+stat over
every file in deploy-logs/) after every single line, so a deploy's cost grew
with both its own output AND the number of past runs' log files on disk.
Measured: 200 lines against 500 existing files took ~16.2s; against 50 files
~1.8s; with the scan removed entirely, ~49ms.
Replace that per-line full scan with SizeBudgetTracker: the directory total
excluding this run's own file is measured once, lazily, and cached; each
appended chunk's byte length is added to an in-memory running total (no
stat() involved); the cache is only re-measured once the run's own output
crosses 1 MiB since the last measurement. The budget itself is unchanged
(2 GiB, checked after each write per design doc §5) -- only how often the
directory gets re-scanned to check it.
This trades exactness for speed under concurrent writers: a run started
right after this run's baseline was measured is invisible to this run's
budget check until the next refresh. Documented on SizeBudgetTracker as an
intentional trade-off -- BUDGET_BYTES exists to catch a filled disk, not to
enforce a precise quota, and the old per-line scan had no locking against
concurrent appendRunLog() calls either.
Tests: SizeBudgetTracker's budget math (under/over/already-over-before-first-
byte), its refresh cadence (scans once for 200 small appends where the old
code would have scanned 200 times, and again once a single big append
crosses the refresh threshold), and the documented concurrent-writer gap.
Plus a DeployRunRecorder-level truncation test via an injected tiny-budget
tracker (SizeBudgetTracker now takes optional budgetBytes/measureOthers
params, DeployRunRecorder now takes an optional sizeBudget -- both test-only
seams, unused by production callers).
Refs branch-review.md H2.
* feat(stack-modal): load and show a deploy run's log on expand
Expanding a Deploys-tab row now fetches GET .../deploys/{id}/log into a
LogViewer, lazily -- never while the list itself loads. A run already
marked logMissing by deploy-log-reconcile skips the fetch entirely
instead of making a request that would just 404.
buildDeployLogPanelState (deploy-run-view.ts) turns the run into the
three independent facts the panel needs: whether the log is missing,
whether it was truncated, and whether the run has settled enough to
allow deleting it -- so a missing log never renders as an empty
viewer, and no delete action is offered while a run is still running.
* fix(deploys): scope stacks:view/edit permission checks to the run's own environment (H1)
GET/DELETE .../deploys/{runId} and GET .../deploys/{runId}/log checked
auth.can('stacks', 'view'|'edit') without an environmentId, which falls back
to the caller's globally-merged permission (OR across every role, per every
environment). A caller with stacks:view/edit scoped to environment A could
therefore read, or delete, a deploy run belonging to environment B, as long
as they also had SOME role granting stacks:view/edit somewhere -- and
loadOwnedDeployRun's own canAccessEnvironment check does not close this,
since it only confirms "has some role for this environment", not "has
stacks:view/edit specifically for this environment".
Add a second, run-scoped check via can('stacks', <action>, run.environmentId
?? undefined) AFTER loadOwnedDeployRun resolves the run -- in addition to,
not instead of, the existing un-scoped gate, so the pre-existing "no
permission anywhere" 403 tests keep their exact prior behavior. This is the
same derive-environment-then-check pattern api/git/stacks/[id]/+server.ts and
its env-files route already use for their own nullable environmentId. The
sibling list route (deploys/+server.ts) already documents and closes this
exact bug class for itself; this closes it for the three single-run routes
too.
Regression tests confirm the scoped check is what closes the gap: reverting
just the two source files turns the three "denied" tests red with the
pre-fix symptom (200 instead of 403), while the "allowed" tests stay green.
* fix(deploys): list route treats the local environment (env omitted) as reachable, not a 400 (H3)
On a single (local-only) environment install -- the normal case -- every
deploy run is recorded with environment_id = NULL: createRunRecorder()
writes it that way whenever the triggering request carried no envId, which
is always true for the local environment, because appendEnvParam()
($lib/stores/environment.ts) only appends an `env` query param when envId is
truthy. GET .../deploys required `env` as a parseable integer and 400'd
otherwise -- and there was no integer a caller could pass that matched a
NULL row (env=0 included; environment ids are never 0). The result: on a
fresh, single-environment Dockhand install, EVERY recorded deploy run was
completely unreachable through this endpoint.
The data layer already supported this -- getScheduleExecutions() maps
`environmentId: null` to `isNull(...)` -- only this route couldn't express
"give me the null-environment runs" at all.
Fix: two `env` shapes now both resolve to `environmentId: null` ("the local
environment") -- the parameter OMITTED entirely (the shape the UI actually
sends), and the literal string "null" (for direct/curl callers). Anything
else must still parse as an integer; a genuinely invalid value is still a
hard 400, so "list runs, unspecified environment" never silently means "all
environments merged". The permission check maps the null case to
`can('stacks', 'view', undefined)` -- the same "no environment context"
global-permission check api/git/stacks/[id]/+server.ts already uses for ITS
nullable environmentId -- so a caller still needs stacks:view somewhere to
see local-environment runs; canAccessEnvironment() is skipped for null (there
is no environment record to check access against).
Test coverage updated in the same file the pre-fix "missing env -> 400" and
"cross-environment leak" tests lived in, since both encoded the very
behavior this fixes (the leak test now confirms the null filter itself
still excludes a different, non-null environment's runs -- the security
property that motivated requiring `env` in the first place is unchanged).
Reverting just the route file turns the two H3-labeled tests red with the
pre-fix symptom (400 instead of 200).
* fix(openapi): move loadOwnedDeployRun-produced 400/404 into prose, closing the CI status-code-drift gate (B1)
`npm run generate:openapi:check` (.github/workflows/openapi.yml:49) failed
with 5 Gate-4 (status-code drift) issues: GET/DELETE .../deploys/{runId}
documented resp-400 and resp-404, and GET .../deploys/{runId}/log documented
resp-400, but all three of those specific codes are produced by the SHARED
loadOwnedDeployRun() helper (deploy-run-access.ts) -- not by a literal
`status: 400` / `error(400)` in the handler's OWN body text, which is all
scripts/generate-openapi.ts's static scanner (analyzeHandlerBody) can see.
statusDrift > 0 is a hard failure.
Fix: move that information into the `description:` field as prose instead of
resp-400/resp-404 lines -- the same pattern already used by
POST /api/backup/destinations/{id}/task for its own guard-produced codes.
Nothing is undocumented: the codes and their meaning are still explained,
just not as a formal schema-level response entry the generator expects to
find literally in the handler it's annotating. The log route's resp-404 line
stays as-is (its "Log not found" 404 IS literal in that handler's own body,
alongside loadOwnedDeployRun's 404 which is now covered by the same prose).
Also regenerates src/lib/openapi.generated.json and static/openapi.json
(--check writes both as a side effect of running) -- picks up this fix plus
unrelated, already-existing drift from earlier commits on this branch that
had never been regenerated (the deploy_log_reconcile schedule type additions
to two already-annotated schedule endpoints).
Verified: `npm run generate:openapi:check` was Exit 1 ("Hard failures: 1",
the 5 issues above) before this commit, at this branch's HEAD, in a fresh
clone with `npm install` -- Exit 0 ("Hard failures: 0") after.
tests/deploy-run-openapi-drift.test.ts reproduces Gate 4's own drift check
directly against these three routes' annotations (via the same
parseAnnotations/splitMethodBodies/analyzeHandlerBody the generator itself
uses) rather than shelling out to the full generator, which scans and
rewrites the entire route tree for what is a narrow, three-file regression.
Reverting just the two doc-comment edits reproduces the exact stale-code
sets from the CI report (["400","404"] and ["400"]).
* fix(stack-modal): stop duplicating a failed run's full error text
The expanded row's error box rendered the raw errorMessage (compose's
full stderr) verbatim, right above the new log panel from this same
task -- for a failed run, that meant showing almost the same text
twice. Task 17's own spec called for the shortened last error line
here (the same text the collapsed row already truncates to), not the
raw message; the box now shows view.errorSummary instead. The full
text has exactly one place now: the LogViewer beneath it.
* fix(stack-modal): fetch deploy runs for the local environment too
envId === null is not "no environment selected" -- per the list route's
H3 fix (.../deploys/+server.ts), it IS the local environment: a
single-environment install's own deploy calls never send an env param
either (appendEnvParam only appends one when envId is truthy), so
every one of its runs is recorded with environmentId === null, and the
route now resolves an omitted env to exactly that filter.
The guard that skipped the fetch and showed 'No environment selected.'
predates that fix, from when the endpoint hard-required env and would
400 without it. With the endpoint now accepting the omitted shape, the
guard silently kept the tab empty on the most common install shape
even though the API would have answered. Removed -- onMount fetches
unconditionally, appendEnvParam already produces the right URL for
both the local (null) and a specific (numeric) environment.
No pure-function decision to extract here: the branch this replaced
IS the fix, not a rule with two live outcomes -- appendEnvParam
already forms the correct request for both envId shapes, so there is
nothing left in the component to test beyond what deploy-endpoints.
test.ts already covers at the route.
* feat(git-stacks): record stack_deploy runs for every git-stack deploy trigger
deployGitStack() is the single function every git-stack deploy trigger funnels
through -- the manual Deploy button, a config-update-then-redeploy, both webhook
methods, create-and-deploy, and (via runGitStackSync) the cron scheduler and the
schedules page's "run now". Wire the stack_deploy run recorder in there, not at
a single HTTP route: wiring it only at the route level (the way compose PUT and
the non-git deploy endpoint already do it) would leave every other caller --
crucially the cron- and webhook-triggered ones, the truly unattended runs the
deploy-verlauf design doc's Meilenstein 5 is about -- unrecorded.
composeHash is taken from the content actually synced from git; envHash/secrets
mirror the same (deliberately approximate) DB-var resolution the non-git routes
already use. Every real call site now passes an explicit triggeredBy (manual/
webhook/cron) instead of the previous unconditional default, and the manual
routes forward auth.user?.id and an onLine callback so SSE progress streaming is
unchanged for HTTP callers.
Added tests/helpers/stacks-fake.ts (mirroring db-fake.ts's collision-safe
registration pattern) since syncGitStack -- deployGitStack's sync step -- is a
same-module collaborator that can't be faked via mock.module(), so the new test
drives a real git sync against a local repo served by a real `git daemon`
subprocess, faking only the $lib/server/stacks boundary (deployStack). Migrated
the existing compose-redeploy test to the same shared helper, since a second
direct mock.module('$lib/server/stacks', ...) call would have collided with it.
* docs(backups): note that a restore's redeploy has no stack_deploy run record
Backup restore's redeploy calls redeployStackFromDir directly, bypassing the
deployGitStack()/compose-route path createRunRecorder is wired into -- so it
gets no stack_deploy row and no log file. Left unrecorded on purpose (its own
piece of work, no structural blocker), but that was invisible in the code: no
comment, no marker, nothing to distinguish it from an oversight for a reader
who doesn't already know the history.
* fix(git-stacks): coerce forceRecreate to boolean for the run recorder's options
syncResult.updated is boolean | undefined; DeployRunOptions.forceRecreate wants a
strict boolean. Caught by a differential tsc run against the parent commit (the
only genuinely new type error introduced by the previous commit -- everything
else tsc reported was pre-existing, just line-shifted).
* fix(api): thread build/pull/forceRecreate through stack create & redeploy
POST /api/stacks and PUT /api/stacks/[name]/compose always deployed with
build:false and no pullPolicy, regardless of what the request sent -- a
stack whose compose declares a build: section silently never rebuilt on
"Create & Start" or "Save & redeploy" (the saved compose change just ran
against the stale local image). Both endpoints now accept pull, build,
and forceRecreate in the request body and forward them to deployStack()
and the run recorder, matching the contract the dedicated deploy endpoint
(POST /api/stacks/[name]/deploy) already uses.
PUT .../compose defaults forceRecreate to true when the field is omitted,
preserving its prior hardcoded behavior for a caller that has not been
updated to send it explicitly (env var changes need --force-recreate to
take effect). POST /api/stacks (Create & Start) has no such compatibility
concern -- there is nothing to recreate on a brand new stack -- so it
defaults all three to false when omitted.
pull:false intentionally leaves pullPolicy undefined rather than mapping
to a "never"/"missing" policy: deployStack() treats an undefined
pullPolicy as "do not pull", and its own post-deploy pending-update badge
reconciliation is gated on the same field, so unchecking Pull images also
means a stale "update available" badge on that stack will not clear until
the next pull. Documented at both call sites, not just left implicit.
Regenerated static/openapi.json and src/lib/openapi.generated.json for the
new pull/build/forceRecreate body fields on both @openapi blocks.
* feat(stacks): let Save & redeploy / Create & Start pick pull/build/force-recreate
"Save & redeploy" and "Create & Start" in the stack edit dialog silently
never rebuilt a service with a build: section (see the paired API commit).
Fixing the endpoints alone isn't enough without a way for the operator to
actually ask for a build -- both buttons are now wrapped by RedeployPopover
instead of deploying immediately, offering the same Pull/Build/Force
recreate checkboxes already used for redeploying from the stack grid.
RedeployPopover gains:
- triggerVariant="button" + triggerClass, so it can render as a full
labeled trigger styled like the shadcn Button component (buttonVariants)
instead of only the small icon-only trigger the stack grid rows use.
- defaultPull/defaultBuild/defaultForceRecreate + reason, re-seeded into
the checkboxes on every open (not just once at mount) so a caller whose
default tracks live content -- "does the compose have a build: section
right now" -- stays correct across repeated opens.
"Build images" is pre-checked whenever the compose file declares a build:
section for any service (compose-build-detect.ts, extracted as a plain
function with its own test rather than left inline in the component, the
same way save-close-policy.ts is). "Pull images" defaults to unchecked for
both buttons (unlike the grid's redeploy popover, which defaults it on) --
Save/Create already run against whatever images are already local, pulling
is an explicit extra step here. "Force recreate" defaults to checked for
Save & redeploy only, preserving its prior always-on behavior now that the
API also accepts a caller opting it off; Create & Start defaults it off
since there is nothing to recreate on a brand new stack.
pendingSaveOptions carries the chosen options through the path-change
confirmation dialog the same way pendingSaveRestart already does, so
re-entering handleSave() after the operator confirms a compose/env file
move still deploys with what they actually picked in the popover.
* chore: drop internal planning-doc references from comments
Comments and test descriptions referenced an internal milestone label and
design-doc section number that mean nothing to anyone outside this repo's
own planning process. Reworded to be self-contained -- no behavior change.
* refactor(stacks): split "Save & redeploy" / "Create & Start" into main + arrow
The previous shape had the whole button open the popover before deploying
anything -- correct behavior, wrong ergonomics. The common case (just save
and redeploy, or just create and start, with sane defaults) now takes one
click again: the label is a real, directly clickable button. A separate
small chevron button to its right opens the same Pull/Build/Force recreate
popover for the cases that need it. Two independently focusable <button>
elements sitting flush together, not one element whose behavior depends on
click position.
RedeployPopover's triggerVariant switches from 'button' (removed, was a
label-plus-icon trigger wrapping the whole action) to 'chevron' (a small
square trigger meant to sit attached to a separate main button the caller
renders itself -- the popover no longer owns the main button at all).
saveRedeployDefaults/createStartDefaults in StackModal are the single
source of truth for "what a direct click deploys with", shared between
each main button's own onclick and the popover's defaultPull/defaultBuild/
defaultForceRecreate props next to it, so the one-click path and the
pick-then-Deploy path can never quietly diverge on what "the default"
means.
The two existing icon-only redeploy triggers in the stack grid (RedeployPopover
used there via the unchanged 'icon' variant) are untouched -- they were
already the "click opens a menu" shape by design, since there is no separate
main button in a table row to split off from.
* feat(logs): follow the caller's own theme in LogViewer
LogViewer defaulted to a hardcoded dark theme regardless of the
editor's own light/dark toggle. Add a theme prop (defaulting to
'dark', so any caller that doesn't pass it renders unchanged) and
wire it through the two places that already have an editor theme in
scope: StackModal's docked output panel and, forwarded through a new
theme prop on DeploysPanel, the Deploys tab's expanded run log.
ComposeOutputModal has no editor in scope (opened straight from the
stack list) — its Dialog.Content already follows the app-wide
light/dark switch via bg-background, so it reads that switch once on
mount instead, mirroring the existing pattern in ComposeGraphViewer's
graphTheme.
Search-match highlighting gets a light-mode override, scoped to
.log-viewer-light so it doesn't touch the shared (unscoped)
.search-match rule other components such as LogsPanel still rely on:
the base rule's near-white text is illegible against the pale amber
highlight once the panel itself goes light.
* fix(stacks): pass DeploysPanel's theme prop under its own name
{editorTheme} is Svelte's shorthand for a prop literally named
editorTheme -- DeploysPanel's Props interface declares theme, not
editorTheme, so this silently passed an unknown prop and left theme
at its 'dark' default. svelte-check caught it: 'editorTheme' does not
exist in type 'Props'. bun test doesn't type-check props, which is
why it stayed green through the first commit.
* fix(sse): guard RunRecorder.end() against a second call if it throws
createJobResponse's fire-and-forget path calls recorder.end() in its
.then() branch. If that call itself throws, the rejection lands in
.catch(), which unconditionally called recorder.end() again -- breaking
the RunRecorder contract that end() runs exactly once.
Track whether the .then() branch already reached its end() call and
skip the .catch() call when it did, regardless of whether end() itself
resolved or rejected.
* test(fs-guard): remove redundant DATA_DIR assignment before the import
isProtectedPath() only reads process.env.DATA_DIR lazily inside its own
call, not at module-import time, so the beforeAll/afterAll pin already
in this file is sufficient on its own -- the extra assignment right
before the dynamic import did nothing.
Verified by measurement, not assumption: this file passes standalone,
paired with selfhst-icons.test.ts in both file orders, and as part of
the full suite, all without the removed line.
* refactor(components): move LogViewer out of the logs route
LogViewer.svelte lived under src/routes/logs/ but was never used by
that route (LogsPanel.svelte and +page.svelte render container/system
logs their own way). Its only three consumers -- ComposeOutputModal,
StackModal, and DeploysPanel, all under src/routes/stacks/ -- imported
it cross-route with a relative path, which is what shared components
under src/lib/components/ exist to avoid.
Moved it there and repointed all three imports at the $lib alias. No
internal change: every import inside LogViewer.svelte already used
$lib/... aliases, none were relative to its old location.
* test(types): fix svelte-check type errors in two test files
deploy-run-record-core.test.ts: d.summary is DeploySummary | undefined
on RunDetails, so d.summary.containersStarted possibly-undefined'd.
Switched to optional chaining -- the assertion stays just as strong
since expect(undefined).toBe(2) still fails, the RHS is a hard literal.
deploy-run-view.test.ts: both test.each((input, expected) => ...)
callbacks had their params implicitly typed 'any'. Both formatTrigger
and formatStatusLabel take/return plain strings, so annotated both
params 'string', matching the explicit-annotation style test.each
already uses elsewhere in this repo (stack-filename.test.ts).
* feat(settings): make deploy-log reconcile schedule configurable
setDeployLogReconcileCron/setDeployLogReconcileEnabled existed in db.ts
with no caller outside of it and the scheduler startup path -- there was
no way to disable or reschedule the daily reconcile job (which deletes
orphaned deploy-log files) short of writing to the settings table by
hand. Wire both into the general settings GET/POST route, the client
settings store, and a new toggle+cron block on the General settings
tab, following the existing scannerCleanupCron/scannerCleanupEnabled
pattern in the same files.
refreshSystemJobs() already read these getters before this change; it
just never had anything to react to.
* fix(format): read naive server timestamps as UTC in formatRelativeTime
formatRelativeTime() parsed its input with a bare `new Date(...)`, so a
naive PG timestamp (e.g. "2026-08-30 20:36:11.534" - no "Z"/offset, the
form Drizzle hands back for a `timestamp without time zone` column) got
read as browser-local time instead of UTC. A deploy from a couple of
minutes ago rendered as "2h ago" for anyone outside UTC.
date-format.ts already solved exactly this for formatDateTime() via
parseTimestamp() (#1183), but formatRelativeTime() lives in the
import-light utils/format module and was never switched over, even
though every caller passes the same raw server value to both functions
side by side.
Switch formatRelativeTime() to parseTimestamp() as well. Behavior is
unchanged for Date instances, epoch numbers, and already-zoned strings;
only the previously-mishandled naive-string case is fixed.
Added a TZ-forced (America/New_York) test block that reproduces the
bug and verifies the fix; reverting the change makes those three tests
fail with "in the future" (a 4h forward misinterpretation), confirming
they fail for the right reason.
* fix(tests): inject the reconcile job's log-store calls instead of mocking the module
mock.module() freezes a module's export shape for the whole test process, not
just the file that calls it. The reconcile job's test replaced
'$lib/server/deploy-log-store' with a two-export factory, so every module that
resolved the same specifier afterwards lost the other eight -- deploy-run-record.ts
imports appendRunLog, runLogFileName and SizeBudgetTracker from there and threw
"Export named ... not found".
Whether it triggered depended purely on the order bun walks tests/, which is why
the suite was green locally and red in CI.
The test's own comment reasoned that no other test file claimed that specifier.
That was the wrong question: the collision is with source modules, not test files.
runDeployLogReconcileJob() now takes its two filesystem calls as an optional deps
parameter, defaulting to the real ones. The test passes fakes and no longer
touches the module registry, so there is nothing left to collide.
* fix(deploy): redact provider-resolved secrets from stored deploy errors
Deploy run records (schedule_executions.errorMessage, readable by every
authenticated user via GET /api/schedules/executions) were redacted only
against the secret list a caller had at recorder-construction time --
DB-only nonSecretVars/secretVars, captured BEFORE deployStack() calls
resolveProviderEnvVars() and merges in the bound secret provider's
values (Bitwarden/1Password/Keepass/Proton/Vault/Infisical bulk pulls or
inline refs). A provider-resolved secret that surfaced in compose's raw
stderr therefore passed straight through DeployRunRecorder.end()'s
redaction and into errorMessage unredacted.
Add StackOperationResult.resolvedSecrets, populated once in deployStack()
(the single call site after resolveProviderEnvVars, covering both the
local and Hawser paths) from the post-resolution secretVars -- the same
set executeComposeCommand already redacts streamed lines against. Add
RunRecorder.addSecrets()/DeployRunRecorder.addSecrets() to extend the
redaction list after construction, and call it from every stack_deploy
run-record caller (the three createRunRecorder-based API routes and
deployGitStack, the funnel every git-stack deploy trigger -- including
the unattended cron/webhook ones -- goes through) right after
deployStack() returns and before the recorder is closed.
Regression tests assert on the stored value, not just field presence,
and were verified against a temporarily neutered addSecrets() (the
secret leaked verbatim, confirming they fail for the right reason)
before being restored to pass.
* fix(deploy-logs): scope deploy-log directory and size budget per environment
Before this fix, deploy-log-store.ts kept ALL environments' deploy-run log
files in a single shared deploy-logs/ directory and enforced a single
BUDGET_BYTES total across all of them (SizeBudgetTracker). A user with
deploy rights in one environment could fill that shared budget with a noisy
deploy, silently truncating deploy logs and summaries belonging to every
other environment -- a multi-tenant audit-trail integrity violation.
- deploy-log-store.ts: every path/size function now takes an `envId` and
resolves to deploy-logs/<envDirName(envId)>/ (envDirName(null) => "null").
SizeBudgetTracker measures "everything else" scoped to its own run's
environment only. Added envDirName/parseEnvDirName, listEnvDirNames() for
reconcile-job discovery, and a readRunLog() fallback to the pre-F5 flat
path (unreleased feature, no production data in that layout -- fallback
exists purely as a robustness measure).
- deploy-run-record.ts: DeployRunRecorder threads its envId through to
appendRunLog() and its default SizeBudgetTracker.
- db.ts: getScheduleExecutionIdsByType() now also returns environmentId, so
the reconcile job can group records per environment.
- deploy-log-reconcile.ts: reconciles PER ENVIRONMENT (union of directories
on disk and environments referenced by records), so an orphan file in one
environment can never be deleted -- or a record marked logMissing --
because of a same-numbered file/record in a different environment.
- Both single-run routes (GET/DELETE .../deploys/[runId], GET .../log) now
pass run.environmentId through to readRunLog()/deleteRunLog().
Tests: new cross-environment isolation coverage at the SizeBudgetTracker,
DeployRunRecorder, and reconcile-job levels, each proven against a reverted
(shared-directory) mutation to confirm it fails for the right reason before
being reverted back. Full suite: 3011 pass / 0 fail (was 2987 pass / 0 fail).
* fix(deploy): forbid deleting a non-terminal deploy run
DELETE /api/stacks/[name]/deploys/[runId] deleted the schedule_executions
row and the on-disk log file with no check on the run's status, so a
caller could delete their OWN still-running deploy's record. The
background deploy process keeps going after that: recorder.line()
recreates the just-deleted log file via appendRunLog(), recorder.end()'s
updateScheduleExecution() becomes a silent no-op (UPDATE WHERE id=x, 0
rows), and the default-enabled reconcile job then treats the recreated
file as an orphan and purges it -- erasing every trace of a deploy that
actually ran and had real host side effects.
Add isTerminalRunStatus() (deploy-run-access.ts) as an explicit allow-list
of the run-closing statuses (success/failed/skipped/warning/error/
cancelled/stale) and use it to gate the DELETE handler: a non-terminal
status (queued/running, or anything this code doesn't recognize -- the
status column is free text) now returns 409 and touches neither the log
file nor the database row. The check runs after the existing
ownership/permission checks, so an unauthorized or wrong-stack caller
still gets 403/404 rather than learning the run's status via a 409.
Deliberately its own allow-list rather than reusing
deploy-log-reconcile-core.ts's isEligibleForMissingMark(): that helper
fails OPEN for an unrecognized status because marking logMissing is
reversible metadata-only. Deleting a run is not reversible, so this
check fails CLOSED instead.
* test(deploy): guard that deployStack records the secret set it redacts against
A review found F4's single production line -- stacks.ts's
`result.resolvedSecrets = Object.values(secretVars)` -- had zero coverage:
deleting it left the whole targeted suite green. The two tests that reference
`resolvedSecrets` fake deployStack wholesale and hand-set the field, proving the
downstream recorder wiring but never that the real stacks.ts populates it.
A behaviour test of the real deployStack() is infeasible in this suite, established
empirically, not assumed:
- tests/helpers/stacks-fake.ts mock.module()s $lib/server/stacks at import time,
freezing that specifier for the whole Bun process; two suites load it, so
`import { deployStack }` resolves to the fake for the entire run, and Bun has no
per-file process isolation to escape it.
- pulling the real $lib/server/stacks into a test's import graph instantiates
better-sqlite3, which Bun does not support and crashes at load (reproduced).
So this follows the pattern the suite already uses for deployStack-internal
invariants (deploy-stack-online.test.ts, stack-create-start-build-options.test.ts):
a source guard, with comments stripped before matching and a vacuity guard. It pins
the wiring resolveProviderEnvVars -> secretVars -> executeComposeCommand (redaction)
AND -> result.resolvedSecrets (recording). Counter-tested: each assertion goes red
for its own mutation, including the exact deletion the review demonstrated.
* chore(openapi): regenerate spec after rebase onto upstream/main
* test(stacks): fake requireEnvAccess in the compose-endpoint auth stub
The old merge commit into this branch had manually patched this fake to
add requireEnvAccess (compose/+server.ts calls it, and authEnabled is
false here so the real implementation is a no-op) -- that fixup lived
only in the merge commit itself, not in any single commit, so a plain
rebase onto upstream/main dropped it. Restored; full suite is green
(3212 pass, 0 fail).
* docs(openapi): document deployLogReconcileCron/Enabled in the settings body
upstream's new Gate 7 (body-field drift check) caught that this PR's
deployLogReconcileCron/deployLogReconcileEnabled fields were read in
POST /api/settings/general but never listed in its @openapi body
annotation. Documented them and regenerated the spec; all openapi
gates and the full test suite (3221 pass, 0 fail) are green.
* fix(deploy-log-reconcile): protect an in-flight deploy's file from a TOCTOU orphan-delete
The reconcile job snapshots stack_deploy records once via
getScheduleExecutionIdsByType() before walking each environment's files.
A deploy whose record is created after that snapshot has no matching id
yet, but its log file can already exist by the time this environment's
listRunLogIds() runs -- indistinguishable from a genuine orphan by id
alone, and previously deleted on the spot while still being written to.
Add getRunLogMtimeMs() (deploy-log-store.ts) and use it as a second
signal alongside the records snapshot: a delete candidate whose file was
modified within the last 5 minutes is left alone this run instead of
deleted, since appendRunLog() keeps bumping a live deploy's mtime for as
long as it keeps writing. A later reconcile run will see the by-then-
created record and correctly never touch the file again -- nothing is
lost, it is just given one more cycle to prove itself real, the same
trade-off isEligibleForMissingMark() already makes on the other side of
this race.
* fix(git): record a stack_deploy run for the UI's manual deploy button
deployGitStack's doc comment claimed it was the single function every
git-stack deploy trigger funnels through, and wired the stack_deploy run
recorder in there on that basis. deployGitStackWithProgress -- the UI's
"Deploy" button (deploy-stream/+server.ts) -- runs its own clone/read/
deploy pipeline instead of calling deployGitStack (it needs its own
discrete onProgress stages, not deployGitStack's onLine streaming), so
it was the one caller that reached deployStack() without ever going
through createRunRecorder: webhook, cron, and config-update-triggered
deploys were recorded, the manual button was not.
Wire the same recorder setup into deployGitStackWithProgress, right
before its own deployStack() call, mirroring deployGitStack's shape
(composeHash/envHash/secrets from the DB-only vars, addSecrets() for the
provider-resolved values deployStack() only reveals after it returns,
end() closing the row on every path out). onLine mirrors output into the
run's log file without being forwarded to onProgress, so the existing
progress-stage streaming is untouched.
Corrected deployGitStack's doc comment, which is no longer accurate now
that a second call site exists, and extended the existing source-level
guard test (previously asserting exactly one createRunRecorder call in
the whole file) to check both functions individually.
This commit is contained in:
1 parent
be10bb098c
commit
b2eb0b5dcb
104 files changed
+17212
-605
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX "schedule_executions_entity_env_idx" ON "schedule_executions" USING btree ("entity_name","environment_id");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1787468505425,
|
"when": 1787468505425,
|
||||||
"tag": "0014_backup_tls_and_container_icons",
|
"tag": "0014_backup_tls_and_container_icons",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788099526949,
|
||||||
|
"tag": "0015_deploy_run_index",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX `schedule_executions_entity_env_idx` ON `schedule_executions` (`entity_name`,`environment_id`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1787468495122,
|
"when": 1787468495122,
|
||||||
"tag": "0014_backup_tls_and_container_icons",
|
"tag": "0014_backup_tls_and_container_icons",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788099517358,
|
||||||
|
"tag": "0015_deploy_run_index",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
|
||||||
|
import { wrapHtmlLines } from '$lib/utils/log-lines';
|
||||||
|
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
|
import { downloadFileName, stripAnsi } from '$lib/utils/log-download-name';
|
||||||
|
import { isScrolledToBottom, shouldResetScrollPause } from '$lib/utils/scroll-position';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||||
|
import { themeStore } from '$lib/stores/theme';
|
||||||
|
import { getMonospaceFont } from '$lib/themes';
|
||||||
|
import { AnsiUp } from 'ansi_up';
|
||||||
|
const ansiUp = new AnsiUp();
|
||||||
|
ansiUp.use_classes = true;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: string;
|
||||||
|
title?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
autoRefresh?: boolean;
|
||||||
|
autoScroll?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
onAutoRefreshChange?: (value: boolean) => void;
|
||||||
|
onAutoScrollChange?: (value: boolean) => void;
|
||||||
|
class?: string;
|
||||||
|
// Follows the caller's own light/dark switch, independent of the app-wide
|
||||||
|
// theme -- e.g. the editor theme toggle in StackModal (`dockhand-editor-theme`).
|
||||||
|
// Defaults to 'dark' so every existing caller that doesn't pass it renders
|
||||||
|
// exactly as before.
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
logs,
|
||||||
|
title,
|
||||||
|
loading = false,
|
||||||
|
autoRefresh = true,
|
||||||
|
autoScroll = true,
|
||||||
|
onRefresh,
|
||||||
|
onClear,
|
||||||
|
onAutoRefreshChange,
|
||||||
|
onAutoScrollChange,
|
||||||
|
class: className = '',
|
||||||
|
theme = 'dark'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let dark = $derived(theme === 'dark');
|
||||||
|
|
||||||
|
let logsRef: HTMLDivElement;
|
||||||
|
let wordWrap = $state(true);
|
||||||
|
let showLineNumbers = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
|
||||||
|
let fontSize = $state(12);
|
||||||
|
|
||||||
|
// RAF-based auto-scroll
|
||||||
|
let scrollRafPending = false;
|
||||||
|
// True once the user has scrolled up to read earlier output. Cleared again once
|
||||||
|
// they scroll back within range of the end, at which point auto-scroll resumes.
|
||||||
|
let userScrolledUp = $state(false);
|
||||||
|
// Set for the duration of our own programmatic scrollTop write, so the 'scroll'
|
||||||
|
// event it triggers isn't mistaken for the user scrolling. Without this, a fast
|
||||||
|
// enough stream of new lines can grow scrollHeight again between the write and
|
||||||
|
// the event firing, leaving a gap wide enough to look like the user scrolled up.
|
||||||
|
let isAutoScrolling = false;
|
||||||
|
// Tracks the previous value of the autoScroll prop, purely so the effect below can
|
||||||
|
// tell a false -> true edge (a fresh run starting) apart from autoScroll simply
|
||||||
|
// staying true across renders. Left unset here rather than seeded from `autoScroll`
|
||||||
|
// directly -- reading a prop in a plain top-level initializer only captures its
|
||||||
|
// value at that one point in time, which svelte-check flags (state_referenced_locally)
|
||||||
|
// because it usually signals a bug. The effect below seeds it on its first run instead,
|
||||||
|
// where reading `autoScroll` is tracked.
|
||||||
|
let previousAutoScroll: boolean | undefined;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let logSearchActive = $state(false);
|
||||||
|
let logSearchQuery = $state('');
|
||||||
|
let logSearchFilterMode = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-filter-mode') === 'true');
|
||||||
|
let currentMatchIndex = $state(0);
|
||||||
|
let matchCount = $state(0);
|
||||||
|
let logSearchInputRef: HTMLInputElement;
|
||||||
|
|
||||||
|
const fontSizeOptions = [10, 12, 14, 16];
|
||||||
|
|
||||||
|
// Get terminal font family from theme preferences
|
||||||
|
let terminalFontFamily = $derived(() => {
|
||||||
|
const fontMeta = getMonospaceFont($themeStore.terminalFont);
|
||||||
|
return fontMeta?.family || 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
|
||||||
|
});
|
||||||
|
|
||||||
|
// A fresh run starting (autoScroll going false -> true) lifts a scroll-up pause left
|
||||||
|
// over from an earlier run -- otherwise scrolling up once during a deploy pauses
|
||||||
|
// auto-scroll for every later run of the same page session, since nothing else ever
|
||||||
|
// clears userScrolledUp.
|
||||||
|
$effect(() => {
|
||||||
|
if (previousAutoScroll !== undefined && shouldResetScrollPause(previousAutoScroll, autoScroll)) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
}
|
||||||
|
previousAutoScroll = autoScroll;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll when logs change, unless the user scrolled up to read earlier output.
|
||||||
|
$effect(() => {
|
||||||
|
if (autoScroll && !userScrolledUp && logsRef && logs) {
|
||||||
|
if (!scrollRafPending) {
|
||||||
|
scrollRafPending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (logsRef) {
|
||||||
|
isAutoScrolling = true;
|
||||||
|
logsRef.scrollTop = logsRef.scrollHeight;
|
||||||
|
// Let the 'scroll' event this write triggers reach handleLogScroll (and be
|
||||||
|
// ignored there) before treating further events as user-initiated again.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isAutoScrolling = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scrollRafPending = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pause auto-scroll once the user scrolls up, resume once they scroll back down.
|
||||||
|
function handleLogScroll() {
|
||||||
|
if (isAutoScrolling || !logsRef) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = logsRef;
|
||||||
|
userScrolledUp = !isScrolledToBottom(scrollTop, scrollHeight, clientHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy logs to clipboard
|
||||||
|
async function copyLogs() {
|
||||||
|
if (logs) {
|
||||||
|
await copyToClipboard(stripAnsi(logs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download logs as txt file
|
||||||
|
function downloadLogs() {
|
||||||
|
if (logs) {
|
||||||
|
const blob = new Blob([stripAnsi(logs)], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = downloadFileName(title);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search functions
|
||||||
|
function toggleLogSearch() {
|
||||||
|
logSearchActive = !logSearchActive;
|
||||||
|
if (logSearchActive) {
|
||||||
|
setTimeout(() => logSearchInputRef?.focus(), 50);
|
||||||
|
} else {
|
||||||
|
logSearchQuery = '';
|
||||||
|
currentMatchIndex = 0;
|
||||||
|
matchCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogSearch() {
|
||||||
|
logSearchActive = false;
|
||||||
|
logSearchQuery = '';
|
||||||
|
logSearchFilterMode = false;
|
||||||
|
currentMatchIndex = 0;
|
||||||
|
matchCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSearchFilterMode() {
|
||||||
|
logSearchFilterMode = !logSearchFilterMode;
|
||||||
|
localStorage.setItem('dockhand-log-filter-mode', String(logSearchFilterMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateMatch(direction: 'prev' | 'next') {
|
||||||
|
if (!logsRef || matchCount === 0) return;
|
||||||
|
|
||||||
|
const matches = logsRef.querySelectorAll('.search-match');
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
|
||||||
|
matches[currentMatchIndex]?.classList.remove('current-match');
|
||||||
|
|
||||||
|
if (direction === 'next') {
|
||||||
|
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
|
||||||
|
} else {
|
||||||
|
currentMatchIndex = (currentMatchIndex - 1 + matches.length) % matches.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentEl = matches[currentMatchIndex];
|
||||||
|
if (currentEl) {
|
||||||
|
currentEl.classList.add('current-match');
|
||||||
|
currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogSearchKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
navigateMatch('prev');
|
||||||
|
} else {
|
||||||
|
navigateMatch('next');
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
closeLogSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlighted logs with search matches and ANSI color support
|
||||||
|
let highlightedLogs = $derived(() => {
|
||||||
|
let text = logs || '';
|
||||||
|
if ($appSettings.formatLogTimestamps) {
|
||||||
|
text = formatLogTimestamps(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = logSearchQuery.trim();
|
||||||
|
|
||||||
|
// Filter lines before ANSI conversion (plain text matching)
|
||||||
|
if (logSearchFilterMode && query) {
|
||||||
|
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const filterRegex = new RegExp(escapedForRegex, 'i');
|
||||||
|
const lines = text.split('\n');
|
||||||
|
text = lines.filter(line => filterRegex.test(line)).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAnsi = ansiUp.ansi_to_html(text);
|
||||||
|
if (!query) return withAnsi;
|
||||||
|
|
||||||
|
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const escapedQuery = escapedForRegex.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Split by HTML tags and only process text parts
|
||||||
|
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||||
|
return parts.map(part => {
|
||||||
|
if (part.startsWith('<')) return part;
|
||||||
|
return part.replace(new RegExp(`(${escapedQuery})`, 'gi'), '<mark class="search-match">$1</mark>');
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update match count after render
|
||||||
|
$effect(() => {
|
||||||
|
const html = highlightedLogs();
|
||||||
|
|
||||||
|
if (logSearchQuery && logsRef) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const matches = logsRef.querySelectorAll('.search-match');
|
||||||
|
matchCount = matches.length;
|
||||||
|
currentMatchIndex = 0;
|
||||||
|
if (matches.length > 0) {
|
||||||
|
matches[0].classList.add('current-match');
|
||||||
|
matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
matchCount = 0;
|
||||||
|
currentMatchIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col rounded-lg border overflow-hidden {dark ? 'log-viewer-dark bg-zinc-950 border-zinc-800' : 'log-viewer-light bg-gray-50 border-gray-300'} {className}">
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0 {dark ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if loading}
|
||||||
|
<RefreshCw class="w-3 h-3 animate-spin {dark ? 'text-zinc-500' : 'text-gray-400'}" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Auto-refresh button -->
|
||||||
|
<button
|
||||||
|
onclick={() => onAutoRefreshChange?.(!autoRefresh)}
|
||||||
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoRefresh ? (dark ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : dark ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
|
||||||
|
title="Toggle auto-refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<!-- Auto-scroll button -->
|
||||||
|
<button
|
||||||
|
onclick={() => onAutoScrollChange?.(!autoScroll)}
|
||||||
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoScroll ? (dark ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : dark ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
|
||||||
|
title="Toggle auto-scroll"
|
||||||
|
>
|
||||||
|
<ArrowDownToLine class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<!-- Font size -->
|
||||||
|
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => fontSize = Number(v)}>
|
||||||
|
<Select.Trigger class="h-6 w-16 text-xs px-1.5 {dark ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
|
||||||
|
<Type class="w-3 h-3 mr-1 {dark ? 'text-zinc-400' : 'text-gray-400'}" />
|
||||||
|
<span>{fontSize}px</span>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each fontSizeOptions as size}
|
||||||
|
<Select.Item value={String(size)} label="{size}px">
|
||||||
|
<Type class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||||
|
{size}px
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<!-- Word wrap -->
|
||||||
|
<button
|
||||||
|
onclick={() => wordWrap = !wordWrap}
|
||||||
|
class="p-1 rounded transition-colors {wordWrap ? (dark ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Toggle word wrap"
|
||||||
|
>
|
||||||
|
<WrapText class="w-3 h-3 transition-colors {wordWrap ? (dark ? 'text-amber-400' : 'text-amber-700') : dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
<!-- Line numbers -->
|
||||||
|
<button
|
||||||
|
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
|
||||||
|
class="p-1 rounded transition-colors {showLineNumbers ? (dark ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
||||||
|
>
|
||||||
|
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (dark ? 'text-amber-400' : 'text-amber-700') : dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
<!-- Search -->
|
||||||
|
{#if logSearchActive}
|
||||||
|
<div class="flex items-center gap-1 rounded px-1.5 py-0.5 {dark ? 'bg-zinc-800' : 'bg-gray-200'}">
|
||||||
|
<Search class="w-3 h-3 {dark ? 'text-amber-400' : 'text-amber-700'}" />
|
||||||
|
<input
|
||||||
|
bind:this={logSearchInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
bind:value={logSearchQuery}
|
||||||
|
onkeydown={handleLogSearchKeydown}
|
||||||
|
class="bg-transparent border-none outline-none text-xs w-20 {dark ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={toggleSearchFilterMode}
|
||||||
|
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (dark ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : dark ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}"
|
||||||
|
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
|
||||||
|
>
|
||||||
|
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (dark ? 'text-amber-400' : 'text-amber-700') : dark ? 'text-zinc-400' : 'text-gray-500'}" />
|
||||||
|
</button>
|
||||||
|
{#if matchCount > 0}
|
||||||
|
<span class="text-xs {dark ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
||||||
|
{:else if logSearchQuery}
|
||||||
|
<span class="text-xs {dark ? 'text-zinc-500' : 'text-gray-400'}">0/0</span>
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => navigateMatch('prev')} class="p-0.5 rounded {dark ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Previous">
|
||||||
|
<ChevronUp class="w-3 h-3 {dark ? 'text-zinc-400' : 'text-gray-500'}" />
|
||||||
|
</button>
|
||||||
|
<button onclick={() => navigateMatch('next')} class="p-0.5 rounded {dark ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Next">
|
||||||
|
<ChevronDown class="w-3 h-3 {dark ? 'text-zinc-400' : 'text-gray-500'}" />
|
||||||
|
</button>
|
||||||
|
<button onclick={closeLogSearch} class="p-0.5 rounded {dark ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}" title="Close">
|
||||||
|
<X class="w-3 h-3 {dark ? 'text-zinc-400' : 'text-gray-500'}" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={toggleLogSearch}
|
||||||
|
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Search logs"
|
||||||
|
>
|
||||||
|
<Search class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- Copy -->
|
||||||
|
<button
|
||||||
|
onclick={copyLogs}
|
||||||
|
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Copy logs"
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
<!-- Download -->
|
||||||
|
<button
|
||||||
|
onclick={downloadLogs}
|
||||||
|
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Download logs"
|
||||||
|
>
|
||||||
|
<Download class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
<!-- Clear -->
|
||||||
|
{#if onClear}
|
||||||
|
<button
|
||||||
|
onclick={() => onClear?.()}
|
||||||
|
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Clear logs"
|
||||||
|
>
|
||||||
|
<Eraser class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- Refresh -->
|
||||||
|
{#if onRefresh}
|
||||||
|
<button
|
||||||
|
onclick={() => onRefresh?.()}
|
||||||
|
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
|
||||||
|
title="Refresh logs"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs content -->
|
||||||
|
<div bind:this={logsRef} onscroll={handleLogScroll} class="flex-1 overflow-auto p-4">
|
||||||
|
{#if logs}
|
||||||
|
<pre class="{dark ? 'text-zinc-50' : 'text-gray-900'} {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="flex items-center justify-center h-full {dark ? 'text-zinc-500' : 'text-gray-500'}">
|
||||||
|
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
|
||||||
|
Loading logs...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm {dark ? 'text-zinc-500' : 'text-gray-500'}">No logs available</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.search-match) {
|
||||||
|
background-color: rgba(234, 179, 8, 0.4);
|
||||||
|
color: #fef3c7;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 2px;
|
||||||
|
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
|
||||||
|
}
|
||||||
|
:global(.search-match.current-match) {
|
||||||
|
background-color: rgba(234, 179, 8, 0.8);
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
|
||||||
|
outline: 2px solid rgb(250, 204, 21);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The base .search-match rule above pairs a pale amber background with
|
||||||
|
near-white text (#fef3c7) -- fine against the dark log background this
|
||||||
|
component defaults to, but low-contrast once the panel switches to its
|
||||||
|
own light theme (see the `theme` prop). Scoped to .log-viewer-light so it
|
||||||
|
never touches the dark styling above or other components sharing the
|
||||||
|
(unscoped) .search-match class, such as LogsPanel. Higher specificity than
|
||||||
|
the base rule -- (0,2,0) vs (0,1,0) -- so it wins regardless of source order.
|
||||||
|
.current-match is left alone: its 0.8 background alpha already composites to
|
||||||
|
a near-solid amber against either page background, so the dark (#1a1a1a) text
|
||||||
|
it uses stays legible without a light-mode override. */
|
||||||
|
:global(.log-viewer-light .search-match) {
|
||||||
|
color: #78350f;
|
||||||
|
box-shadow: 0 0 4px rgba(217, 119, 6, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22110,7 +22110,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)"
|
"description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -22184,7 +22184,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)"
|
"description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -22224,7 +22224,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)"
|
"description": "Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused)"
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Schedule, backup config, or backup destination not found"
|
"description": "Schedule, backup config, or backup destination not found"
|
||||||
@@ -23777,6 +23777,12 @@
|
|||||||
"defaultTrivyImage": {
|
"defaultTrivyImage": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"deployLogReconcileCron": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deployLogReconcileEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"downloadFormat": {
|
"downloadFormat": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -23895,6 +23901,8 @@
|
|||||||
"defaultScannerNetworkMode": "string",
|
"defaultScannerNetworkMode": "string",
|
||||||
"defaultTrivyArgs": "string",
|
"defaultTrivyArgs": "string",
|
||||||
"defaultTrivyImage": "string",
|
"defaultTrivyImage": "string",
|
||||||
|
"deployLogReconcileCron": "string",
|
||||||
|
"deployLogReconcileEnabled": true,
|
||||||
"downloadFormat": "string",
|
"downloadFormat": "string",
|
||||||
"editorFont": "string",
|
"editorFont": "string",
|
||||||
"eventCleanupCron": "string",
|
"eventCleanupCron": "string",
|
||||||
@@ -24675,6 +24683,15 @@
|
|||||||
},
|
},
|
||||||
"environmentId": {
|
"environmentId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pull": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"forceRecreate": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -24694,7 +24711,10 @@
|
|||||||
"secretProviderId": 0,
|
"secretProviderId": 0,
|
||||||
"start": true,
|
"start": true,
|
||||||
"envId": 0,
|
"envId": 0,
|
||||||
"environmentId": 0
|
"environmentId": 0,
|
||||||
|
"pull": true,
|
||||||
|
"build": true,
|
||||||
|
"forceRecreate": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25036,6 +25056,15 @@
|
|||||||
},
|
},
|
||||||
"secretProviderId": {
|
"secretProviderId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pull": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"forceRecreate": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -25050,7 +25079,10 @@
|
|||||||
"oldEnvPath": "string",
|
"oldEnvPath": "string",
|
||||||
"moveFromDir": "string",
|
"moveFromDir": "string",
|
||||||
"restart": true,
|
"restart": true,
|
||||||
"secretProviderId": 0
|
"secretProviderId": 0,
|
||||||
|
"pull": true,
|
||||||
|
"build": true,
|
||||||
|
"forceRecreate": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25221,6 +25253,357 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/stacks/{name}/deploys": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "env",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string \"null\", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"runs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"triggeredBy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggeredAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"errorMessage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"triggeredBy",
|
||||||
|
"triggeredAt",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"runs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"environmentId": 0,
|
||||||
|
"triggeredBy": "string",
|
||||||
|
"triggeredAt": "string",
|
||||||
|
"startedAt": "string",
|
||||||
|
"completedAt": "string",
|
||||||
|
"duration": 0,
|
||||||
|
"status": "string",
|
||||||
|
"errorMessage": "string",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "env query parameter must be an integer, or omitted/\"null\" for the local environment"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view), or access denied to this environment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stacks/{name}/deploys/{runId}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys_runId",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Get a single deploy run's metadata (no log text -- see GET .../{runId}/log)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"triggeredBy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggeredAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"errorMessage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"triggeredBy",
|
||||||
|
"triggeredAt",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"id": 0,
|
||||||
|
"environmentId": 0,
|
||||||
|
"triggeredBy": "string",
|
||||||
|
"triggeredAt": "string",
|
||||||
|
"startedAt": "string",
|
||||||
|
"completedAt": "string",
|
||||||
|
"duration": 0,
|
||||||
|
"status": "string",
|
||||||
|
"errorMessage": "string",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description)."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "delete_api_stacks_name_deploys_runId",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Delete a deploy run's database record and its on-disk log file",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"success"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "The run has not finished yet (status is not a terminal one) -- deleting a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stacks/{name}/deploys/{runId}/log": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys_runId_log",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Get a deploy run's log text (text/plain, from the on-disk file)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Plain-text log content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Deploy run not found (also returned for a run that belongs to a different stack), or log file missing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's \"run not found / belongs to a different stack\" AND this handler's own \"log file missing\" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/stacks/{name}/down": {
|
"/api/stacks/{name}/down": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_api_stacks_name_down",
|
"operationId": "post_api_stacks_name_down",
|
||||||
|
|||||||
@@ -969,6 +969,12 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce
|
|||||||
log(`registered: composePath=${composePath} envPath=${envPath ?? '(none)'}`);
|
log(`registered: composePath=${composePath} envPath=${envPath ?? '(none)'}`);
|
||||||
},
|
},
|
||||||
deploy: async () => {
|
deploy: async () => {
|
||||||
|
// Deliberately does NOT create a stack_deploy run record: this call goes
|
||||||
|
// straight to redeployStackFromDir (stacks.ts), not through the
|
||||||
|
// deployGitStack()/compose-route path that createRunRecorder is wired
|
||||||
|
// into. A backup restore's redeploy could get its own recorder the same
|
||||||
|
// way -- there is nothing structural stopping it -- it just hasn't been
|
||||||
|
// done yet.
|
||||||
const r = await redeployStackFromDir(name, stackDir, composeFileName, envId ?? undefined);
|
const r = await redeployStackFromDir(name, stackDir, composeFileName, envId ?? undefined);
|
||||||
if (!r.success) throw new Error(r.error || 'docker compose up failed');
|
if (!r.success) throw new Error(r.error || 'docker compose up failed');
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-1
@@ -4276,7 +4276,7 @@ export async function saveDashboardPreferences(data: {
|
|||||||
// SCHEDULE EXECUTION OPERATIONS
|
// SCHEDULE EXECUTION OPERATIONS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore';
|
export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore' | 'stack_deploy' | 'deploy_log_reconcile';
|
||||||
export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup';
|
export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup';
|
||||||
export type ScheduleStatus =
|
export type ScheduleStatus =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -4336,6 +4336,10 @@ export interface ScheduleExecutionFilters {
|
|||||||
scheduleType?: ScheduleType;
|
scheduleType?: ScheduleType;
|
||||||
scheduleId?: number;
|
scheduleId?: number;
|
||||||
environmentId?: number | null;
|
environmentId?: number | null;
|
||||||
|
// 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.
|
||||||
|
entityName?: string;
|
||||||
status?: ScheduleStatus;
|
status?: ScheduleStatus;
|
||||||
statuses?: ScheduleStatus[];
|
statuses?: ScheduleStatus[];
|
||||||
triggeredBy?: ScheduleTrigger;
|
triggeredBy?: ScheduleTrigger;
|
||||||
@@ -4465,6 +4469,9 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
|
|||||||
conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId));
|
conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (filters.entityName !== undefined) {
|
||||||
|
conditions.push(eq(scheduleExecutions.entityName, filters.entityName));
|
||||||
|
}
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
conditions.push(eq(scheduleExecutions.status, filters.status));
|
conditions.push(eq(scheduleExecutions.status, filters.status));
|
||||||
}
|
}
|
||||||
@@ -4527,6 +4534,44 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ALL execution ids (+ parsed details) for a given schedule type, unpaginated.
|
||||||
|
*
|
||||||
|
* Deliberately bypasses getScheduleExecutions()'s default 50-row page: a caller that
|
||||||
|
* needs to reconcile every record against something else (deploy-log-reconcile.ts,
|
||||||
|
* against files on disk) cannot afford to only see the most recent page -- an older
|
||||||
|
* record just outside the window would look exactly like an orphan file with no
|
||||||
|
* record, and the file behind it would be deleted even though a record exists.
|
||||||
|
*
|
||||||
|
* `environmentId` is included (F5 fix) so the caller can scope its reconciliation
|
||||||
|
* PER ENVIRONMENT -- deploy-log-store.ts now keeps one log directory per environment
|
||||||
|
* (see envDirName()), so a record's file can only ever be found in, or be deleted
|
||||||
|
* from, ITS OWN environment's directory. Without environmentId here,
|
||||||
|
* deploy-log-reconcile.ts would have to either reconcile everything as one flat pool
|
||||||
|
* again (reintroducing the cross-environment mixing the size-budget fix closes) or
|
||||||
|
* guess which environment a record belongs to.
|
||||||
|
*/
|
||||||
|
export async function getScheduleExecutionIdsByType(
|
||||||
|
scheduleType: ScheduleType
|
||||||
|
): Promise<Array<{ id: number; status: ScheduleStatus; details: any | null; environmentId: number | null }>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: scheduleExecutions.id,
|
||||||
|
status: scheduleExecutions.status,
|
||||||
|
details: scheduleExecutions.details,
|
||||||
|
environmentId: scheduleExecutions.environmentId
|
||||||
|
})
|
||||||
|
.from(scheduleExecutions)
|
||||||
|
.where(eq(scheduleExecutions.scheduleType, scheduleType));
|
||||||
|
|
||||||
|
return rows.map((row: { id: number; status: string; details: string | null; environmentId: number | null }) => ({
|
||||||
|
id: row.id,
|
||||||
|
status: row.status as ScheduleStatus,
|
||||||
|
details: row.details ? JSON.parse(row.details) : null,
|
||||||
|
environmentId: row.environmentId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled-task health for the metrics endpoint: execution counts by
|
* Scheduled-task health for the metrics endpoint: execution counts by
|
||||||
* (scheduleType, status), and per-type age (seconds) of the last run and last
|
* (scheduleType, status), and per-type age (seconds) of the last run and last
|
||||||
@@ -4699,9 +4744,12 @@ const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
|
|||||||
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
|
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
|
||||||
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
|
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
|
||||||
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
|
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
|
||||||
|
const DEPLOY_LOG_RECONCILE_CRON_KEY = 'deploy_log_reconcile_cron';
|
||||||
|
const DEPLOY_LOG_RECONCILE_ENABLED_KEY = 'deploy_log_reconcile_enabled';
|
||||||
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
|
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
|
||||||
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
|
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
|
||||||
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
|
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
|
||||||
|
const DEFAULT_DEPLOY_LOG_RECONCILE_CRON = '0 4 * * *'; // Daily at 4 AM
|
||||||
|
|
||||||
export async function getScheduleRetentionDays(): Promise<number> {
|
export async function getScheduleRetentionDays(): Promise<number> {
|
||||||
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
|
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
|
||||||
@@ -4879,6 +4927,50 @@ export async function setScannerCleanupEnabled(enabled: boolean): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeployLogReconcileCron(): Promise<string> {
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
|
||||||
|
if (result[0]) {
|
||||||
|
return result[0].value || DEFAULT_DEPLOY_LOG_RECONCILE_CRON;
|
||||||
|
}
|
||||||
|
return DEFAULT_DEPLOY_LOG_RECONCILE_CRON;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeployLogReconcileCron(cron: string): Promise<void> {
|
||||||
|
const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: cron, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({
|
||||||
|
key: DEPLOY_LOG_RECONCILE_CRON_KEY,
|
||||||
|
value: cron
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeployLogReconcileEnabled(): Promise<boolean> {
|
||||||
|
const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
|
||||||
|
if (result[0]) {
|
||||||
|
return result[0].value === 'true';
|
||||||
|
}
|
||||||
|
return true; // Enabled by default
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeployLogReconcileEnabled(enabled: boolean): Promise<void> {
|
||||||
|
const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db.update(settings)
|
||||||
|
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({
|
||||||
|
key: DEPLOY_LOG_RECONCILE_ENABLED_KEY,
|
||||||
|
value: enabled ? 'true' : 'false'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// EXTERNAL STACK PATHS
|
// EXTERNAL STACK PATHS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -488,7 +488,9 @@ export const scheduleExecutions = sqliteTable('schedule_executions', {
|
|||||||
logs: text('logs'), // Execution logs/output
|
logs: text('logs'), // Execution logs/output
|
||||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`)
|
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`)
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId)
|
typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId),
|
||||||
|
// Powers "runs for this stack/container" lookups without a full table scan.
|
||||||
|
entityEnvIdx: index('schedule_executions_entity_env_idx').on(table.entityName, table.environmentId)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -490,7 +490,9 @@ export const scheduleExecutions = pgTable('schedule_executions', {
|
|||||||
logs: text('logs'), // Execution logs/output
|
logs: text('logs'), // Execution logs/output
|
||||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow()
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow()
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId)
|
typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId),
|
||||||
|
// Powers "runs for this stack/container" lookups without a full table scan.
|
||||||
|
entityEnvIdx: index('schedule_executions_entity_env_idx').on(table.entityName, table.environmentId)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Pure decision core for the deploy-log reconcile job.
|
||||||
|
*
|
||||||
|
* Compares the set of deploy-log files on disk against the set of run records in the
|
||||||
|
* database and decides what to do about each side of the mismatch -- nothing else. It
|
||||||
|
* touches neither the filesystem nor the database; the caller (deploy-log-reconcile.ts)
|
||||||
|
* is responsible for gathering the two id sets and for carrying out the plan.
|
||||||
|
*
|
||||||
|
* The two failure modes are NOT symmetric, and that asymmetry is the whole point of
|
||||||
|
* this module:
|
||||||
|
* - A file without a record is deleted -- it is an orphan with nothing left to explain it.
|
||||||
|
* - A record without a file is only marked, never deleted -- the metadata (when, who,
|
||||||
|
* what was built, which digest) stays valuable even once the log text itself is gone.
|
||||||
|
* Deleting the record here would be the "too eager" failure this job exists to avoid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReconcileInput {
|
||||||
|
/** Run ids that have a deploy-log file on disk. */
|
||||||
|
fileIds: string[];
|
||||||
|
/** Run ids that have a run record in the database. */
|
||||||
|
recordIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconcilePlan {
|
||||||
|
/** Files to delete: present on disk, no matching record. */
|
||||||
|
deleteFiles: string[];
|
||||||
|
/** Records to mark (details.logMissing = true): present in the database, no matching file. */
|
||||||
|
markMissing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||||
|
const recordIdSet = new Set(input.recordIds);
|
||||||
|
const fileIdSet = new Set(input.fileIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteFiles: input.fileIds.filter((id) => !recordIdSet.has(id)),
|
||||||
|
markMissing: input.recordIds.filter((id) => !fileIdSet.has(id))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a record in this status may be judged "missing its log file".
|
||||||
|
*
|
||||||
|
* A still in-progress run ('queued'/'running') may simply not have written its first
|
||||||
|
* log line yet -- the schedule_executions row for a stack_deploy run is created before
|
||||||
|
* the deploy has produced any output, and the log file itself only comes into existence
|
||||||
|
* on the first line written (see deploy-log-reconcile.ts's module doc comment for the
|
||||||
|
* verified timing this guards against). Marking it here would be wrong, and nothing
|
||||||
|
* downstream ever re-checks or clears the flag once the file does appear -- so the
|
||||||
|
* record would carry a false "log missing" permanently.
|
||||||
|
*/
|
||||||
|
export function isEligibleForMissingMark(status: string): boolean {
|
||||||
|
return status !== 'queued' && status !== 'running';
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { appendFile, mkdir, readFile, rm, readdir, stat } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const ID = /^[A-Za-z0-9_-]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run id comes from a route parameter, so it is validated rather than sanitised:
|
||||||
|
* a value that is not a plain id is a bug or an attack, and neither should quietly
|
||||||
|
* resolve to some other file.
|
||||||
|
*/
|
||||||
|
export function runLogFileName(runId: string): string {
|
||||||
|
if (!ID.test(runId)) throw new Error(`invalid run id: ${JSON.stringify(runId)}`);
|
||||||
|
return `${runId}.log`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 fix: the directory name (under deploy-logs/) that holds ONE environment's OWN
|
||||||
|
* run log files and counts against ONE environment's OWN size budget.
|
||||||
|
*
|
||||||
|
* Before this existed, every environment shared a SINGLE deploy-logs/ directory and a
|
||||||
|
* single BUDGET_BYTES total (see SizeBudgetTracker below). A user with deploy rights
|
||||||
|
* in ONE environment could fill that shared budget with a noisy deploy, which then
|
||||||
|
* silently truncated the deploy logs and summaries of every OTHER environment -- a
|
||||||
|
* multi-tenant audit-trail integrity violation, since a deploy log is exactly the
|
||||||
|
* evidence an operator reaches for after something goes wrong. Scoping the directory
|
||||||
|
* (and therefore the budget, since usedBytes()/usedBytesExcluding() only ever look
|
||||||
|
* inside ONE environment's directory) per environment is the entire fix.
|
||||||
|
*
|
||||||
|
* `environmentId` always comes from a database column (`number | null`, see
|
||||||
|
* ScheduleExecutionData in db.ts) -- never attacker-controlled free text reaching
|
||||||
|
* this function directly. This still asserts the shape rather than trusting it
|
||||||
|
* blindly, the same posture runLogFileName's ID regex takes for runId: a value that
|
||||||
|
* is not a plain integer or null is a bug, and should throw rather than quietly
|
||||||
|
* resolve to some unexpected directory name.
|
||||||
|
*
|
||||||
|
* The `null` case (an install with no environments / a run not attributed to one --
|
||||||
|
* see createRunRecorder's `envId: number | null` input) gets the literal directory
|
||||||
|
* name "null", not an omitted segment. `String(null) === 'null'` in JS already reads
|
||||||
|
* exactly this way, so this is not inventing a new convention -- it is naming the one
|
||||||
|
* `String(envId)` would already produce, so the special case is documented rather
|
||||||
|
* than accidental.
|
||||||
|
*/
|
||||||
|
export function envDirName(envId: number | null): string {
|
||||||
|
if (envId === null) return 'null';
|
||||||
|
if (!Number.isInteger(envId)) {
|
||||||
|
throw new Error(`invalid environment id: ${JSON.stringify(envId)}`);
|
||||||
|
}
|
||||||
|
return String(envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inverse of envDirName() -- turns a directory name discovered on disk (via
|
||||||
|
* listEnvDirNames()) back into the environmentId it represents. Used by the
|
||||||
|
* reconcile job, which learns which environments have files by LISTING
|
||||||
|
* deploy-logs/, rather than being handed a set of ids up front.
|
||||||
|
*
|
||||||
|
* Throws on anything that is not one of envDirName()'s own outputs. listEnvDirNames()
|
||||||
|
* already filters to directories only (excluding the pre-F5 flat-layout .log files --
|
||||||
|
* see legacyFlatRunLogPath), so in practice this only ever sees "null" or a
|
||||||
|
* non-negative integer string; the throw is a defensive backstop against a
|
||||||
|
* hand-created or otherwise unexpected directory being silently misread as some
|
||||||
|
* arbitrary environment id.
|
||||||
|
*/
|
||||||
|
export function parseEnvDirName(name: string): number | null {
|
||||||
|
if (name === 'null') return null;
|
||||||
|
if (!/^\d+$/.test(name)) {
|
||||||
|
throw new Error(`not an environment directory name: ${JSON.stringify(name)}`);
|
||||||
|
}
|
||||||
|
return Number(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deployLogsRoot(): string {
|
||||||
|
return join(process.env.DATA_DIR || './data', 'deploy-logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One environment's OWN log directory: deploy-logs/<envDirName(envId)>/. */
|
||||||
|
function logDir(envId: number | null): string {
|
||||||
|
return join(deployLogsRoot(), envDirName(envId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runLogPath(envId: number | null, runId: string): string {
|
||||||
|
return join(logDir(envId), runLogFileName(runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path a run's log file would have lived at BEFORE the F5 per-environment
|
||||||
|
* scoping existed: deploy-logs/<runId>.log, no environment subdirectory. This branch
|
||||||
|
* introduced deploy-log-store.ts in the first place -- there is no released version
|
||||||
|
* of Dockhand that ever wrote logs in this flat layout, so there is no production
|
||||||
|
* data to migrate, and appendRunLog()/deleteRunLog() never write here.
|
||||||
|
*
|
||||||
|
* readRunLog() still falls back to this path (see its doc comment) purely as a
|
||||||
|
* robustness measure: a file placed here by hand -- a manual copy, a fixture, a
|
||||||
|
* future migration script -- is still found instead of silently 404ing, rather than
|
||||||
|
* this fix assuming its own directory layout is the only one that will ever exist on
|
||||||
|
* disk. deleteRunLog() also clears it, so such a file cannot outlive the record that
|
||||||
|
* pointed at it.
|
||||||
|
*/
|
||||||
|
function legacyFlatRunLogPath(runId: string): string {
|
||||||
|
return join(deployLogsRoot(), runLogFileName(runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total bytes ONE environment's log directory may occupy before new writes are refused. */
|
||||||
|
const BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many bytes of a run's OWN output SizeBudgetTracker lets accumulate against its
|
||||||
|
* cached view of the rest of the directory before re-scanning that view. Bounds how
|
||||||
|
* stale the cache can get for a single long-running deploy -- see SizeBudgetTracker's
|
||||||
|
* doc comment for the concurrency trade-off this makes.
|
||||||
|
*/
|
||||||
|
const REFRESH_AFTER_OWN_BYTES = 1 * 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
|
export async function appendRunLog(envId: number | null, runId: string, chunk: string): Promise<void> {
|
||||||
|
await mkdir(logDir(envId), { recursive: true });
|
||||||
|
await appendFile(runLogPath(envId, runId), chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a run's log text. Tries the run's OWN environment directory first -- the
|
||||||
|
* only place appendRunLog() ever writes -- and falls back to the pre-F5 flat path
|
||||||
|
* (legacyFlatRunLogPath) if no file exists there. See that function's doc comment
|
||||||
|
* for why the fallback exists despite there being no production data in that layout.
|
||||||
|
* A file that exists at NEITHER path returns null, exactly as before F5.
|
||||||
|
*/
|
||||||
|
export async function readRunLog(envId: number | null, runId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await readFile(runLogPath(envId, runId), 'utf8');
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return await readFile(legacyFlatRunLogPath(runId), 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a run's log file from its own environment directory AND from the pre-F5
|
||||||
|
* flat legacy path (force: true -- a path that was never there is not an error), so
|
||||||
|
* a run whose file happens to have been found via readRunLog()'s legacy fallback
|
||||||
|
* does not survive its own deletion.
|
||||||
|
*/
|
||||||
|
export async function deleteRunLog(envId: number | null, runId: string): Promise<void> {
|
||||||
|
await rm(runLogPath(envId, runId), { force: true });
|
||||||
|
await rm(legacyFlatRunLogPath(runId), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRunLogIds(envId: number | null): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const names = await readdir(logDir(envId));
|
||||||
|
return names.filter((n) => n.endsWith('.log')).map((n) => n.slice(0, -4));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-modified time (ms since epoch) of a run's log file, or null if it does not
|
||||||
|
* exist. Used by the reconcile job (deploy-log-reconcile.ts) as a TOCTOU guard: the
|
||||||
|
* set of stack_deploy records it compares files against is a single point-in-time
|
||||||
|
* snapshot taken at the start of the job, while appendRunLog() keeps writing to (and
|
||||||
|
* therefore keeps bumping the mtime of) a live deploy's file for as long as it runs.
|
||||||
|
* A deploy that starts AFTER that snapshot has no record in it yet, but its file can
|
||||||
|
* already exist by the time this environment's files are listed -- a file this fresh
|
||||||
|
* is exactly what that race looks like, not an abandoned orphan.
|
||||||
|
*/
|
||||||
|
export async function getRunLogMtimeMs(envId: number | null, runId: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
return (await stat(runLogPath(envId, runId))).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every environment directory name currently present under deploy-logs/. Used by the
|
||||||
|
* reconcile job (deploy-log-reconcile.ts) to discover which environments have ANY
|
||||||
|
* files on disk, without first needing to already know the full set of environment
|
||||||
|
* ids from the database (an environment that was later deleted, but still has
|
||||||
|
* orphaned log files, would otherwise never be reconciled).
|
||||||
|
*
|
||||||
|
* Filters to directories only via readdir's withFileTypes -- a stray FILE directly
|
||||||
|
* under deploy-logs/ is the pre-F5 flat legacy layout (legacyFlatRunLogPath), not an
|
||||||
|
* environment directory, and is intentionally excluded: F5 is a fresh, unreleased
|
||||||
|
* feature with no production data in that layout to reconcile (see that function's
|
||||||
|
* doc comment).
|
||||||
|
*/
|
||||||
|
export async function listEnvDirNames(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(deployLogsRoot(), { withFileTypes: true });
|
||||||
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usedBytes(envId: number | null): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
for (const id of await listRunLogIds(envId)) {
|
||||||
|
try {
|
||||||
|
total += (await stat(runLogPath(envId, id))).size;
|
||||||
|
} catch {
|
||||||
|
/* raced with cleanup */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as usedBytes(), but leaves excludeRunId's own file out of the sum. Used by
|
||||||
|
* SizeBudgetTracker to get the size of "everything ELSE IN THIS ENVIRONMENT", so it
|
||||||
|
* can add this run's own bytes on top from an in-memory counter instead of
|
||||||
|
* re-stat'ing its own file.
|
||||||
|
*/
|
||||||
|
export async function usedBytesExcluding(envId: number | null, excludeRunId: string): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
for (const id of await listRunLogIds(envId)) {
|
||||||
|
if (id === excludeRunId) continue;
|
||||||
|
try {
|
||||||
|
total += (await stat(runLogPath(envId, id))).size;
|
||||||
|
} catch {
|
||||||
|
/* raced with cleanup */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function budgetExceeded(envId: number | null): Promise<boolean> {
|
||||||
|
return (await usedBytes(envId)) > BUDGET_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether ONE run's output has pushed ITS OWN ENVIRONMENT's deploy-logs/<env>/
|
||||||
|
* over BUDGET_BYTES, without doing a full directory scan (readdir + stat every file)
|
||||||
|
* on every appended line.
|
||||||
|
*
|
||||||
|
* Before the scan-avoidance existed, deploy-run-record.ts's line() called
|
||||||
|
* budgetExceeded() -- a full usedBytes() scan -- after EVERY line, so a deploy with N
|
||||||
|
* output lines against a log directory holding M past runs' files cost O(N*M) stat
|
||||||
|
* calls. Measured on this machine: 200 lines against 500 existing log files took
|
||||||
|
* ~16.2s (~81ms/line); the same 200 lines with the directory scan removed entirely
|
||||||
|
* took ~49ms total. That scan is needed to stay honest about the directory's actual
|
||||||
|
* size, so it can't just be dropped -- it can only be done less often.
|
||||||
|
*
|
||||||
|
* How: the directory total EXCLUDING this run's own file (usedBytesExcluding) is
|
||||||
|
* measured once, lazily, on the first recordAppend() call, and cached. Each
|
||||||
|
* recordAppend() after that just adds the byte length the caller reports (bytes it
|
||||||
|
* just wrote to ITS OWN file -- no stat() involved) to that cached total in memory.
|
||||||
|
* The cache is re-measured every REFRESH_AFTER_OWN_BYTES bytes of this run's own
|
||||||
|
* output, so a long deploy's view of "everything else" can't drift arbitrarily far
|
||||||
|
* from reality -- at most REFRESH_AFTER_OWN_BYTES stale before the next refresh
|
||||||
|
* catches up.
|
||||||
|
*
|
||||||
|
* F5 fix, ENVIRONMENT scoping: `envId` is threaded into the constructor and passed to
|
||||||
|
* every measureOthers() call, so "everything else" means everything else IN THIS
|
||||||
|
* RUN'S OWN ENVIRONMENT's directory -- never another environment's files. Before this,
|
||||||
|
* `envId` did not exist here at all: every tracker (regardless of which environment
|
||||||
|
* its run belonged to) measured the SAME shared deploy-logs/ directory against the
|
||||||
|
* SAME BUDGET_BYTES total, so a noisy deploy in one environment could push every other
|
||||||
|
* environment's tracker over budget too, truncating runs that never wrote anywhere
|
||||||
|
* near BUDGET_BYTES themselves. See deploy-run-record.ts's module doc comment (F5
|
||||||
|
* fix) and DeployRunRecorder's envId field for how this is wired end to end.
|
||||||
|
*
|
||||||
|
* Concurrency: OTHER runs in the SAME environment writing at the same time are
|
||||||
|
* invisible between this tracker's refreshes. A second run in the same environment
|
||||||
|
* that starts writing right after this one's baseline was measured can add up to its
|
||||||
|
* own full output before the next refresh reflects it, so the total this tracker
|
||||||
|
* checks against can undercount the real directory size by that much in the
|
||||||
|
* meantime. This is an intentional trade-off: BUDGET_BYTES (2 GiB, deliberately
|
||||||
|
* generous) exists to catch a filled disk, not to enforce an exact quota -- the
|
||||||
|
* design doc calls for aborting "at the end of writing, not truncating mid-line",
|
||||||
|
* never for a precise cutoff. The OLD per-line full rescan did not close this gap
|
||||||
|
* either: budgetExceeded() reads the directory at the moment it's called, with no
|
||||||
|
* locking against another run's concurrent appendRunLog() -- the old code just paid
|
||||||
|
* for that same imprecision on every single line instead of once per MiB. This
|
||||||
|
* concurrency trade-off is entirely orthogonal to the F5 environment-scoping fix
|
||||||
|
* above: it was true before F5 (within the single shared directory) and remains true
|
||||||
|
* after F5 (within one environment's own directory) -- F5 changes WHICH directory is
|
||||||
|
* measured, not how fresh that measurement is.
|
||||||
|
*/
|
||||||
|
export class SizeBudgetTracker {
|
||||||
|
/** "Everything else" (every OTHER run's file IN THIS RUN'S OWN ENVIRONMENT), last
|
||||||
|
* measured by refresh(). NEVER includes this run's own file -- see
|
||||||
|
* usedBytesExcluding() -- and NEVER includes another environment's files, since
|
||||||
|
* usedBytesExcluding() only ever reads inside logDir(this.envId). */
|
||||||
|
private cachedOtherBytes = 0;
|
||||||
|
/** Every byte THIS run has appended so far, summed in memory as recordAppend()
|
||||||
|
* is called. This is the run's true own total and is never reset -- it does not
|
||||||
|
* need a re-scan to stay accurate, because the caller already knows exactly what
|
||||||
|
* it just wrote. */
|
||||||
|
private ownBytesTotal = 0;
|
||||||
|
/** Own bytes appended since the LAST refresh() -- reset to 0 on every refresh.
|
||||||
|
* Only exists to decide WHEN to refresh; the budget check itself always uses
|
||||||
|
* ownBytesTotal, not this. */
|
||||||
|
private ownBytesSinceLastScan = 0;
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** F5 fix: which environment's directory/budget this tracker measures against.
|
||||||
|
* Passed straight through to measureOthers() on every refresh() -- see the
|
||||||
|
* class doc comment. */
|
||||||
|
private readonly envId: number | null,
|
||||||
|
private readonly runId: string,
|
||||||
|
private readonly budgetBytes: number = BUDGET_BYTES,
|
||||||
|
/** Test-only seam: lets tests count/replace the directory scan without
|
||||||
|
* writing real files. Production code never passes this -- it always gets
|
||||||
|
* the real usedBytesExcluding. */
|
||||||
|
private readonly measureOthers: (
|
||||||
|
envId: number | null,
|
||||||
|
excludeRunId: string
|
||||||
|
) => Promise<number> = usedBytesExcluding
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async refresh(): Promise<void> {
|
||||||
|
this.cachedOtherBytes = await this.measureOthers(this.envId, this.runId);
|
||||||
|
this.ownBytesSinceLastScan = 0;
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call once per chunk appended to this run's own log file, with the number of
|
||||||
|
* UTF-8 bytes just written (Buffer.byteLength(chunk, 'utf8') -- NOT chunk.length,
|
||||||
|
* which counts UTF-16 code units and undercounts multi-byte characters). Returns
|
||||||
|
* whether the directory total, including this run's own bytes so far, currently
|
||||||
|
* exceeds the budget.
|
||||||
|
*
|
||||||
|
* ownBytesTotal is updated BEFORE the refresh decision, on purpose: a single
|
||||||
|
* chunk large enough to cross REFRESH_AFTER_OWN_BYTES on its own must trigger its
|
||||||
|
* refresh in this same call, not the next one -- otherwise a caller that stops
|
||||||
|
* appending right after such a chunk would leave the tracker's view of
|
||||||
|
* "everything else" stale for the rest of the run.
|
||||||
|
*/
|
||||||
|
async recordAppend(byteLength: number): Promise<boolean> {
|
||||||
|
this.ownBytesTotal += byteLength;
|
||||||
|
this.ownBytesSinceLastScan += byteLength;
|
||||||
|
if (!this.loaded || this.ownBytesSinceLastScan >= REFRESH_AFTER_OWN_BYTES) {
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
return this.cachedOtherBytes + this.ownBytesTotal > this.budgetBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// src/lib/server/deploy-recorder.ts
|
||||||
|
/**
|
||||||
|
* Decides what may be written to a persisted deploy log.
|
||||||
|
*
|
||||||
|
* Only redacted output lines qualify. The 'result' event is excluded on purpose: it
|
||||||
|
* carries result.output (and, on the compose endpoint, result.error) -- compose's raw
|
||||||
|
* stdout/stderr, which never passes through redactLine. Persisting it would turn a
|
||||||
|
* ten-minute in-memory buffer into a permanent plaintext archive on disk.
|
||||||
|
*
|
||||||
|
* BOTH conditions below are load-bearing and are covered by separate tests. Do not
|
||||||
|
* collapse them into the type check alone: that would admit any future event carrying
|
||||||
|
* type: 'line', and no test would notice.
|
||||||
|
*/
|
||||||
|
export function shouldRecord(event: string | undefined, data: unknown): boolean {
|
||||||
|
if (event !== 'progress') return false;
|
||||||
|
if (typeof data !== 'object' || data === null) return false;
|
||||||
|
return (data as { type?: string }).type === 'line';
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getScheduleExecution, type ScheduleExecutionData } from './db';
|
||||||
|
import type { AuthorizationContext } from './authorize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a schedule_executions row and confirms it actually belongs to the named
|
||||||
|
* stack before deciding whether the caller may reach it.
|
||||||
|
*
|
||||||
|
* Two checks, deliberately in this order:
|
||||||
|
*
|
||||||
|
* 1. Ownership -- the row must be a 'stack_deploy' execution whose entityName
|
||||||
|
* matches the stack in the URL. A run for a DIFFERENT stack (or a
|
||||||
|
* non-deploy schedule execution that happens to reuse the same numeric id
|
||||||
|
* space) comes back as the exact same 404 as a run that doesn't exist at
|
||||||
|
* all. A distinct response for "wrong stack" vs "no such run" would leak
|
||||||
|
* that the id is valid for something else.
|
||||||
|
*
|
||||||
|
* 2. Environment access -- checked against the RUN's OWN environmentId, never
|
||||||
|
* against a caller-supplied `env` query parameter. A caller could otherwise
|
||||||
|
* pass an env they legitimately have access to while pointing runId at a
|
||||||
|
* run that actually belongs to a DIFFERENT environment they have no access
|
||||||
|
* to at all -- the same cross-env bypass class the backup routes' own
|
||||||
|
* route-guards.ts closes for snapshot/config lookups (resolveSnapshotEnvId
|
||||||
|
* + guardSnapshotEnvAccess). Fails closed: enterprise + inaccessible env
|
||||||
|
* denies, even if that hides an otherwise-existing run.
|
||||||
|
*
|
||||||
|
* Shared by GET and DELETE .../deploys/{runId} AND GET .../deploys/{runId}/log
|
||||||
|
* so the three routes can never drift into checking different things for the
|
||||||
|
* same row. The stacks:view/stacks:edit permission gate itself is NOT part of
|
||||||
|
* this helper -- each route checks that on its own, inline, before calling
|
||||||
|
* this (see the three +server.ts files).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Statuses a stack_deploy run can be permanently closed under -- i.e. the deploy has
|
||||||
|
* actually stopped running and nothing will update this row again on its own. Kept as
|
||||||
|
* an explicit allow-list (not "everything except queued/running") on purpose: the
|
||||||
|
* status column is free text (db.ts's ScheduleStatus comment), and a status this code
|
||||||
|
* doesn't recognize must NOT be assumed finished just because it isn't literally
|
||||||
|
* 'queued' or 'running' -- see isTerminalRunStatus()'s doc comment for what that
|
||||||
|
* assumption would cost.
|
||||||
|
*
|
||||||
|
* This is deliberately its OWN small set, not deploy-log-reconcile-core.ts's
|
||||||
|
* isEligibleForMissingMark() (which answers "queued"/"running" vs. everything else,
|
||||||
|
* fail-open for an unrecognized status because marking logMissing is reversible and
|
||||||
|
* merely metadata). Deleting a run is NOT reversible, so unlike that check, an
|
||||||
|
* unrecognized status here must fail CLOSED (treated as still in progress), not open.
|
||||||
|
*/
|
||||||
|
const TERMINAL_RUN_STATUSES = new Set(['success', 'failed', 'skipped', 'warning', 'error', 'cancelled', 'stale']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stack_deploy run's status means the deploy is over and its record/log file
|
||||||
|
* may safely be deleted.
|
||||||
|
*
|
||||||
|
* Fails CLOSED for anything not explicitly listed in TERMINAL_RUN_STATUSES -- including
|
||||||
|
* 'queued', 'running', and any status this code has never been taught about (the status
|
||||||
|
* column is free text). The alternative (terminal = "not queued/running") would let a
|
||||||
|
* status this code doesn't recognize be deleted on the assumption that it must be
|
||||||
|
* finished; there is no way to confirm that, and a wrong guess here is irreversible (see
|
||||||
|
* loadOwnedDeployRun's DELETE caller in .../deploys/[runId]/+server.ts).
|
||||||
|
*/
|
||||||
|
export function isTerminalRunStatus(status: string): boolean {
|
||||||
|
return TERMINAL_RUN_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOwnedDeployRun(
|
||||||
|
stackName: string,
|
||||||
|
runIdParam: string,
|
||||||
|
auth: AuthorizationContext
|
||||||
|
): Promise<{ run: ScheduleExecutionData } | { response: Response }> {
|
||||||
|
const runId = parseInt(runIdParam, 10);
|
||||||
|
if (isNaN(runId)) {
|
||||||
|
return { response: json({ error: 'Invalid run id' }, { status: 400 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = await getScheduleExecution(runId);
|
||||||
|
if (!run || run.scheduleType !== 'stack_deploy' || run.entityName !== stackName) {
|
||||||
|
return { response: json({ error: 'Deploy run not found' }, { status: 404 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.environmentId != null && auth.isEnterprise && !(await auth.canAccessEnvironment(run.environmentId))) {
|
||||||
|
return { response: json({ error: 'Access denied to this environment' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { run };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { DeploySummary } from './deploy-summary-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the `details` JSON stored on a stack_deploy schedule_executions row.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free (no db.ts, no filesystem) so it can be unit tested
|
||||||
|
* under Bun -- see deploy-run-record.ts for the database-touching half that calls
|
||||||
|
* this. Importing db.ts here would make this module unimportable under Bun
|
||||||
|
* (better-sqlite3, ERR_DLOPEN_FAILED), breaking this file's own test and dragging
|
||||||
|
* down every other test file that happens to run in the same process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DeployRunOptions {
|
||||||
|
pull: boolean;
|
||||||
|
build: boolean;
|
||||||
|
forceRecreate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildRunDetailsInput {
|
||||||
|
options: DeployRunOptions;
|
||||||
|
summary?: DeploySummary;
|
||||||
|
exitCode: number;
|
||||||
|
composeHash: string;
|
||||||
|
envHash: string;
|
||||||
|
logFile: string;
|
||||||
|
/** Set when the run's output hit deploy-log-store's total-size budget and was cut off. */
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployRunDetails {
|
||||||
|
options: DeployRunOptions;
|
||||||
|
summary?: DeploySummary;
|
||||||
|
exitCode: number;
|
||||||
|
composeHash: string;
|
||||||
|
envHash: string;
|
||||||
|
logFile: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The log file name is what ties this schedule_executions row to its log file on
|
||||||
|
* disk (deploy-logs/<id>.log). Task 13's reconciler deletes any file whose id has
|
||||||
|
* no matching record -- if the name were missing here, a live run's own file would
|
||||||
|
* look orphaned and get eaten out from under it.
|
||||||
|
*/
|
||||||
|
export function buildRunDetails(input: BuildRunDetailsInput): DeployRunDetails {
|
||||||
|
if (!input.logFile) {
|
||||||
|
throw new Error('buildRunDetails: logFile is required to tie the record to its log file');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
options: input.options,
|
||||||
|
summary: input.summary,
|
||||||
|
exitCode: input.exitCode,
|
||||||
|
composeHash: input.composeHash,
|
||||||
|
envHash: input.envHash,
|
||||||
|
logFile: input.logFile,
|
||||||
|
...(input.truncated ? { truncated: true } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 of the compose file content used for this deploy, hex-encoded. */
|
||||||
|
export function hashComposeContent(content: string): string {
|
||||||
|
return createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 fingerprint of the effective env for this deploy (every var -- secret and
|
||||||
|
* non-secret alike -- that will actually reach the container), hex-encoded. Sorted by
|
||||||
|
* key so the same env always hashes to the same value regardless of object key order.
|
||||||
|
* This is a change-fingerprint, not a secret store -- the hash alone can't be turned
|
||||||
|
* back into the values it was built from, so hashing secret values in is safe even
|
||||||
|
* though logging them would not be. Callers merge whatever sources make up their
|
||||||
|
* effective env (DB non-secret + secret vars, or a request body's raw/array vars)
|
||||||
|
* into one Record before calling this.
|
||||||
|
*/
|
||||||
|
export function hashEnvFingerprint(vars: Record<string, string>): string {
|
||||||
|
const serialized = Object.keys(vars).sort().map((k) => `${k}=${vars[k]}`).join('\n');
|
||||||
|
return createHash('sha256').update(serialized).digest('hex');
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
createScheduleExecution,
|
||||||
|
updateScheduleExecution,
|
||||||
|
type ScheduleTrigger
|
||||||
|
} from './db';
|
||||||
|
import { appendRunLog, runLogFileName, SizeBudgetTracker } from './deploy-log-store';
|
||||||
|
import { summarize } from './deploy-summary-core';
|
||||||
|
import { buildRunDetails, type DeployRunOptions } from './deploy-run-record-core';
|
||||||
|
import { redactLine } from './secret-redaction';
|
||||||
|
import type { RunRecorder } from './sse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored instead of the raw error text when redactLine() had to withhold the whole
|
||||||
|
* line (a too-short secret value matched, and redacting only that substring would
|
||||||
|
* risk hitting an unrelated coincidence -- see secret-redaction.ts). A run without an
|
||||||
|
* error message is unfortunate; a run with a leaked secret in a widely-readable field
|
||||||
|
* is a security incident, so the row still closes "failed", just without the text.
|
||||||
|
*/
|
||||||
|
const ERROR_WITHHELD_MESSAGE = 'Deploy failed (error message withheld: contained a secret value)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database-touching half of the stack_deploy run record. Split from
|
||||||
|
* deploy-run-record-core.ts on purpose: this file imports db.ts (createScheduleExecution
|
||||||
|
* pulls in better-sqlite3), which is not importable under Bun (ERR_DLOPEN_FAILED). Keeping
|
||||||
|
* that import out of the pure -core module is what lets deploy-run-record-core.test.ts run
|
||||||
|
* at all, and keeps it from dragging down unrelated test files sharing the same process.
|
||||||
|
*
|
||||||
|
* F5 fix: the caller-supplied `envId` (the environment the run belongs to, or null for a
|
||||||
|
* run not attributed to one) is threaded through to appendRunLog()/SizeBudgetTracker
|
||||||
|
* (deploy-log-store.ts) for every filesystem operation this class performs. Before this,
|
||||||
|
* `envId` was accepted by createRunRecorder()'s input and stored on the schedule_executions
|
||||||
|
* row (environmentId), but never reached the log file's PATH or its size budget -- every
|
||||||
|
* environment's deploy output landed in the SAME deploy-logs/ directory and counted against
|
||||||
|
* the SAME 2 GiB total (deploy-log-store.ts's BUDGET_BYTES), so a noisy deploy in one
|
||||||
|
* environment could silently truncate another environment's deploy logs. See
|
||||||
|
* deploy-log-store.ts's SizeBudgetTracker doc comment for the full explanation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRUNCATION_NOTICE = '\n[deploy log truncated: size budget exceeded]\n';
|
||||||
|
|
||||||
|
export class DeployRunRecorder implements RunRecorder {
|
||||||
|
private readonly executionId: number;
|
||||||
|
private readonly runId: string;
|
||||||
|
private readonly startedAtMs: number;
|
||||||
|
private readonly options: DeployRunOptions;
|
||||||
|
private readonly composeHash: string;
|
||||||
|
private readonly 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
|
||||||
|
* against (deploy-log-store.ts's logDir()/SizeBudgetTracker). Never used for anything
|
||||||
|
* else here -- environmentId on the schedule_executions row itself is set separately,
|
||||||
|
* by createRunRecorder()'s createScheduleExecution() call. */
|
||||||
|
private readonly envId: number | null;
|
||||||
|
/** Not readonly: addSecrets() extends this after construction -- see its doc comment. */
|
||||||
|
private secrets: string[];
|
||||||
|
private readonly lines: string[] = [];
|
||||||
|
/** Chain of queued appends -- see line()'s doc comment for why this exists. */
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
private truncated = false;
|
||||||
|
/** Tracks the deploy-logs/ size budget without a full directory re-scan on every
|
||||||
|
* appended line -- see SizeBudgetTracker's doc comment (deploy-log-store.ts). */
|
||||||
|
private readonly sizeBudget: SizeBudgetTracker;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
executionId: number,
|
||||||
|
startedAtMs: number,
|
||||||
|
input: {
|
||||||
|
options: DeployRunOptions;
|
||||||
|
composeHash: string;
|
||||||
|
envHash: string;
|
||||||
|
userId?: number;
|
||||||
|
secrets: string[];
|
||||||
|
/** F5 fix: see the envId field's doc comment above. */
|
||||||
|
envId: number | null;
|
||||||
|
},
|
||||||
|
/** Test-only seam: lets tests exercise the truncation path with a tiny budget
|
||||||
|
* instead of writing gigabytes of real log data. createRunRecorder() never
|
||||||
|
* passes this, so production always gets SizeBudgetTracker's real default
|
||||||
|
* (BUDGET_BYTES, deploy-log-store.ts). */
|
||||||
|
sizeBudget?: SizeBudgetTracker
|
||||||
|
) {
|
||||||
|
this.executionId = executionId;
|
||||||
|
this.runId = String(executionId);
|
||||||
|
this.startedAtMs = startedAtMs;
|
||||||
|
this.options = input.options;
|
||||||
|
this.composeHash = input.composeHash;
|
||||||
|
this.envHash = input.envHash;
|
||||||
|
this.userId = input.userId;
|
||||||
|
this.secrets = input.secrets;
|
||||||
|
this.envId = input.envId;
|
||||||
|
this.sizeBudget = sizeBudget ?? new SizeBudgetTracker(this.envId, this.runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appendRunLog (deploy-log-store.ts) is async; this method is not. Without a
|
||||||
|
* chain, two lines arriving close together could race and land in the file out
|
||||||
|
* of order. Each call hangs its append off the tail of the previous one, so
|
||||||
|
* writes always happen in the order line() was called -- and end() can await
|
||||||
|
* `tail` to know every queued write has actually landed before it closes the row.
|
||||||
|
*/
|
||||||
|
line(line: string): void {
|
||||||
|
if (this.truncated) return;
|
||||||
|
this.tail = this.tail.then(async () => {
|
||||||
|
if (this.truncated) return;
|
||||||
|
// Pushed for the summary in the SAME guarded step as the file write, not
|
||||||
|
// synchronously up in line() -- line() can be called many times before
|
||||||
|
// the chain gets to run any of them, and truncation only actually takes
|
||||||
|
// effect partway through that queue. Pushing here keeps `this.lines`
|
||||||
|
// (what summarize() sees) exactly in sync with what really landed in the
|
||||||
|
// file: a line that gets skipped because truncation already kicked in
|
||||||
|
// isn't counted as if it had been written.
|
||||||
|
this.lines.push(line);
|
||||||
|
const chunk = line + '\n';
|
||||||
|
await appendRunLog(this.envId, this.runId, chunk);
|
||||||
|
// Checked AFTER writing, not before: a budget check ahead of the write
|
||||||
|
// would either reject a legitimate line on stale size info, or need a
|
||||||
|
// second read straight after anyway. Checking once, right after the
|
||||||
|
// write that just happened, is both simpler and exactly what "abort at
|
||||||
|
// the end of writing, not truncate mid-line" (design doc §5) asks for.
|
||||||
|
// recordAppend() itself only re-scans the directory occasionally (see
|
||||||
|
// SizeBudgetTracker) -- it does NOT re-stat the whole deploy-logs/
|
||||||
|
// directory on every call, unlike the budgetExceeded() this replaced.
|
||||||
|
if (await this.sizeBudget.recordAppend(Buffer.byteLength(chunk, 'utf8'))) {
|
||||||
|
this.truncated = true;
|
||||||
|
await appendRunLog(this.envId, this.runId, TRUNCATION_NOTICE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4 fix: the caller (a route or deployGitStack) builds this recorder from the
|
||||||
|
* DB-only vars it has BEFORE calling deployStack(), which then resolves the bound
|
||||||
|
* secret provider's bulk/inline-ref values internally (resolveProviderEnvVars,
|
||||||
|
* stacks.ts) -- entirely new values the caller never saw. Without this, a
|
||||||
|
* provider-resolved secret that ends up in compose's raw stderr passes straight
|
||||||
|
* through end()'s redaction (it redacts against `this.secrets` only) and into
|
||||||
|
* errorMessage, which GET /api/schedules/executions returns to every authenticated
|
||||||
|
* user. Callers pass deployStack()'s result.resolvedSecrets here, after the call
|
||||||
|
* returns and before end() is invoked -- see stacks.ts's StackOperationResult.
|
||||||
|
*/
|
||||||
|
addSecrets(values: string[]): void {
|
||||||
|
for (const value of values) {
|
||||||
|
if (!this.secrets.includes(value)) this.secrets.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* output. That's why this never assumes `this.lines` is non-empty.
|
||||||
|
*/
|
||||||
|
async end(ok: boolean, exitCode?: number, error?: string): Promise<void> {
|
||||||
|
// Wait for every queued append to land before summarizing and closing the
|
||||||
|
// row -- otherwise a line still in flight could be missing from the summary,
|
||||||
|
// or the row could close before its own last line is actually on disk.
|
||||||
|
await this.tail;
|
||||||
|
|
||||||
|
const details = buildRunDetails({
|
||||||
|
options: this.options,
|
||||||
|
summary: this.lines.length > 0 ? summarize(this.lines) : undefined,
|
||||||
|
// A real exit code (from the local/direct compose path) is preferred; the
|
||||||
|
// Hawser path doesn't have one to give, so fall back to a code consistent
|
||||||
|
// with ok/failed rather than leaving it out.
|
||||||
|
exitCode: exitCode ?? (ok ? 0 : 1),
|
||||||
|
composeHash: this.composeHash,
|
||||||
|
envHash: this.envHash,
|
||||||
|
logFile: runLogFileName(this.runId),
|
||||||
|
truncated: this.truncated
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Who" is best-effort only (design doc §10.5: webhook/cron/startup have no
|
||||||
|
// user, and auth can be disabled entirely) -- kept out of buildRunDetails'
|
||||||
|
// pure, tested shape and merged in here instead.
|
||||||
|
const detailsWithUser = this.userId !== undefined ? { ...details, userId: this.userId } : details;
|
||||||
|
|
||||||
|
// error is compose's raw, unredacted stdout/stderr (deploy-recorder.ts's
|
||||||
|
// shouldRecord() deliberately excludes the 'result' event from the log file for
|
||||||
|
// exactly this reason: it never passes through redactLine). This value goes into
|
||||||
|
// errorMessage on schedule_executions, which GET /api/schedules/executions returns
|
||||||
|
// to every authenticated user regardless of stack/environment permission -- so it
|
||||||
|
// gets the same redaction the streamed lines already got before it's stored.
|
||||||
|
const errorMessage = ok
|
||||||
|
? null
|
||||||
|
: (redactLine(error ?? 'Deploy failed', this.secrets) ?? ERROR_WITHHELD_MESSAGE);
|
||||||
|
|
||||||
|
await updateScheduleExecution(this.executionId, {
|
||||||
|
status: ok ? 'success' : 'failed',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
duration: Date.now() - this.startedAtMs,
|
||||||
|
errorMessage,
|
||||||
|
details: detailsWithUser
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the schedule_executions row for a stack deploy run and returns a RunRecorder
|
||||||
|
* that mirrors qualifying progress lines to its log file and closes the row when the
|
||||||
|
* deploy is over. See sse.ts for why this is built in the route/here, not in sse.ts itself.
|
||||||
|
*/
|
||||||
|
export async function createRunRecorder(input: {
|
||||||
|
stackName: string;
|
||||||
|
envId: number | null;
|
||||||
|
userId?: number;
|
||||||
|
triggeredBy: ScheduleTrigger;
|
||||||
|
options: DeployRunOptions;
|
||||||
|
composeHash: string;
|
||||||
|
envHash: string;
|
||||||
|
/** Every value (secret AND non-secret) that reaches the container -- same set the
|
||||||
|
* caller already merged for envHash. Used to redact error/summary text on end(),
|
||||||
|
* same secrets list stacks.ts uses for makeLineForwarder()/makeRedactedLineSink(). */
|
||||||
|
secrets: string[];
|
||||||
|
}): Promise<RunRecorder> {
|
||||||
|
const execution = await createScheduleExecution({
|
||||||
|
scheduleType: 'stack_deploy',
|
||||||
|
scheduleId: 0, // there is no schedule -- see schema comment "or 0 for system jobs"
|
||||||
|
environmentId: input.envId,
|
||||||
|
entityName: input.stackName,
|
||||||
|
triggeredBy: input.triggeredBy,
|
||||||
|
status: 'running'
|
||||||
|
// logs stays empty on purpose: the text lives in the file, not this column.
|
||||||
|
});
|
||||||
|
|
||||||
|
// createScheduleExecution doesn't accept startedAt (it fills triggeredAt itself) --
|
||||||
|
// same two-step shape as the system-cleanup.ts jobs this one is modeled on.
|
||||||
|
const startedAtMs = Date.now();
|
||||||
|
await updateScheduleExecution(execution.id, {
|
||||||
|
startedAt: new Date(startedAtMs).toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return new DeployRunRecorder(execution.id, startedAtMs, {
|
||||||
|
options: input.options,
|
||||||
|
composeHash: input.composeHash,
|
||||||
|
envHash: input.envHash,
|
||||||
|
userId: input.userId,
|
||||||
|
secrets: input.secrets,
|
||||||
|
// F5 fix: same envId already stored on the schedule_executions row above
|
||||||
|
// (environmentId: input.envId) -- also threaded to the recorder so its log
|
||||||
|
// file lands in, and counts against the budget of, THIS run's own
|
||||||
|
// environment directory. See DeployRunRecorder's envId field doc comment.
|
||||||
|
envId: input.envId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -598,6 +598,12 @@ export async function getDockerConfig(envId?: number | null): Promise<DockerClie
|
|||||||
interface DockerFetchOptions extends RequestInit {
|
interface DockerFetchOptions extends RequestInit {
|
||||||
/** Set to true for long-lived streaming connections */
|
/** Set to true for long-lived streaming connections */
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
/**
|
||||||
|
* Called per output line while the request runs. Only the Hawser Edge (WebSocket) path
|
||||||
|
* can deliver these: the agent sends 'stream' messages alongside a non-streaming request.
|
||||||
|
* Every other transport is plain request/response and simply never calls it.
|
||||||
|
*/
|
||||||
|
onLine?: (line: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -828,7 +834,7 @@ export async function dockerFetch(
|
|||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const config = await getDockerConfig(envId);
|
const config = await getDockerConfig(envId);
|
||||||
const { streaming, ...fetchOptions } = options;
|
const { streaming, onLine, ...fetchOptions } = options;
|
||||||
const method = (options.method || 'GET').toUpperCase();
|
const method = (options.method || 'GET').toUpperCase();
|
||||||
|
|
||||||
// Hawser Edge mode - route through WebSocket connection
|
// Hawser Edge mode - route through WebSocket connection
|
||||||
@@ -888,7 +894,8 @@ export async function dockerFetch(
|
|||||||
streaming || false,
|
streaming || false,
|
||||||
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
||||||
isBinary,
|
isBinary,
|
||||||
fetchOptions.signal ?? undefined
|
fetchOptions.signal ?? undefined,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
const elapsed = Date.now() - startTime;
|
const elapsed = Date.now() - startTime;
|
||||||
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
* config embeds remote credentials), at any depth. This blocks the credential
|
* config embeds remote credentials), at any depth. This blocks the credential
|
||||||
* files inside a git-repos clone while still letting the working copies be
|
* files inside a git-repos clone while still letting the working copies be
|
||||||
* browsed (deploy verification, adoption).
|
* browsed (deploy verification, adoption).
|
||||||
|
* - the deploy-log directory ($DATA_DIR/deploy-logs, see deploy-log-store.ts) —
|
||||||
|
* deploy logs may carry secrets that survived redaction, and the file
|
||||||
|
* browser is open by design, so this directory has to be named here too.
|
||||||
*
|
*
|
||||||
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
|
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
|
||||||
* symlink pointing into a protected location can't be used to bypass the check.
|
* symlink pointing into a protected location can't be used to bypass the check.
|
||||||
@@ -38,12 +41,15 @@ function getDataDir(): string {
|
|||||||
return process.env.DATA_DIR || './data';
|
return process.env.DATA_DIR || './data';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Absolute paths under Dockhand's data dir that must never be exposed. */
|
/** Absolute paths Dockhand must never expose through the file browser. */
|
||||||
function protectedPaths(): { dbDir: string; keyFile: string } {
|
function protectedPaths(): { dbDir: string; keyFile: string; deployLogsDir: string } {
|
||||||
const dataDir = resolve(getDataDir());
|
const dataDir = resolve(getDataDir());
|
||||||
return {
|
return {
|
||||||
dbDir: join(dataDir, 'db'),
|
dbDir: join(dataDir, 'db'),
|
||||||
keyFile: join(dataDir, KEY_FILE_NAME)
|
keyFile: join(dataDir, KEY_FILE_NAME),
|
||||||
|
// deploy logs may contain secrets that survived redaction; the file browser is open by
|
||||||
|
// design, so this directory is named here (see deploy-log-store.ts).
|
||||||
|
deployLogsDir: join(dataDir, 'deploy-logs')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,18 +84,23 @@ function isInside(child: string, parent: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if `requestedPath` resolves to a location that must never be read
|
* Returns true if `requestedPath` resolves to a location that must never be read
|
||||||
* through the file browser (Dockhand's DB/key, system secret trees, or any .ssh /
|
* through the file browser (Dockhand's DB/key, the deploy-log directory, system
|
||||||
* .git directory). Symlinks are resolved first so they can't bypass it.
|
* secret trees, or any .ssh / .git directory). Symlinks are resolved first so
|
||||||
|
* they can't bypass it.
|
||||||
*/
|
*/
|
||||||
export function isProtectedPath(requestedPath: string): boolean {
|
export function isProtectedPath(requestedPath: string): boolean {
|
||||||
const { dbDir, keyFile } = protectedPaths();
|
const { dbDir, keyFile, deployLogsDir } = protectedPaths();
|
||||||
const resolved = safeResolve(requestedPath);
|
const resolved = safeResolve(requestedPath);
|
||||||
|
|
||||||
// Compare against symlink-resolved targets too: the request is resolved, so a
|
// Compare against symlink-resolved targets too: the request is resolved, so a
|
||||||
// protected target that is itself a symlink (e.g. .encryption_key mounted from
|
// protected target that is itself a symlink (e.g. .encryption_key mounted from
|
||||||
// a secret store, or a symlinked db/ volume) must be resolved as well or the
|
// a secret store, or a symlinked db/ volume) must be resolved as well or the
|
||||||
// two never match. safeResolve tolerates a not-yet-existing target.
|
// two never match. safeResolve tolerates a not-yet-existing target.
|
||||||
if (resolved === safeResolve(keyFile) || isInside(resolved, safeResolve(dbDir))) {
|
if (
|
||||||
|
resolved === safeResolve(keyFile) ||
|
||||||
|
isInside(resolved, safeResolve(dbDir)) ||
|
||||||
|
isInside(resolved, safeResolve(deployLogsDir))
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Match each root both literally and symlink-resolved (e.g. macOS /etc -> /private/etc).
|
// Match each root both literally and symlink-resolved (e.g. macOS /etc -> /private/etc).
|
||||||
|
|||||||
+193
-26
@@ -12,11 +12,16 @@ import {
|
|||||||
updateGitStack,
|
updateGitStack,
|
||||||
upsertStackSource,
|
upsertStackSource,
|
||||||
getEnvironment,
|
getEnvironment,
|
||||||
|
getSecretEnvVarsAsRecord,
|
||||||
|
getNonSecretEnvVarsAsRecord,
|
||||||
type GitRepository,
|
type GitRepository,
|
||||||
type GitCredential,
|
type GitCredential,
|
||||||
type GitStackWithRepo
|
type GitStackWithRepo,
|
||||||
|
type ScheduleTrigger
|
||||||
} from './db';
|
} from './db';
|
||||||
import { deployStack, getStackDir } from './stacks';
|
import { deployStack, getStackDir } from './stacks';
|
||||||
|
import { createRunRecorder } from './deploy-run-record';
|
||||||
|
import { hashComposeContent, hashEnvFingerprint } from './deploy-run-record-core';
|
||||||
import { sendEventNotification } from './notifications';
|
import { sendEventNotification } from './notifications';
|
||||||
import { buildBasicAuthHeader } from './git-auth';
|
import { buildBasicAuthHeader } from './git-auth';
|
||||||
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath, repoBaseEnvPath } from './git-url-safety';
|
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath, repoBaseEnvPath } from './git-url-safety';
|
||||||
@@ -1315,8 +1320,45 @@ async function notifyGitSync(stackName: string, envId: number | null | undefined
|
|||||||
} catch { /* never changes the deploy outcome */ }
|
} catch { /* never changes the deploy outcome */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> {
|
/**
|
||||||
|
* The function every git-stack deploy trigger funnels through EXCEPT ONE: a
|
||||||
|
* config-update-then-redeploy, both webhook methods, create-and-deploy, and (via
|
||||||
|
* runGitStackSync) the cron scheduler and the schedules page's "run now" all reach
|
||||||
|
* this function. That is why the stack_deploy run recorder is wired in HERE and not
|
||||||
|
* at each of those call sites: wiring it at the route level only (the way
|
||||||
|
* compose/+server.ts does it) would leave every OTHER caller -- crucially the cron-
|
||||||
|
* and webhook-triggered ones, the truly unattended runs the design doc's Meilenstein 5
|
||||||
|
* is about -- unrecorded. Duplicating the composeHash/envHash/secrets setup at
|
||||||
|
* six-plus call sites would also drift out of sync with each other over time; one
|
||||||
|
* implementation, reached from everywhere but one, cannot.
|
||||||
|
*
|
||||||
|
* The one exception is the UI's own "Deploy" button (deploy-stream/+server.ts),
|
||||||
|
* which streams live per-step progress via deployGitStackWithProgress() below --
|
||||||
|
* a separate pipeline (it re-clones and re-reads the compose file itself rather than
|
||||||
|
* calling this function) that carries its OWN, separately-wired copy of the same
|
||||||
|
* recorder setup, right at its own deployStack() call. See that function's doc
|
||||||
|
* comment for why it is a second call site instead of routing through here.
|
||||||
|
*/
|
||||||
|
export async function deployGitStack(
|
||||||
|
stackId: number,
|
||||||
|
options?: {
|
||||||
|
force?: boolean;
|
||||||
|
/** Who/what triggered this deploy, for the stack_deploy run record. Defaults to
|
||||||
|
* 'manual' for backward compatibility with existing callers/tests that predate
|
||||||
|
* this parameter -- every REAL call site below passes its own explicit value. */
|
||||||
|
triggeredBy?: ScheduleTrigger;
|
||||||
|
/** Best-effort only, like every other stack_deploy record (design doc §10.5):
|
||||||
|
* webhook/cron callers have no user and simply omit this. */
|
||||||
|
userId?: number;
|
||||||
|
/** Forwarded to deployStack() alongside the recorder's own line() call, so an
|
||||||
|
* HTTP caller (the manual deploy route, the deployNow routes) can still stream
|
||||||
|
* progress over SSE exactly as before -- recording never depends on whether
|
||||||
|
* anyone is listening. */
|
||||||
|
onLine?: (line: string) => void;
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> {
|
||||||
const force = options?.force ?? true; // Default to force for backward compatibility
|
const force = options?.force ?? true; // Default to force for backward compatibility
|
||||||
|
const triggeredBy: ScheduleTrigger = options?.triggeredBy ?? 'manual';
|
||||||
|
|
||||||
const gitStack = await getGitStack(stackId);
|
const gitStack = await getGitStack(stackId);
|
||||||
if (!gitStack) {
|
if (!gitStack) {
|
||||||
@@ -1386,21 +1428,82 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
|||||||
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
|
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
|
||||||
console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)');
|
console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)');
|
||||||
|
|
||||||
const result = await deployStack({
|
// Same stack_deploy run record shape the non-git routes build (compose PUT,
|
||||||
name: gitStack.stackName,
|
// dedicated deploy endpoint) -- see deploy-run-record.ts. composeHash is taken
|
||||||
compose: syncResult.composeContent!,
|
// from syncResult.composeContent, the EXACT string handed to deployStack right
|
||||||
envId: gitStack.environmentId,
|
// below, not a redundant re-read off disk.
|
||||||
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
|
//
|
||||||
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
|
// envHash/secrets mirror the same (deliberately approximate) resolution the
|
||||||
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
// non-git routes already use: getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord
|
||||||
forceRecreate,
|
// keyed by stackName+envId -- the same DB-sourced values deployStack itself
|
||||||
build: gitStack.buildOnDeploy,
|
// resolves again internally at deploy time (stacks.ts). This does NOT include
|
||||||
noBuildCache: gitStack.noBuildCache,
|
// secret-provider-injected vars or the git repo's own .env file content; that's
|
||||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
// fine for the redaction list because deployStack's onLine output is
|
||||||
filesToDelete: syncResult.deletionPlan?.toDelete,
|
// independently redacted against ITS OWN, more complete secrets set
|
||||||
isGitDeploy: true // suppress stack_* notification; we emit git_sync_* below
|
// (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 };
|
||||||
|
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: gitStack.stackName,
|
||||||
|
envId: gitStack.environmentId ?? null,
|
||||||
|
userId: options?.userId,
|
||||||
|
triggeredBy,
|
||||||
|
options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: !!forceRecreate },
|
||||||
|
composeHash: hashComposeContent(syncResult.composeContent!),
|
||||||
|
envHash: hashEnvFingerprint(effectiveEnvVars),
|
||||||
|
secrets: Object.values(effectiveEnvVars)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
options?.onLine?.(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof deployStack>>;
|
||||||
|
try {
|
||||||
|
result = await deployStack({
|
||||||
|
name: gitStack.stackName,
|
||||||
|
compose: syncResult.composeContent!,
|
||||||
|
envId: gitStack.environmentId,
|
||||||
|
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
|
||||||
|
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
|
||||||
|
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||||
|
forceRecreate,
|
||||||
|
build: gitStack.buildOnDeploy,
|
||||||
|
noBuildCache: gitStack.noBuildCache,
|
||||||
|
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||||
|
filesToDelete: syncResult.deletionPlan?.toDelete,
|
||||||
|
isGitDeploy: true, // suppress stack_* notification; we emit git_sync_* below
|
||||||
|
onLine
|
||||||
|
});
|
||||||
|
} 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));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F4 fix: deployStack() resolves the bound secret provider's values internally,
|
||||||
|
// AFTER `recorder` above was already built from the DB-only nonSecretVars/
|
||||||
|
// secretVars this function read before calling it. Feed the provider-resolved
|
||||||
|
// values in now, before recorder.end() below -- otherwise a provider secret
|
||||||
|
// 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 ?? []);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} DEPLOY GIT STACK RESULT`);
|
console.log(`${logPrefix} DEPLOY GIT STACK RESULT`);
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
@@ -1523,6 +1626,17 @@ type ProgressCallback = (data: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UI's own "Deploy" button (deploy-stream/+server.ts): runs its own clone/read/
|
||||||
|
* deploy pipeline, reporting 5 discrete stages via onProgress, rather than calling
|
||||||
|
* deployGitStack() -- that function's onLine streams raw compose output lines, a
|
||||||
|
* different contract than the stage messages this one sends. Because of that split,
|
||||||
|
* this is a SECOND call site that reaches deployStack() directly, and it carries its
|
||||||
|
* own copy of the same stack_deploy run-record wiring deployGitStack() has (built
|
||||||
|
* right before its own deployStack() call below) rather than inheriting it -- see
|
||||||
|
* deployGitStack's doc comment for the full picture of which callers go through
|
||||||
|
* which path.
|
||||||
|
*/
|
||||||
export async function deployGitStackWithProgress(
|
export async function deployGitStackWithProgress(
|
||||||
stackId: number,
|
stackId: number,
|
||||||
onProgress: ProgressCallback
|
onProgress: ProgressCallback
|
||||||
@@ -1554,6 +1668,11 @@ export async function deployGitStackWithProgress(
|
|||||||
|
|
||||||
const totalSteps = 5;
|
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;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Connecting
|
// Step 1: Connecting
|
||||||
onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
|
onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
|
||||||
@@ -1720,19 +1839,67 @@ export async function deployGitStackWithProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await deployStack({
|
// Same stack_deploy run record shape deployGitStack builds (see its doc
|
||||||
name: gitStack.stackName,
|
// comment above deployGitStack) -- this manual "Deploy" button path runs its
|
||||||
compose: composeContent,
|
// own clone/read/deploy pipeline instead of calling deployGitStack (it needs
|
||||||
envId: gitStack.environmentId,
|
// this function's own discrete onProgress stages, not deployGitStack's onLine
|
||||||
sourceDir: composeDir, // Copy entire directory from git repo
|
// streaming), so it was the one caller of deployStack that never went through
|
||||||
composeFileName: progressComposeFileName, // Compose filename relative to source dir
|
// createRunRecorder at all -- unlike every other trigger (webhook,
|
||||||
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
// config-update-then-redeploy, and cron via runGitStackSync, all of which
|
||||||
build: gitStack.buildOnDeploy,
|
// funnel through deployGitStack). Wired in here, right before the SAME
|
||||||
noBuildCache: gitStack.noBuildCache,
|
// deployStack() call deployGitStack wires it around, so it records without
|
||||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
// touching onProgress's own streaming.
|
||||||
filesToDelete: deletionData.plan.toDelete
|
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)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof deployStack>>;
|
||||||
|
try {
|
||||||
|
result = await deployStack({
|
||||||
|
name: gitStack.stackName,
|
||||||
|
compose: composeContent,
|
||||||
|
envId: gitStack.environmentId,
|
||||||
|
sourceDir: composeDir, // Copy entire directory from git repo
|
||||||
|
composeFileName: progressComposeFileName, // Compose filename relative to source dir
|
||||||
|
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||||
|
build: gitStack.buildOnDeploy,
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
} 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));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F4 fix (see deployGitStack's own addSecrets() call for the full rationale):
|
||||||
|
// deployStack() resolves the bound secret provider's values internally, after
|
||||||
|
// `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);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Deletion sync: persist manifest + log per-file change summary.
|
// Deletion sync: persist manifest + log per-file change summary.
|
||||||
// The change table was already shown before the deploy (#1260);
|
// The change table was already shown before the deploy (#1260);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* hawser-core.ts — the PURE parts of the Hawser Edge protocol message routing: no DB import,
|
||||||
|
* so it is unit-testable directly (importing hawser.ts pulls in db/drizzle -> better-sqlite3,
|
||||||
|
* which bun's test runner can't load). hawser.ts re-exports this so callers have one import site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The minimal shape dispatchStreamMessage needs off an EdgeConnection. */
|
||||||
|
export interface StreamDispatchTarget {
|
||||||
|
pendingStreamRequests?: Map<string, { onData?: (data: string, stream?: 'stdout' | 'stderr') => void }>;
|
||||||
|
// Line callbacks for non-streaming requests (e.g. compose) that still want to observe
|
||||||
|
// 'stream' messages as they arrive. Separate from pendingStreamRequests, which belongs
|
||||||
|
// to requests sent with streaming: true (see sendEdgeStreamRequest in hawser.ts).
|
||||||
|
lineHandlers?: Map<string, (line: string) => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a 'stream' message from the agent to whichever consumer is waiting for it.
|
||||||
|
*
|
||||||
|
* Two independent registries can claim a requestId: pendingStreamRequests (requests sent
|
||||||
|
* with streaming: true) and lineHandlers (non-streaming requests, e.g. compose, that still
|
||||||
|
* want to observe line-by-line output as it arrives). A requestId is only ever registered in
|
||||||
|
* one of the two — pendingStreamRequests is checked first, matching the previous behaviour of
|
||||||
|
* the inline 'stream' case this was extracted from.
|
||||||
|
*
|
||||||
|
* Compose requests are sent with streaming: false and land in pendingRequests only. Before
|
||||||
|
* this function existed, the message dispatcher's 'stream' case looked up pendingStreamRequests
|
||||||
|
* exclusively, so any line message for a non-streaming request vanished silently — no error, no
|
||||||
|
* warning.
|
||||||
|
*/
|
||||||
|
export function dispatchStreamMessage(
|
||||||
|
connection: StreamDispatchTarget,
|
||||||
|
msg: { requestId: string; data: unknown }
|
||||||
|
): void {
|
||||||
|
const streamPending = connection.pendingStreamRequests?.get(msg.requestId);
|
||||||
|
if (streamPending) {
|
||||||
|
streamPending.onData?.(msg.data as string);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineHandler = connection.lineHandlers?.get(msg.requestId);
|
||||||
|
if (!lineHandler) return; // no handler registered: drop silently, this is the normal case
|
||||||
|
|
||||||
|
// Go marshals StreamMessage.Data ([]byte) as base64 -- sendEdgeStreamRequest's onData
|
||||||
|
// callback does the same decode on the streaming path. Without this, the UI shows base64
|
||||||
|
// gibberish instead of readable output.
|
||||||
|
const text = typeof msg.data === 'string' ? Buffer.from(msg.data, 'base64').toString('utf8') : '';
|
||||||
|
if (text) lineHandler(text);
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ import { isHealthTransition } from './subprocess-manager.js';
|
|||||||
import { pushMetric } from './metrics-store.js';
|
import { pushMetric } from './metrics-store.js';
|
||||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||||
import { hashPassword, verifyPassword } from './auth.js';
|
import { hashPassword, verifyPassword } from './auth.js';
|
||||||
|
// The 'stream' message routing logic lives in a db-free core module so it stays unit-testable
|
||||||
|
// (importing this file pulls in db/drizzle -> better-sqlite3, which bun's test runner can't
|
||||||
|
// load). Re-exported so callers get it from one import site.
|
||||||
|
import { dispatchStreamMessage } from './hawser-core.js';
|
||||||
|
export { dispatchStreamMessage };
|
||||||
|
|
||||||
// Protocol constants
|
// Protocol constants
|
||||||
export const HAWSER_PROTOCOL_VERSION = '1.0';
|
export const HAWSER_PROTOCOL_VERSION = '1.0';
|
||||||
@@ -48,6 +53,10 @@ export interface EdgeConnection {
|
|||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
pendingRequests: Map<string, PendingRequest>;
|
pendingRequests: Map<string, PendingRequest>;
|
||||||
pendingStreamRequests: Map<string, PendingStreamRequest>;
|
pendingStreamRequests: Map<string, PendingStreamRequest>;
|
||||||
|
// Line callbacks for non-streaming requests (e.g. compose) that still want to observe
|
||||||
|
// 'stream' messages as they arrive. Separate from pendingStreamRequests, which belongs
|
||||||
|
// to requests sent with streaming: true (see sendEdgeStreamRequest).
|
||||||
|
lineHandlers?: Map<string, (line: string) => void>;
|
||||||
pingInterval?: ReturnType<typeof setInterval>;
|
pingInterval?: ReturnType<typeof setInterval>;
|
||||||
lastMetrics?: {
|
lastMetrics?: {
|
||||||
uptime?: number;
|
uptime?: number;
|
||||||
@@ -514,7 +523,8 @@ export function handleEdgeConnection(
|
|||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
pendingRequests: new Map(),
|
pendingRequests: new Map(),
|
||||||
pendingStreamRequests: new Map()
|
pendingStreamRequests: new Map(),
|
||||||
|
lineHandlers: new Map()
|
||||||
};
|
};
|
||||||
|
|
||||||
edgeConnections.set(environmentId, connection);
|
edgeConnections.set(environmentId, connection);
|
||||||
@@ -578,7 +588,8 @@ export async function sendEdgeRequest(
|
|||||||
streaming = false,
|
streaming = false,
|
||||||
timeout = 30000,
|
timeout = 30000,
|
||||||
isBinary = false,
|
isBinary = false,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<EdgeResponse> {
|
): Promise<EdgeResponse> {
|
||||||
const connection = edgeConnections.get(environmentId);
|
const connection = edgeConnections.get(environmentId);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
@@ -587,9 +598,18 @@ export async function sendEdgeRequest(
|
|||||||
|
|
||||||
const requestId = secureRandomUUID();
|
const requestId = secureRandomUUID();
|
||||||
|
|
||||||
|
// A non-streaming request (compose) that wants to observe output as it happens. The agent
|
||||||
|
// sends it as 'stream' messages, which dispatchStreamMessage routes here by requestId.
|
||||||
|
// Registered before the message goes out, so no early line can miss the handler.
|
||||||
|
if (onLine) {
|
||||||
|
connection.lineHandlers ??= new Map();
|
||||||
|
connection.lineHandlers.set(requestId, onLine);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
connection.pendingRequests.delete(requestId);
|
connection.pendingRequests.delete(requestId);
|
||||||
|
connection.lineHandlers?.delete(requestId);
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
}
|
}
|
||||||
@@ -607,6 +627,7 @@ export async function sendEdgeRequest(
|
|||||||
'abort',
|
'abort',
|
||||||
() => {
|
() => {
|
||||||
connection.pendingRequests.delete(requestId);
|
connection.pendingRequests.delete(requestId);
|
||||||
|
connection.lineHandlers?.delete(requestId);
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
}
|
}
|
||||||
@@ -700,6 +721,7 @@ export async function sendEdgeRequest(
|
|||||||
const sent = globalThis.__hawserSendMessage(environmentId, messageStr);
|
const sent = globalThis.__hawserSendMessage(environmentId, messageStr);
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
connection.pendingRequests.delete(requestId);
|
connection.pendingRequests.delete(requestId);
|
||||||
|
connection.lineHandlers?.delete(requestId);
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
}
|
}
|
||||||
@@ -713,6 +735,7 @@ export async function sendEdgeRequest(
|
|||||||
const errorMsg = sendError instanceof Error ? sendError.message : String(sendError);
|
const errorMsg = sendError instanceof Error ? sendError.message : String(sendError);
|
||||||
console.error(`[Hawser Edge] Error sending message:`, errorMsg);
|
console.error(`[Hawser Edge] Error sending message:`, errorMsg);
|
||||||
connection.pendingRequests.delete(requestId);
|
connection.pendingRequests.delete(requestId);
|
||||||
|
connection.lineHandlers?.delete(requestId);
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
connection.pendingStreamRequests.delete(requestId);
|
connection.pendingStreamRequests.delete(requestId);
|
||||||
}
|
}
|
||||||
@@ -1261,6 +1284,10 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp
|
|||||||
if (pending) {
|
if (pending) {
|
||||||
clearTimeout(pending.timeout);
|
clearTimeout(pending.timeout);
|
||||||
connection.pendingRequests.delete(msg.requestId);
|
connection.pendingRequests.delete(msg.requestId);
|
||||||
|
// A non-streaming request that registered a line handler (compose, Task 9) is
|
||||||
|
// done once its response arrives -- without this the map grows unbounded for
|
||||||
|
// the lifetime of the process.
|
||||||
|
connection.lineHandlers?.delete(msg.requestId);
|
||||||
pending.resolve({
|
pending.resolve({
|
||||||
statusCode: msg.statusCode,
|
statusCode: msg.statusCode,
|
||||||
headers: msg.headers || {},
|
headers: msg.headers || {},
|
||||||
@@ -1272,10 +1299,7 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'stream': {
|
case 'stream': {
|
||||||
const streamPending = connection.pendingStreamRequests.get(msg.requestId);
|
dispatchStreamMessage(connection, msg);
|
||||||
if (streamPending) {
|
|
||||||
streamPending.onData?.(msg.data);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Pure child-process output collector. No imports, so it is unit-testable in
|
||||||
|
* the bun runner without dragging in better-sqlite3 via stacks.ts -> ./db ->
|
||||||
|
* ./db/drizzle (top-level `await seedDatabase()`, unsupported under Bun --
|
||||||
|
* see tests/stacks-collect-process.test.ts for the reproduction).
|
||||||
|
*/
|
||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
export type LineCallback = (line: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect stdout/stderr from a child process and wait for it to exit.
|
||||||
|
* When `onLine` is provided, it is called for each complete line (split on '\n')
|
||||||
|
* seen on either stdout or stderr, in arrival order, as the process runs — plus
|
||||||
|
* once more for a trailing partial line (no terminating '\n') once the process closes.
|
||||||
|
* Without `onLine`, behavior is unchanged.
|
||||||
|
*/
|
||||||
|
export function collectProcess(
|
||||||
|
proc: ChildProcess,
|
||||||
|
onLine?: LineCallback
|
||||||
|
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const stdoutChunks: Buffer[] = [];
|
||||||
|
const stderrChunks: Buffer[] = [];
|
||||||
|
let lineBuffer = '';
|
||||||
|
|
||||||
|
const emitLines = (chunk: string) => {
|
||||||
|
if (!onLine) return;
|
||||||
|
lineBuffer += chunk;
|
||||||
|
const parts = lineBuffer.split('\n');
|
||||||
|
lineBuffer = parts.pop() ?? ''; // last part may be incomplete
|
||||||
|
for (const line of parts) onLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
stdoutChunks.push(chunk);
|
||||||
|
emitLines(chunk.toString());
|
||||||
|
});
|
||||||
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderrChunks.push(chunk);
|
||||||
|
emitLines(chunk.toString());
|
||||||
|
});
|
||||||
|
proc.on('error', reject);
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (onLine && lineBuffer.length > 0) {
|
||||||
|
onLine(lineBuffer);
|
||||||
|
lineBuffer = '';
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
exitCode: code ?? 1,
|
||||||
|
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||||
|
stderr: Buffer.concat(stderrChunks).toString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,11 +18,13 @@ import {
|
|||||||
getScheduleCleanupCron,
|
getScheduleCleanupCron,
|
||||||
getEventCleanupCron,
|
getEventCleanupCron,
|
||||||
getScannerCleanupCron,
|
getScannerCleanupCron,
|
||||||
|
getDeployLogReconcileCron,
|
||||||
getScheduleRetentionDays,
|
getScheduleRetentionDays,
|
||||||
getEventRetentionDays,
|
getEventRetentionDays,
|
||||||
getScheduleCleanupEnabled,
|
getScheduleCleanupEnabled,
|
||||||
getEventCleanupEnabled,
|
getEventCleanupEnabled,
|
||||||
getScannerCleanupEnabled,
|
getScannerCleanupEnabled,
|
||||||
|
getDeployLogReconcileEnabled,
|
||||||
getEnvironments,
|
getEnvironments,
|
||||||
getEnvUpdateCheckSettings,
|
getEnvUpdateCheckSettings,
|
||||||
getAllEnvUpdateCheckSettings,
|
getAllEnvUpdateCheckSettings,
|
||||||
@@ -61,6 +63,7 @@ import {
|
|||||||
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
|
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
|
||||||
SYSTEM_SCANNER_CLEANUP_ID
|
SYSTEM_SCANNER_CLEANUP_ID
|
||||||
} from './tasks/system-cleanup';
|
} from './tasks/system-cleanup';
|
||||||
|
import { runDeployLogReconcileJob, DEPLOY_LOG_RECONCILE_ID } from './tasks/deploy-log-reconcile';
|
||||||
|
|
||||||
// Store all active cron jobs
|
// Store all active cron jobs
|
||||||
const activeJobs: Map<string, Cron> = new Map();
|
const activeJobs: Map<string, Cron> = new Map();
|
||||||
@@ -70,6 +73,7 @@ let cleanupJob: Cron | null = null;
|
|||||||
let eventCleanupJob: Cron | null = null;
|
let eventCleanupJob: Cron | null = null;
|
||||||
let volumeHelperCleanupJob: Cron | null = null;
|
let volumeHelperCleanupJob: Cron | null = null;
|
||||||
let scannerCacheCleanupJob: Cron | null = null;
|
let scannerCacheCleanupJob: Cron | null = null;
|
||||||
|
let deployLogReconcileJob: Cron | null = null;
|
||||||
|
|
||||||
// Scheduler state
|
// Scheduler state
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
@@ -170,6 +174,7 @@ export async function startScheduler(): Promise<void> {
|
|||||||
const scheduleCleanupCron = await getScheduleCleanupCron();
|
const scheduleCleanupCron = await getScheduleCleanupCron();
|
||||||
const eventCleanupCron = await getEventCleanupCron();
|
const eventCleanupCron = await getEventCleanupCron();
|
||||||
const scannerCleanupCron = await getScannerCleanupCron();
|
const scannerCleanupCron = await getScannerCleanupCron();
|
||||||
|
const deployLogReconcileCron = await getDeployLogReconcileCron();
|
||||||
const defaultTimezone = await getDefaultTimezone();
|
const defaultTimezone = await getDefaultTimezone();
|
||||||
|
|
||||||
// Start system cleanup jobs (static schedules with default timezone)
|
// Start system cleanup jobs (static schedules with default timezone)
|
||||||
@@ -205,10 +210,19 @@ export async function startScheduler(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deploy log reconcile: own enabled flag + cron, same pattern as scanner cleanup
|
||||||
|
const deployLogReconcileEnabled = await getDeployLogReconcileEnabled();
|
||||||
|
if (deployLogReconcileEnabled) {
|
||||||
|
deployLogReconcileJob = new Cron(deployLogReconcileCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||||
|
await runDeployLogReconcileJob('cron');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
||||||
|
console.log(`[Scheduler] Deploy log reconcile: ${deployLogReconcileEnabled ? deployLogReconcileCron : 'disabled'} [${defaultTimezone}]`);
|
||||||
|
|
||||||
// Register all dynamic schedules from database
|
// Register all dynamic schedules from database
|
||||||
await refreshAllSchedules();
|
await refreshAllSchedules();
|
||||||
@@ -247,6 +261,10 @@ export function stopScheduler(): void {
|
|||||||
scannerCacheCleanupJob.stop();
|
scannerCacheCleanupJob.stop();
|
||||||
scannerCacheCleanupJob = null;
|
scannerCacheCleanupJob = null;
|
||||||
}
|
}
|
||||||
|
if (deployLogReconcileJob) {
|
||||||
|
deployLogReconcileJob.stop();
|
||||||
|
deployLogReconcileJob = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop all dynamic jobs
|
// Stop all dynamic jobs
|
||||||
for (const [key, job] of activeJobs.entries()) {
|
for (const [key, job] of activeJobs.entries()) {
|
||||||
@@ -668,6 +686,8 @@ export async function refreshSystemJobs(): Promise<void> {
|
|||||||
const eventCleanupCron = await getEventCleanupCron();
|
const eventCleanupCron = await getEventCleanupCron();
|
||||||
const scannerCleanupCron = await getScannerCleanupCron();
|
const scannerCleanupCron = await getScannerCleanupCron();
|
||||||
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
||||||
|
const deployLogReconcileCron = await getDeployLogReconcileCron();
|
||||||
|
const deployLogReconcileEnabled = await getDeployLogReconcileEnabled();
|
||||||
const defaultTimezone = await getDefaultTimezone();
|
const defaultTimezone = await getDefaultTimezone();
|
||||||
|
|
||||||
// Cleanup functions to pass to the job
|
// Cleanup functions to pass to the job
|
||||||
@@ -693,6 +713,9 @@ export async function refreshSystemJobs(): Promise<void> {
|
|||||||
if (scannerCacheCleanupJob) {
|
if (scannerCacheCleanupJob) {
|
||||||
scannerCacheCleanupJob.stop();
|
scannerCacheCleanupJob.stop();
|
||||||
}
|
}
|
||||||
|
if (deployLogReconcileJob) {
|
||||||
|
deployLogReconcileJob.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// Re-create with new timezone
|
// Re-create with new timezone
|
||||||
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||||
@@ -713,10 +736,17 @@ export async function refreshSystemJobs(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deployLogReconcileEnabled) {
|
||||||
|
deployLogReconcileJob = new Cron(deployLogReconcileCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||||
|
await runDeployLogReconcileJob('cron');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
||||||
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
||||||
|
console.log(`[Scheduler] Deploy log reconcile: ${deployLogReconcileEnabled ? deployLogReconcileCron : 'disabled'} [${defaultTimezone}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -861,6 +891,23 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger the deploy log reconcile job.
|
||||||
|
*
|
||||||
|
* A dedicated function rather than a jobId branch in triggerSystemJob() above: this job
|
||||||
|
* has its own scheduleType ('deploy_log_reconcile', not 'system_cleanup'), so its
|
||||||
|
* DEPLOY_LOG_RECONCILE_ID (1) would otherwise collide with SYSTEM_SCHEDULE_CLEANUP_ID
|
||||||
|
* (also 1) in that function's flat, type-less jobId dispatch.
|
||||||
|
*/
|
||||||
|
export async function triggerDeployLogReconcile(): Promise<{ success: boolean; executionId?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
runDeployLogReconcileJob('manual');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// UTILITY FUNCTIONS
|
// UTILITY FUNCTIONS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -881,6 +928,8 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
|
|||||||
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
|
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
|
||||||
const eventCleanupEnabled = await getEventCleanupEnabled();
|
const eventCleanupEnabled = await getEventCleanupEnabled();
|
||||||
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
||||||
|
const deployLogReconcileCron = await getDeployLogReconcileCron();
|
||||||
|
const deployLogReconcileEnabled = await getDeployLogReconcileEnabled();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -922,13 +971,23 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
|
|||||||
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
|
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
enabled: scannerCleanupEnabled
|
enabled: scannerCleanupEnabled
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DEPLOY_LOG_RECONCILE_ID,
|
||||||
|
type: 'deploy_log_reconcile' as const,
|
||||||
|
name: 'Deploy log reconcile',
|
||||||
|
description: 'Reconciles deploy-log files on disk against stack_deploy run records',
|
||||||
|
cronExpression: deployLogReconcileCron,
|
||||||
|
nextRun: deployLogReconcileEnabled ? getNextRun(deployLogReconcileCron)?.toISOString() ?? null : null,
|
||||||
|
isSystem: true,
|
||||||
|
enabled: deployLogReconcileEnabled
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemScheduleInfo {
|
export interface SystemScheduleInfo {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'system_cleanup';
|
type: 'system_cleanup' | 'deploy_log_reconcile';
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
cronExpression: string;
|
cronExpression: string;
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Deploy Log Reconcile Task
|
||||||
|
*
|
||||||
|
* Compares deploy-log files on disk (deploy-log-store.ts) against stack_deploy run
|
||||||
|
* records in the database and reconciles the two: a file with no record is deleted (an
|
||||||
|
* orphan with nothing left to explain it); a record with no file is only marked via
|
||||||
|
* details.logMissing -- never deleted, since the metadata (when, who, what was built,
|
||||||
|
* which digest) stays valuable even once the log text is gone. See
|
||||||
|
* deploy-log-reconcile-core.ts for the decision itself; this file only gathers the two
|
||||||
|
* id sets, guards the one case planReconcile can't know about (see below), and carries
|
||||||
|
* out the resulting plan.
|
||||||
|
*
|
||||||
|
* F5 fix -- reconciled PER ENVIRONMENT, never across environments: deploy-log-store.ts
|
||||||
|
* now keeps one log directory per environment (envDirName()/logDir()), because a SHARED
|
||||||
|
* directory and a SHARED size budget let a noisy deploy in one environment silently
|
||||||
|
* truncate another environment's deploy logs. This job has to honor that same boundary,
|
||||||
|
* or it would reintroduce the exact cross-environment mixing the size-budget fix closes
|
||||||
|
* -- e.g. deleting environment B's file because it happens to share a numeric run id
|
||||||
|
* with an orphan in environment A, or marking environment A's record logMissing because
|
||||||
|
* environment B happens to hold a same-numbered file. Every id set this file gathers
|
||||||
|
* (files AND records) is therefore grouped by environment FIRST, and planReconcile()
|
||||||
|
* (the pure decision core) is called ONCE PER ENVIRONMENT, on that environment's own two
|
||||||
|
* sets only -- never once on everything flattened together.
|
||||||
|
*
|
||||||
|
* The set of environments to look at comes from BOTH sides on purpose:
|
||||||
|
* - listEnvDirNames() (deploy-log-store.ts) -- every environment that currently has
|
||||||
|
* ANY file on disk, including one that was later deleted from the database (its
|
||||||
|
* orphaned files must still be reconciled, so its directory must still be visited).
|
||||||
|
* - every DISTINCT environmentId among the stack_deploy records themselves -- an
|
||||||
|
* environment whose only record has no file at all would otherwise never appear
|
||||||
|
* via listEnvDirNames() (there is no directory for it to discover), and its record
|
||||||
|
* would never be considered for markMissing.
|
||||||
|
* The union of the two is walked once each; a directory name that cannot be parsed back
|
||||||
|
* into an environment id (parseEnvDirName()) is skipped defensively rather than crashing
|
||||||
|
* the whole job -- see the per-environment loop below.
|
||||||
|
*
|
||||||
|
* Record source, verified against Task 10's actual code (deploy-run-record.ts, read
|
||||||
|
* directly from a sibling clone -- Task 10 had not landed on this branch at the time
|
||||||
|
* this file was written, so it could not be imported here): createRunRecorder() creates
|
||||||
|
* a schedule_executions row with scheduleType 'stack_deploy', scheduleId 0 (there is no
|
||||||
|
* schedule, only individual runs), and DeployRunRecorder sets `this.runId =
|
||||||
|
* String(executionId)` -- exactly the id this file reads via
|
||||||
|
* getScheduleExecutionIdsByType('stack_deploy') and converts with String(r.id). Confirmed
|
||||||
|
* correct, not assumed.
|
||||||
|
*
|
||||||
|
* That same reading surfaced a real race this job has to guard against: the row is
|
||||||
|
* created with status 'running' BEFORE the deploy has produced a single line, and the
|
||||||
|
* log file itself only comes into existence on the first DeployRunRecorder.line() call
|
||||||
|
* (appendRunLog() -- deploy-log-store.ts). So there is a real, if narrow, window where a
|
||||||
|
* 'running' record legitimately has no file yet. A naive reconcile firing in that window
|
||||||
|
* would mark a deploy that is actively in progress as logMissing -- and nothing in this
|
||||||
|
* job ever re-checks or clears that flag once the file does show up, so the record would
|
||||||
|
* carry a false "log missing" forever. Records still in a non-terminal state ('queued'/
|
||||||
|
* 'running') are therefore excluded from markMissing candidacy below, while still
|
||||||
|
* counting toward recordIds (within their own environment's set) so an already-existing
|
||||||
|
* file for them is protected from deletion either way.
|
||||||
|
*
|
||||||
|
* TOCTOU guard on the delete side: `records` (below) is read ONCE, at the very start
|
||||||
|
* of this job, via a single getScheduleExecutionIdsByType() call -- a point-in-time
|
||||||
|
* snapshot, not a live view. A deploy whose createRunRecorder() call happens AFTER
|
||||||
|
* that snapshot has no row in `records` at all, yet its log file can already exist by
|
||||||
|
* the time THIS environment's fileIds are listed a moment later (appendRunLog() writes
|
||||||
|
* the file on the very first streamed line, which can arrive within the same tick).
|
||||||
|
* Nothing in planReconcile() can tell that file apart from a genuine orphan -- both
|
||||||
|
* simply have no matching id in `records`. Deleting it would tear a live deploy's log
|
||||||
|
* out from under it while it is still being written to.
|
||||||
|
*
|
||||||
|
* Reported independently and confirmed by re-reading this exact snapshot-then-list
|
||||||
|
* ordering: fixed by treating a delete candidate's file mtime as a second signal
|
||||||
|
* alongside `records`, not by trying to make the DB read and the directory listing
|
||||||
|
* atomic with each other (they are two different data sources; no single query spans
|
||||||
|
* both). ORPHAN_DELETE_GRACE_MS below is deliberately generous compared to how long
|
||||||
|
* this job actually takes to walk from its `records` snapshot to any one
|
||||||
|
* environment's listRunLogIds() call (well under a second in the ordinary case) --
|
||||||
|
* appendRunLog() keeps bumping a live deploy's file mtime for as long as it keeps
|
||||||
|
* writing, so the grace period does not need to outlast the whole deploy, only the
|
||||||
|
* gap between the snapshot and this environment being reached. A file newer than the
|
||||||
|
* grace period is left alone THIS run; the NEXT scheduled run will see its by-then-
|
||||||
|
* created record and correctly never consider it for deletion again -- nothing is
|
||||||
|
* lost, the file is just given one more cycle to prove itself real, exactly the same
|
||||||
|
* shape of trade-off isEligibleForMissingMark() already makes on the other side of
|
||||||
|
* this same race (see above).
|
||||||
|
*
|
||||||
|
* Each element in both loops below is wrapped in its own try/catch. Without that, one
|
||||||
|
* throwing deleteRunLog/updateScheduleExecution call would abort the whole run -- and
|
||||||
|
* since the failing file/record is never removed/marked, every LATER run would fail at
|
||||||
|
* the exact same element again, permanently. Reported and reproduced independently: a
|
||||||
|
* harmless orphan file ordered before a failing one was still removed, everything after
|
||||||
|
* it was not, and the failed run's own `details` came back `null` -- no record of what
|
||||||
|
* had actually happened. Per-element isolation plus real counts (see below) fixes both --
|
||||||
|
* and now applies WITHIN each environment's own loop, so a failure in one environment's
|
||||||
|
* reconciliation cannot stop a later environment's elements from being tried either.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ScheduleTrigger } from '../../db';
|
||||||
|
import {
|
||||||
|
getDeployLogReconcileEnabled,
|
||||||
|
getScheduleExecutionIdsByType,
|
||||||
|
createScheduleExecution,
|
||||||
|
updateScheduleExecution,
|
||||||
|
appendScheduleExecutionLog
|
||||||
|
} from '../../db';
|
||||||
|
import {
|
||||||
|
listEnvDirNames,
|
||||||
|
listRunLogIds,
|
||||||
|
deleteRunLog,
|
||||||
|
envDirName,
|
||||||
|
parseEnvDirName,
|
||||||
|
getRunLogMtimeMs
|
||||||
|
} from '../../deploy-log-store';
|
||||||
|
import { planReconcile, isEligibleForMissingMark } from '../../deploy-log-reconcile-core';
|
||||||
|
|
||||||
|
// System job ID (own scheduleType, so this never collides with system-cleanup.ts's ids)
|
||||||
|
export const DEPLOY_LOG_RECONCILE_ID = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much younger than "now" a delete candidate's file mtime may be before this job
|
||||||
|
* still deletes it. See the TOCTOU guard in the module doc comment above: this only
|
||||||
|
* needs to outlast the gap between the `records` snapshot and this environment's
|
||||||
|
* listRunLogIds() call (ordinarily well under a second), never the whole deploy --
|
||||||
|
* appendRunLog() keeps re-bumping a live deploy's mtime for as long as it keeps
|
||||||
|
* writing. Generous on purpose; a false skip just costs one more reconcile cycle,
|
||||||
|
* while a false delete destroys a live deploy's log outright.
|
||||||
|
*/
|
||||||
|
const ORPHAN_DELETE_GRACE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filesystem calls this job makes, injectable so a test can force one element
|
||||||
|
* to fail while its neighbours succeed, and so a test can populate a fake per-environment
|
||||||
|
* file layout without touching the real filesystem.
|
||||||
|
*
|
||||||
|
* They are parameters rather than a mock.module() of '../../deploy-log-store' because that
|
||||||
|
* call freezes the module's export shape for the WHOLE test process: deploy-run-record.ts
|
||||||
|
* imports appendRunLog/runLogFileName/SizeBudgetTracker from the same specifier, and a
|
||||||
|
* partial factory left those missing for every module that resolved it afterwards.
|
||||||
|
*/
|
||||||
|
export interface DeployLogReconcileDeps {
|
||||||
|
listEnvDirNames: () => Promise<string[]>;
|
||||||
|
listRunLogIds: (envId: number | null) => Promise<string[]>;
|
||||||
|
deleteRunLog: (envId: number | null, runId: string) => Promise<void>;
|
||||||
|
/** mtime (ms since epoch) of a delete candidate's file, or null if it is already gone. */
|
||||||
|
getRunLogMtimeMs: (envId: number | null, runId: string) => Promise<number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: DeployLogReconcileDeps = {
|
||||||
|
listEnvDirNames,
|
||||||
|
listRunLogIds,
|
||||||
|
deleteRunLog,
|
||||||
|
getRunLogMtimeMs
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the deploy-log reconcile job.
|
||||||
|
*/
|
||||||
|
export async function runDeployLogReconcileJob(
|
||||||
|
triggeredBy: ScheduleTrigger = 'cron',
|
||||||
|
deps: DeployLogReconcileDeps = defaultDeps
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if reconcile is enabled (skip check if manually triggered)
|
||||||
|
if (triggeredBy === 'cron') {
|
||||||
|
const enabled = await getDeployLogReconcileEnabled();
|
||||||
|
if (!enabled) {
|
||||||
|
return; // Skip execution if disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Create execution record
|
||||||
|
const execution = await createScheduleExecution({
|
||||||
|
scheduleType: 'deploy_log_reconcile',
|
||||||
|
scheduleId: DEPLOY_LOG_RECONCILE_ID,
|
||||||
|
environmentId: null,
|
||||||
|
entityName: 'Deploy log reconcile',
|
||||||
|
triggeredBy,
|
||||||
|
status: 'running'
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateScheduleExecution(execution.id, {
|
||||||
|
startedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = async (message: string) => {
|
||||||
|
console.log(`[Deploy Log Reconcile] ${message}`);
|
||||||
|
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await getScheduleExecutionIdsByType('stack_deploy');
|
||||||
|
|
||||||
|
// F5 fix: group records by their OWN environment (via envDirName -- the same
|
||||||
|
// directory-name mapping deploy-log-store.ts uses on the filesystem side), so
|
||||||
|
// each environment's records are only ever compared against that SAME
|
||||||
|
// environment's files below -- never another environment's.
|
||||||
|
const recordsByEnvKey = new Map<string, typeof records>();
|
||||||
|
for (const record of records) {
|
||||||
|
const key = envDirName(record.environmentId);
|
||||||
|
const list = recordsByEnvKey.get(key);
|
||||||
|
if (list) list.push(record);
|
||||||
|
else recordsByEnvKey.set(key, [record]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The set of environments to actually visit: every environment that has a
|
||||||
|
// directory on disk (covers orphan files for an environment that no longer
|
||||||
|
// has ANY record, e.g. a deleted environment) UNION every environment that has
|
||||||
|
// at least one record (covers a record whose file has never been written, so
|
||||||
|
// there is no directory for listEnvDirNames() to discover it by -- see the
|
||||||
|
// module doc comment).
|
||||||
|
const envKeysOnDisk = await deps.listEnvDirNames();
|
||||||
|
const envKeys = new Set<string>([...envKeysOnDisk, ...recordsByEnvKey.keys()]);
|
||||||
|
|
||||||
|
let filesFoundTotal = 0;
|
||||||
|
// Actually-performed counts, not the plan's sizes -- a failing element must not
|
||||||
|
// be counted as done, and must not stop the elements after it (in this SAME
|
||||||
|
// environment, or in a LATER environment) from being tried.
|
||||||
|
let deletedCount = 0;
|
||||||
|
let markedCount = 0;
|
||||||
|
let skippedInProgress = 0;
|
||||||
|
let skippedRecent = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const envKey of envKeys) {
|
||||||
|
let envId: number | null;
|
||||||
|
try {
|
||||||
|
envId = parseEnvDirName(envKey);
|
||||||
|
} catch {
|
||||||
|
// A directory under deploy-logs/ that isn't one of envDirName()'s own
|
||||||
|
// outputs (e.g. an unrelated file or a hand-created directory) is not an
|
||||||
|
// environment this job knows how to reconcile -- skipped defensively
|
||||||
|
// rather than crashing the whole run over it.
|
||||||
|
await log(`Skipping unrecognized deploy-logs entry: ${envKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envRecords = recordsByEnvKey.get(envKey) ?? [];
|
||||||
|
const recordIds = envRecords.map((r) => String(r.id));
|
||||||
|
const fileIds = await deps.listRunLogIds(envId);
|
||||||
|
filesFoundTotal += fileIds.length;
|
||||||
|
|
||||||
|
const plan = planReconcile({ fileIds, recordIds });
|
||||||
|
|
||||||
|
for (const fileId of plan.deleteFiles) {
|
||||||
|
try {
|
||||||
|
// TOCTOU guard (see module doc comment): `records` was snapshotted
|
||||||
|
// once, before this loop started. A file this fresh may belong to a
|
||||||
|
// deploy whose record was created AFTER that snapshot -- not to an
|
||||||
|
// abandoned orphan. Leave it for a later run, which will see its
|
||||||
|
// by-then-existing record and correctly never touch it again.
|
||||||
|
const mtimeMs = await deps.getRunLogMtimeMs(envId, fileId);
|
||||||
|
if (mtimeMs !== null && Date.now() - mtimeMs < ORPHAN_DELETE_GRACE_MS) {
|
||||||
|
skippedRecent++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await deps.deleteRunLog(envId, fileId);
|
||||||
|
deletedCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
failedCount++;
|
||||||
|
await log(`Failed to delete orphan file for run ${fileId} (environment ${envKey}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(envRecords.map((r) => [String(r.id), r]));
|
||||||
|
for (const recordId of plan.markMissing) {
|
||||||
|
const record = byId.get(recordId);
|
||||||
|
// Still running/queued: it may simply not have written its first line yet
|
||||||
|
// (see module doc comment). Leave it alone -- a later reconcile run will
|
||||||
|
// catch it correctly once the run has actually finished.
|
||||||
|
if (record && !isEligibleForMissingMark(record.status)) {
|
||||||
|
skippedInProgress++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateScheduleExecution(Number(recordId), {
|
||||||
|
details: { ...(record?.details ?? {}), logMissing: true }
|
||||||
|
});
|
||||||
|
markedCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
failedCount++;
|
||||||
|
await log(`Failed to mark record ${recordId} as logMissing (environment ${envKey}): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await log(
|
||||||
|
`Found ${filesFoundTotal} deploy-log file(s) across ${envKeys.size} environment(s) and ${records.length} stack_deploy record(s)`
|
||||||
|
);
|
||||||
|
await log(
|
||||||
|
`Reconcile complete: ${deletedCount} orphan file(s) deleted, ${markedCount} record(s) marked logMissing` +
|
||||||
|
(skippedInProgress > 0 ? `, ${skippedInProgress} still-running record(s) skipped` : '') +
|
||||||
|
(skippedRecent > 0 ? `, ${skippedRecent} recently-modified file(s) skipped (possible in-flight deploy)` : '') +
|
||||||
|
(failedCount > 0 ? `, ${failedCount} element(s) failed` : '')
|
||||||
|
);
|
||||||
|
await updateScheduleExecution(execution.id, {
|
||||||
|
// A run that hit per-element failures did complete, but not cleanly -- it
|
||||||
|
// should not read as indistinguishable from one that had nothing go wrong.
|
||||||
|
// 'warning' is exactly this repo's convention for "completed with a caveat"
|
||||||
|
// (see the ScheduleStatus doc comment in db.ts), as opposed to 'failed',
|
||||||
|
// which here is reserved for the run itself throwing (the outer catch below).
|
||||||
|
status: failedCount > 0 ? 'warning' : 'success',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
details: {
|
||||||
|
filesFound: filesFoundTotal,
|
||||||
|
recordsFound: records.length,
|
||||||
|
deletedFiles: deletedCount,
|
||||||
|
markedRecords: markedCount,
|
||||||
|
skippedInProgress,
|
||||||
|
skippedRecent,
|
||||||
|
failed: failedCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await log(`Error: ${error.message}`);
|
||||||
|
await updateScheduleExecution(execution.id, {
|
||||||
|
status: 'failed',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
errorMessage: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,13 @@ export async function runGitStackSync(
|
|||||||
// Deploy the git stack (only if there are changes). deployGitStack now emits the
|
// Deploy the git stack (only if there are changes). deployGitStack now emits the
|
||||||
// git_sync_success/failed/skipped notification itself, so EVERY caller (webhook,
|
// git_sync_success/failed/skipped notification itself, so EVERY caller (webhook,
|
||||||
// manual, this scheduler) notifies uniformly — we no longer dispatch here (#1295).
|
// manual, this scheduler) notifies uniformly — we no longer dispatch here (#1295).
|
||||||
const result = await deployGitStack(stackId, { force: false });
|
//
|
||||||
|
// triggeredBy is forwarded, not hardcoded: this function is itself called with
|
||||||
|
// 'cron' (the nightly scheduler), 'manual' (the schedules page's "run now"), and
|
||||||
|
// 'webhook' (scheduler/index.ts's triggerGitStackSyncFromWebhook) — passing the
|
||||||
|
// value straight through gives the stack_deploy run record the same accurate
|
||||||
|
// trigger this execution's own git_stack_sync record already gets above.
|
||||||
|
const result = await deployGitStack(stackId, { force: false, triggeredBy });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.skipped) {
|
if (result.skipped) {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Minimum length for a secret to be replaced in place. Below it, a value like "test"
|
||||||
|
* would match inside unrelated words ("latest", "testing"), so the whole line is
|
||||||
|
* withheld instead — we cannot safely tell signal from coincidence.
|
||||||
|
*/
|
||||||
|
const MIN_REPLACEABLE_LENGTH = 8;
|
||||||
|
|
||||||
|
/** Placeholder written in place of a secret value. */
|
||||||
|
const PLACEHOLDER = '***';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes known secret values from a single output line.
|
||||||
|
*
|
||||||
|
* This does NOT pattern-match "things that look like secrets" — that approach fails for
|
||||||
|
* every field nobody thought of. It replaces the exact values we injected ourselves and
|
||||||
|
* therefore know.
|
||||||
|
*
|
||||||
|
* Returns the redacted line, or null when the line must be withheld entirely.
|
||||||
|
*/
|
||||||
|
export function redactLine(line: string, secrets: string[]): string | null {
|
||||||
|
let result = line;
|
||||||
|
|
||||||
|
for (const secret of secrets) {
|
||||||
|
const value = secret?.trim();
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (value.length < MIN_REPLACEABLE_LENGTH) {
|
||||||
|
// Too short to replace without hitting unrelated substrings. If it appears at all,
|
||||||
|
// withhold the line: a false positive costs one hidden line, a false negative leaks.
|
||||||
|
if (line.includes(value)) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.split(value).join(PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges a raw line callback to a consumer, applying redaction on the way.
|
||||||
|
* Withheld lines (redactLine returning null) are dropped silently — by design:
|
||||||
|
* a suppressed line is cheaper than a leaked one.
|
||||||
|
*/
|
||||||
|
export function makeLineForwarder(
|
||||||
|
consume: (line: string) => void,
|
||||||
|
secrets: string[]
|
||||||
|
): (line: string) => void {
|
||||||
|
return (line: string) => {
|
||||||
|
const safe = redactLine(line, secrets);
|
||||||
|
if (safe !== null) consume(safe);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces a final output block as a single redacted line, but ONLY when no line was
|
||||||
|
* forwarded during the run (lineCount === 0). Some execution paths (e.g. Hawser's REST
|
||||||
|
* call) report only a block, never individual lines -- without this, the operator sees
|
||||||
|
* nothing while the run is in progress. A path that already forwarded its own lines
|
||||||
|
* (or a future streaming path) must not get the block appended on top: that would show
|
||||||
|
* the same text twice.
|
||||||
|
*/
|
||||||
|
export function surfaceBlockIfNoLines(
|
||||||
|
onLine: ((line: string) => void) | undefined,
|
||||||
|
lineCount: number,
|
||||||
|
block: string | undefined,
|
||||||
|
secrets: string[]
|
||||||
|
): void {
|
||||||
|
if (onLine && lineCount === 0 && block) {
|
||||||
|
makeLineForwarder(onLine, secrets)(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redacting line sink for callers that must also decide, once the run is over, whether to
|
||||||
|
* surface the final output block.
|
||||||
|
*
|
||||||
|
* The two halves belong together because surfaceBlockIfNoLines needs the number of lines that
|
||||||
|
* were actually forwarded, and only the sink knows it. Counting what the sender emitted would
|
||||||
|
* be wrong: a line withheld by redaction never reached the operator, so for them the block is
|
||||||
|
* still the only thing there is to see.
|
||||||
|
*
|
||||||
|
* `forward` is undefined when there is no consumer, so callers can pass it straight into an
|
||||||
|
* optional callback parameter.
|
||||||
|
*/
|
||||||
|
export function makeRedactedLineSink(
|
||||||
|
onLine: ((line: string) => void) | undefined,
|
||||||
|
secrets: string[]
|
||||||
|
): {
|
||||||
|
forward: ((line: string) => void) | undefined;
|
||||||
|
surfaceBlock: (block: string | undefined) => void;
|
||||||
|
} {
|
||||||
|
let forwarded = 0;
|
||||||
|
const forward = onLine
|
||||||
|
? makeLineForwarder((line) => {
|
||||||
|
forwarded++;
|
||||||
|
onLine(line);
|
||||||
|
}, secrets)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
forward,
|
||||||
|
surfaceBlock: (block) => surfaceBlockIfNoLines(onLine, forwarded, block, secrets)
|
||||||
|
};
|
||||||
|
}
|
||||||
+95
-5
@@ -1,10 +1,53 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||||
import { prefersJSON } from '$lib/server/sse-parser';
|
import { prefersJSON } from '$lib/server/sse-parser';
|
||||||
|
import { shouldRecord } from '$lib/server/deploy-recorder';
|
||||||
|
|
||||||
// Re-export pure parsing utilities (no server deps) for backward compat
|
// Re-export pure parsing utilities (no server deps) for backward compat
|
||||||
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
|
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook that mirrors qualifying progress lines into a persisted run record and
|
||||||
|
* is told how the run ended once it's done.
|
||||||
|
*
|
||||||
|
* Built in the ROUTE (see Task 10's deploy-run-record.ts), never here: constructing it
|
||||||
|
* needs db.ts, and importing that here would make this module unimportable under Bun
|
||||||
|
* (ERR_DLOPEN_FAILED) -- breaking every unit test that exercises createJobResponse
|
||||||
|
* directly, this file's own included.
|
||||||
|
*/
|
||||||
|
export interface RunRecorder {
|
||||||
|
/** Called for every send() whose (event, data) passes shouldRecord -- never raw. */
|
||||||
|
line(line: string): void;
|
||||||
|
/**
|
||||||
|
* Extends the redaction secret list with values that were not yet known when the
|
||||||
|
* recorder was constructed -- e.g. provider-resolved secrets (Bitwarden/1Password/
|
||||||
|
* etc. bulk pulls or inline refs) that deployStack() only resolves internally,
|
||||||
|
* strictly AFTER the caller built this recorder from the DB-only vars it had at the
|
||||||
|
* time. Callers MUST call this (if they have anything to add) before end(), which is
|
||||||
|
* the only method that reads the accumulated secret list -- see deploy-run-record.ts.
|
||||||
|
*/
|
||||||
|
addSecrets(values: 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the redacted line string from a shouldRecord-qualifying { type: 'line', line } payload. */
|
||||||
|
function extractLine(data: unknown): string | undefined {
|
||||||
|
if (typeof data !== 'object' || data === null) return undefined;
|
||||||
|
const line = (data as { line?: unknown }).line;
|
||||||
|
return typeof line === 'string' ? line : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives the RunRecorder.end() args from the JSON path's held-back result payload. */
|
||||||
|
function endFromResult(resultData: unknown): { ok: boolean; error?: string } {
|
||||||
|
if (typeof resultData !== 'object' || resultData === null) return { ok: false };
|
||||||
|
const obj = resultData as { success?: unknown; error?: unknown };
|
||||||
|
if (obj.success === true) return { ok: true };
|
||||||
|
const { error } = obj;
|
||||||
|
if (error === undefined) return { ok: false };
|
||||||
|
return { ok: false, error: typeof error === 'string' ? error : String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job-based response for long-running operations.
|
* Job-based response for long-running operations.
|
||||||
*
|
*
|
||||||
@@ -16,10 +59,17 @@ export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
|
|||||||
*
|
*
|
||||||
* The send() callback stores lines with { event, data } so the polling client
|
* The send() callback stores lines with { event, data } so the polling client
|
||||||
* can reconstruct the same event stream semantics used by the old SSE flow.
|
* can reconstruct the same event stream semantics used by the old SSE flow.
|
||||||
|
*
|
||||||
|
* `recorder` is optional and, if omitted, changes nothing about the above -- see
|
||||||
|
* RunRecorder's doc comment for why it's built by the caller, not here. When given, it
|
||||||
|
* is fed from BOTH send() implementations below (JSON path and streaming path) so that
|
||||||
|
* every caller of createJobResponse, not just the ones on the streaming path, gets its
|
||||||
|
* progress lines mirrored.
|
||||||
*/
|
*/
|
||||||
export function createJobResponse(
|
export function createJobResponse(
|
||||||
operation: (send: (event: string, data: unknown) => void, isCancelled: () => boolean) => Promise<void>,
|
operation: (send: (event: string, data: unknown) => void, isCancelled: () => boolean) => Promise<void>,
|
||||||
request?: Request
|
request?: Request,
|
||||||
|
recorder?: RunRecorder
|
||||||
): Response {
|
): Response {
|
||||||
// Backward compat: synchronous JSON path for explicit application/json callers
|
// Backward compat: synchronous JSON path for explicit application/json callers
|
||||||
if (prefersJSON(request)) {
|
if (prefersJSON(request)) {
|
||||||
@@ -29,6 +79,10 @@ export function createJobResponse(
|
|||||||
let resultData: unknown = { success: false, error: 'No result' };
|
let resultData: unknown = { success: false, error: 'No result' };
|
||||||
let sentResult = false;
|
let sentResult = false;
|
||||||
const send = (event: string, data: unknown) => {
|
const send = (event: string, data: unknown) => {
|
||||||
|
if (recorder && shouldRecord(event, data)) {
|
||||||
|
const line = extractLine(data);
|
||||||
|
if (line !== undefined) recorder.line(line);
|
||||||
|
}
|
||||||
// Keep the last 'result' payload as the response body. A handler
|
// Keep the last 'result' payload as the response body. A handler
|
||||||
// may send progress events and then throw (e.g. a backup that
|
// may send progress events and then throw (e.g. a backup that
|
||||||
// returns { status: 'error' } and rethrows), so preserve the
|
// returns { status: 'error' } and rethrows), so preserve the
|
||||||
@@ -40,6 +94,14 @@ export function createJobResponse(
|
|||||||
await operation(send, () => false);
|
await operation(send, () => false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!sentResult) resultData = { success: false, error: String(error) };
|
if (!sentResult) resultData = { success: false, error: String(error) };
|
||||||
|
} finally {
|
||||||
|
// 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.
|
||||||
|
if (recorder) {
|
||||||
|
const { ok, error } = endFromResult(resultData);
|
||||||
|
await recorder.end(ok, undefined, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
controller.enqueue(encoder.encode(JSON.stringify(resultData)));
|
controller.enqueue(encoder.encode(JSON.stringify(resultData)));
|
||||||
controller.close();
|
controller.close();
|
||||||
@@ -54,16 +116,44 @@ export function createJobResponse(
|
|||||||
const job = createJob();
|
const job = createJob();
|
||||||
|
|
||||||
const send = (event: string, data: unknown) => {
|
const send = (event: string, data: unknown) => {
|
||||||
|
if (recorder && shouldRecord(event, data)) {
|
||||||
|
const line = extractLine(data);
|
||||||
|
if (line !== undefined) recorder.line(line);
|
||||||
|
}
|
||||||
appendLine(job, { event, data });
|
appendLine(job, { event, data });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guards RunRecorder.end()'s "called exactly once" contract: if the .then()
|
||||||
|
// branch below reaches its recorder.end() call and that call itself throws,
|
||||||
|
// the rejection lands in .catch(), which must not call end() a second time.
|
||||||
|
// Set right before invoking end() (not after it resolves), so a throwing
|
||||||
|
// end() is still counted as "attempted" and never retried.
|
||||||
|
let recorderEnded = false;
|
||||||
|
|
||||||
operation(send, () => job.cancelRequested === true)
|
operation(send, () => job.cancelRequested === true)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
const resultLine = job.lines.findLast((l) => l.event === 'result');
|
const resultLine = job.lines.findLast((l) => l.event === 'result');
|
||||||
completeJob(job, resultLine?.data ?? { success: true });
|
const resultData = resultLine?.data ?? { success: true };
|
||||||
|
completeJob(job, resultData);
|
||||||
|
// Nothing on this path awaits createJobResponse's return value (it already
|
||||||
|
// returned { jobId } below), so this runs after the response has gone out.
|
||||||
|
// The record is still closed reliably -- just not before the client sees it.
|
||||||
|
//
|
||||||
|
// A failed compose run does not throw: it sends `result: {success:false,
|
||||||
|
// error}` and returns normally, landing HERE, not in .catch() below. This is
|
||||||
|
// 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.
|
||||||
|
if (recorder) {
|
||||||
|
recorderEnded = true;
|
||||||
|
const { ok, error } = endFromResult(resultData);
|
||||||
|
await recorder.end(ok, undefined, error);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch(async (err: unknown) => {
|
||||||
failJob(job, err instanceof Error ? err.message : String(err));
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
failJob(job, message);
|
||||||
|
if (recorder && !recorderEnded) await recorder.end(false, undefined, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ jobId: job.id });
|
return json({ jobId: job.id });
|
||||||
|
|||||||
+110
-45
@@ -10,6 +10,8 @@ import { join, resolve, dirname, basename, isAbsolute, normalize as pathNormaliz
|
|||||||
import { spawn as nodeSpawn } from 'node:child_process';
|
import { spawn as nodeSpawn } from 'node:child_process';
|
||||||
import type { ChildProcess } from 'node:child_process';
|
import type { ChildProcess } from 'node:child_process';
|
||||||
import { redactSecretVars } from './secret-redact';
|
import { redactSecretVars } from './secret-redact';
|
||||||
|
import { collectProcess } from './process-output-core';
|
||||||
|
import { makeLineForwarder, makeRedactedLineSink } from './secret-redaction';
|
||||||
import {
|
import {
|
||||||
applyFileDeletions,
|
applyFileDeletions,
|
||||||
hashDirFiles,
|
hashDirFiles,
|
||||||
@@ -89,6 +91,24 @@ export interface StackOperationResult {
|
|||||||
command?: string;
|
command?: string;
|
||||||
/** Result of applying git deletion sync (files removed / kept, with reasons) */
|
/** Result of applying git deletion sync (files removed / kept, with reasons) */
|
||||||
deletion?: DeletionApplyResult;
|
deletion?: DeletionApplyResult;
|
||||||
|
/**
|
||||||
|
* The process's real exit code, when one exists to report -- the local/direct
|
||||||
|
* compose path runs the command itself and knows it. Left unset on a timeout
|
||||||
|
* (the process was killed, not exited) and on the Hawser path (the agent
|
||||||
|
* protocol doesn't return one). Callers needing an exit code regardless
|
||||||
|
* (deploy-run-record.ts) fall back to a value consistent with success/failure.
|
||||||
|
*/
|
||||||
|
exitCode?: number;
|
||||||
|
/**
|
||||||
|
* Set by deployStack() only: every secret value (DB AND provider-resolved --
|
||||||
|
* Bitwarden/1Password/etc. bulk pulls or inline refs, resolveProviderEnvVars) that
|
||||||
|
* actually reached the container for THIS run. Provider resolution happens inside
|
||||||
|
* deployStack(), after any caller-built stack_deploy run recorder was already
|
||||||
|
* constructed from DB-only vars -- callers MUST feed this into the recorder via
|
||||||
|
* RunRecorder.addSecrets() before closing it, or a provider-resolved secret that
|
||||||
|
* surfaces in compose's raw error text is stored unredacted (see deploy-run-record.ts).
|
||||||
|
*/
|
||||||
|
resolvedSecrets?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,6 +167,8 @@ export interface DeployStackOptions {
|
|||||||
* (Stack events and Git sync are separate user-facing groups). stack_events is
|
* (Stack events and Git sync are separate user-facing groups). stack_events is
|
||||||
* still recorded regardless. (#1295) */
|
* still recorded regardless. (#1295) */
|
||||||
isGitDeploy?: boolean;
|
isGitDeploy?: boolean;
|
||||||
|
/** Optional callback invoked per redacted output line as the compose command runs. */
|
||||||
|
onLine?: (line: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -242,25 +264,11 @@ function isBinaryContent(bytes: Uint8Array): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// collectProcess lives in ./process-output-core (imported above) -- pure,
|
||||||
* Collect stdout/stderr from a child process and wait for it to exit.
|
// dependency-free, so it stays unit-testable without dragging in the DB
|
||||||
*/
|
// module chain. Re-exported here so existing call sites (loginToRegistries,
|
||||||
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
// executeLocalCompose) are unaffected.
|
||||||
return new Promise((resolve, reject) => {
|
export { collectProcess };
|
||||||
const stdoutChunks: Buffer[] = [];
|
|
||||||
const stderrChunks: Buffer[] = [];
|
|
||||||
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
|
||||||
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
|
||||||
proc.on('error', reject);
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
resolve({
|
|
||||||
exitCode: code ?? 1,
|
|
||||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
|
||||||
stderr: Buffer.concat(stderrChunks).toString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read all files from a directory as a map of relative path -> content.
|
* Read all files from a directory as a map of relative path -> content.
|
||||||
@@ -1162,6 +1170,8 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
|
|||||||
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
|
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
|
||||||
* @param workingDir - Optional working directory for compose execution (for imported stacks)
|
* @param workingDir - Optional working directory for compose execution (for imported stacks)
|
||||||
* @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing)
|
* @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing)
|
||||||
|
* @param onLine - Optional callback invoked per output line, redacted against envVars/secretVars
|
||||||
|
* (NOT spawnEnv — that also carries PATH/HOME/DOCKER_CONFIG and would over-redact)
|
||||||
*/
|
*/
|
||||||
async function executeLocalCompose(
|
async function executeLocalCompose(
|
||||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||||
@@ -1185,7 +1195,8 @@ async function executeLocalCompose(
|
|||||||
// direct-remote only: when the stack folder was staged to <remoteStackHostDir> on the target
|
// direct-remote only: when the stack folder was staged to <remoteStackHostDir> on the target
|
||||||
// host, rewrite the compose's same-dir relative binds (`./x`) to <remoteStackHostDir>/x so the
|
// host, rewrite the compose's same-dir relative binds (`./x`) to <remoteStackHostDir>/x so the
|
||||||
// remote daemon binds the staged files. undefined = no staging, compose unchanged.
|
// remote daemon binds the staged files. undefined = no staging, compose unchanged.
|
||||||
remoteStackHostDir?: string
|
remoteStackHostDir?: string,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const logPrefix = `[Stack:${stackName}]`;
|
const logPrefix = `[Stack:${stackName}]`;
|
||||||
|
|
||||||
@@ -1481,7 +1492,15 @@ async function executeLocalCompose(
|
|||||||
}, COMPOSE_TIMEOUT_MS);
|
}, COMPOSE_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { exitCode: code, stdout, stderr } = await collectProcess(proc);
|
// Do NOT use spawnEnv! It also carries PATH, HOME, DOCKER_CONFIG, DOCKER_API_VERSION.
|
||||||
|
// HOME typically falls back to "/root" -- 5 characters, below MIN_REPLACEABLE_LENGTH.
|
||||||
|
// Under our own rule, that would withhold EVERY line containing "/root".
|
||||||
|
const secrets = [...Object.values(envVars ?? {}), ...Object.values(secretVars ?? {})]
|
||||||
|
.filter((v): v is string => typeof v === 'string');
|
||||||
|
const { exitCode: code, stdout, stderr } = await collectProcess(
|
||||||
|
proc,
|
||||||
|
onLine ? makeLineForwarder(onLine, secrets) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`);
|
console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`);
|
||||||
@@ -1510,7 +1529,8 @@ async function executeLocalCompose(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
|
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
|
||||||
command: commandStr
|
command: commandStr,
|
||||||
|
exitCode: code
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// stderr can echo an interpolated secret value (e.g. a failing
|
// stderr can echo an interpolated secret value (e.g. a failing
|
||||||
@@ -1520,7 +1540,8 @@ async function executeLocalCompose(
|
|||||||
success: false,
|
success: false,
|
||||||
output: redactSecretVars(stdout, secretVars),
|
output: redactSecretVars(stdout, secretVars),
|
||||||
error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`,
|
error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`,
|
||||||
command: commandStr
|
command: commandStr,
|
||||||
|
exitCode: code
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1562,6 +1583,12 @@ async function executeLocalCompose(
|
|||||||
*
|
*
|
||||||
* @param envVars - Non-secret environment variables (from .env file)
|
* @param envVars - Non-secret environment variables (from .env file)
|
||||||
* @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env)
|
* @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env)
|
||||||
|
* @param onLine - Called per redacted output line while the command runs. Hawser's
|
||||||
|
* `/_hawser/compose` call is a single request/response, but an agent that understands
|
||||||
|
* `streamOutput` sends its output alongside it as 'stream' messages, which the Edge
|
||||||
|
* connection routes back here by requestId. An older agent sends none; for it the
|
||||||
|
* response's `output` block is surfaced as one line instead. Never both -- see
|
||||||
|
* makeRedactedLineSink.
|
||||||
*/
|
*/
|
||||||
async function executeComposeViaHawser(
|
async function executeComposeViaHawser(
|
||||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||||
@@ -1579,7 +1606,8 @@ async function executeComposeViaHawser(
|
|||||||
noBuildCache?: boolean,
|
noBuildCache?: boolean,
|
||||||
pullPolicy?: string,
|
pullPolicy?: string,
|
||||||
filesToDelete?: FileToDelete[],
|
filesToDelete?: FileToDelete[],
|
||||||
removeFiles?: boolean
|
removeFiles?: boolean,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const logPrefix = `[Stack:${stackName}]`;
|
const logPrefix = `[Stack:${stackName}]`;
|
||||||
// Import dockerFetch dynamically to avoid circular dependency
|
// Import dockerFetch dynamically to avoid circular dependency
|
||||||
@@ -1589,6 +1617,10 @@ async function executeComposeViaHawser(
|
|||||||
// Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env)
|
// Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env)
|
||||||
const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) };
|
const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) };
|
||||||
const secretCount = secretVars ? Object.keys(secretVars).length : 0;
|
const secretCount = secretVars ? Object.keys(secretVars).length : 0;
|
||||||
|
// Unlike spawnEnv on the local path, allEnvVars is genuinely just the stack's own
|
||||||
|
// variables -- no PATH/HOME/DOCKER_CONFIG that would withhold half the output.
|
||||||
|
const secrets = Object.values(allEnvVars).filter((v): v is string => typeof v === 'string');
|
||||||
|
const lines = makeRedactedLineSink(onLine, secrets);
|
||||||
|
|
||||||
console.log(`${logPrefix} ----------------------------------------`);
|
console.log(`${logPrefix} ----------------------------------------`);
|
||||||
console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`);
|
console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`);
|
||||||
@@ -1663,7 +1695,10 @@ async function executeComposeViaHawser(
|
|||||||
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
|
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
|
||||||
: undefined,
|
: undefined,
|
||||||
// Stack deletion (#1162): remove the agent-side stack dir on down
|
// Stack deletion (#1162): remove the agent-side stack dir on down
|
||||||
removeFiles: removeFiles || false
|
removeFiles: removeFiles || false,
|
||||||
|
// Ask the agent to also send its output line by line while the command runs.
|
||||||
|
// Old agents ignore the field and just return the block as before.
|
||||||
|
streamOutput: !!onLine
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
||||||
@@ -1672,7 +1707,8 @@ async function executeComposeViaHawser(
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body
|
body,
|
||||||
|
onLine: lines.forward
|
||||||
},
|
},
|
||||||
envId
|
envId
|
||||||
);
|
);
|
||||||
@@ -1696,6 +1732,10 @@ async function executeComposeViaHawser(
|
|||||||
console.log(`${logPrefix} Error:`, result.error);
|
console.log(`${logPrefix} Error:`, result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only reaches the operator when the agent streamed nothing -- otherwise they would
|
||||||
|
// see the whole run a second time, appended to the lines they already watched.
|
||||||
|
lines.surfaceBlock(result.output);
|
||||||
|
|
||||||
// Git deletion sync: interpret the agent's report. An agent that supports
|
// Git deletion sync: interpret the agent's report. An agent that supports
|
||||||
// the feature always returns deletedFiles/skippedFiles (possibly empty
|
// the feature always returns deletedFiles/skippedFiles (possibly empty
|
||||||
// arrays) when filesToDelete was sent. An old agent ignores the field and
|
// arrays) when filesToDelete was sent. An old agent ignores the field and
|
||||||
@@ -1762,13 +1802,16 @@ async function executeComposeViaHawser(
|
|||||||
*
|
*
|
||||||
* @param envVars - Non-secret environment variables (from .env file)
|
* @param envVars - Non-secret environment variables (from .env file)
|
||||||
* @param secretVars - Secret environment variables (from DB, injected via shell env)
|
* @param secretVars - Secret environment variables (from DB, injected via shell env)
|
||||||
|
* @param onLine - Optional callback invoked per redacted output line as the command runs.
|
||||||
|
* Forwarded to whichever execution path is chosen (local socket, direct, or Hawser).
|
||||||
*/
|
*/
|
||||||
async function executeComposeCommand(
|
async function executeComposeCommand(
|
||||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build',
|
||||||
options: ComposeCommandOptions,
|
options: ComposeCommandOptions,
|
||||||
composeContent: string,
|
composeContent: string,
|
||||||
envVars?: Record<string, string>,
|
envVars?: Record<string, string>,
|
||||||
secretVars?: Record<string, string>
|
secretVars?: Record<string, string>,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
|
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
|
||||||
|
|
||||||
@@ -1795,7 +1838,9 @@ async function executeComposeCommand(
|
|||||||
serviceName,
|
serviceName,
|
||||||
build,
|
build,
|
||||||
noBuildCache,
|
noBuildCache,
|
||||||
pullPolicy
|
pullPolicy,
|
||||||
|
undefined, // remoteStackHostDir
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1861,7 +1906,8 @@ async function executeComposeCommand(
|
|||||||
noBuildCache,
|
noBuildCache,
|
||||||
pullPolicy,
|
pullPolicy,
|
||||||
filesToDelete,
|
filesToDelete,
|
||||||
removeFiles
|
removeFiles,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1924,7 +1970,8 @@ async function executeComposeCommand(
|
|||||||
build,
|
build,
|
||||||
noBuildCache,
|
noBuildCache,
|
||||||
pullPolicy,
|
pullPolicy,
|
||||||
remoteStackHostDir
|
remoteStackHostDir,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1956,7 +2003,9 @@ async function executeComposeCommand(
|
|||||||
serviceName,
|
serviceName,
|
||||||
build,
|
build,
|
||||||
noBuildCache,
|
noBuildCache,
|
||||||
pullPolicy
|
pullPolicy,
|
||||||
|
undefined, // remoteStackHostDir
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2414,7 +2463,8 @@ async function notifyStackLifecycle(stackName: string, envId: number | null | un
|
|||||||
|
|
||||||
export async function startStack(
|
export async function startStack(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null
|
envId?: number | null,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const result = await requireComposeFile(stackName, envId);
|
const result = await requireComposeFile(stackName, envId);
|
||||||
|
|
||||||
@@ -2447,7 +2497,8 @@ export async function startStack(
|
|||||||
opts,
|
opts,
|
||||||
result.content!,
|
result.content!,
|
||||||
result.nonSecretVars,
|
result.nonSecretVars,
|
||||||
result.secretVars
|
result.secretVars,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
await notifyStackLifecycle(stackName, envId, 'stack_started', startResult);
|
await notifyStackLifecycle(stackName, envId, 'stack_started', startResult);
|
||||||
return startResult;
|
return startResult;
|
||||||
@@ -2459,7 +2510,8 @@ export async function startStack(
|
|||||||
*/
|
*/
|
||||||
export async function stopStack(
|
export async function stopStack(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null
|
envId?: number | null,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const result = await requireComposeFile(stackName, envId);
|
const result = await requireComposeFile(stackName, envId);
|
||||||
|
|
||||||
@@ -2481,7 +2533,8 @@ export async function stopStack(
|
|||||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
||||||
result.content!,
|
result.content!,
|
||||||
result.nonSecretVars,
|
result.nonSecretVars,
|
||||||
result.secretVars
|
result.secretVars,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop any dynamically-spawned child containers not in the compose file
|
// Stop any dynamically-spawned child containers not in the compose file
|
||||||
@@ -2507,7 +2560,8 @@ export async function stopStack(
|
|||||||
export async function restartStack(
|
export async function restartStack(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null,
|
envId?: number | null,
|
||||||
mode: 'restart' | 'ordered' | 'recreate' = 'restart'
|
mode: 'restart' | 'ordered' | 'recreate' = 'restart',
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const result = await requireComposeFile(stackName, envId);
|
const result = await requireComposeFile(stackName, envId);
|
||||||
|
|
||||||
@@ -2528,16 +2582,16 @@ export async function restartStack(
|
|||||||
|
|
||||||
if (mode === 'recreate') {
|
if (mode === 'recreate') {
|
||||||
// Stop first, then bring up with --force-recreate to ensure new container IDs
|
// Stop first, then bring up with --force-recreate to ensure new container IDs
|
||||||
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
|
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars, onLine);
|
||||||
await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`);
|
await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`);
|
||||||
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars);
|
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars, onLine);
|
||||||
} else if (mode === 'ordered') {
|
} else if (mode === 'ordered') {
|
||||||
// Stop everything, then start in depends_on order (compose start honors the
|
// Stop everything, then start in depends_on order (compose start honors the
|
||||||
// dependency graph). Same container IDs, no recreate, no re-pull.
|
// dependency graph). Same container IDs, no recreate, no re-pull.
|
||||||
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
|
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars, onLine);
|
||||||
composeResult = await executeComposeCommand('start', opts, result.content!, result.nonSecretVars, result.secretVars);
|
composeResult = await executeComposeCommand('start', opts, result.content!, result.nonSecretVars, result.secretVars, onLine);
|
||||||
} else {
|
} else {
|
||||||
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars);
|
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars, onLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart any dynamically-spawned child containers not in the compose file
|
// Restart any dynamically-spawned child containers not in the compose file
|
||||||
@@ -2553,7 +2607,8 @@ export async function restartStack(
|
|||||||
export async function downStack(
|
export async function downStack(
|
||||||
stackName: string,
|
stackName: string,
|
||||||
envId?: number | null,
|
envId?: number | null,
|
||||||
removeVolumes = false
|
removeVolumes = false,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<StackOperationResult> {
|
): Promise<StackOperationResult> {
|
||||||
const result = await requireComposeFile(stackName, envId);
|
const result = await requireComposeFile(stackName, envId);
|
||||||
|
|
||||||
@@ -2571,7 +2626,8 @@ export async function downStack(
|
|||||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) },
|
||||||
result.content!,
|
result.content!,
|
||||||
result.nonSecretVars,
|
result.nonSecretVars,
|
||||||
result.secretVars
|
result.secretVars,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove any dynamically-spawned child containers not in the compose file
|
// Remove any dynamically-spawned child containers not in the compose file
|
||||||
@@ -2946,7 +3002,7 @@ async function reconcileStackPendingUpdates(stackName: string, envId: number): P
|
|||||||
* Uses stack locking to prevent concurrent deployments.
|
* Uses stack locking to prevent concurrent deployments.
|
||||||
*/
|
*/
|
||||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy } = options;
|
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy, onLine } = options;
|
||||||
const logPrefix = `[Stack:${name}]`;
|
const logPrefix = `[Stack:${name}]`;
|
||||||
|
|
||||||
console.log(`${logPrefix} ========================================`);
|
console.log(`${logPrefix} ========================================`);
|
||||||
@@ -3178,8 +3234,17 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
|||||||
cmdOptions,
|
cmdOptions,
|
||||||
compose,
|
compose,
|
||||||
composeEnvVars,
|
composeEnvVars,
|
||||||
secretVars
|
secretVars,
|
||||||
|
onLine
|
||||||
);
|
);
|
||||||
|
// F4 fix: `secretVars` here is POST-resolveProviderEnvVars (line ~3059 above) --
|
||||||
|
// the same set executeComposeCommand just redacted streamed lines against. This
|
||||||
|
// is the single call site inside deployStack(), so setting it here covers both
|
||||||
|
// the local/direct compose path and the Hawser path uniformly. Callers (routes,
|
||||||
|
// deployGitStack) feed this into their stack_deploy run recorder via
|
||||||
|
// RunRecorder.addSecrets() before closing it -- see StackOperationResult's doc
|
||||||
|
// comment and deploy-run-record.ts.
|
||||||
|
result.resolvedSecrets = Object.values(secretVars);
|
||||||
console.log(`${logPrefix} ========================================`);
|
console.log(`${logPrefix} ========================================`);
|
||||||
console.log(`${logPrefix} DEPLOY STACK RESULT`);
|
console.log(`${logPrefix} DEPLOY STACK RESULT`);
|
||||||
console.log(`${logPrefix} ========================================`);
|
console.log(`${logPrefix} ========================================`);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface AppSettings {
|
|||||||
eventCleanupEnabled: boolean;
|
eventCleanupEnabled: boolean;
|
||||||
scannerCleanupCron: string;
|
scannerCleanupCron: string;
|
||||||
scannerCleanupEnabled: boolean;
|
scannerCleanupEnabled: boolean;
|
||||||
|
deployLogReconcileCron: string;
|
||||||
|
deployLogReconcileEnabled: boolean;
|
||||||
logBufferSizeKb: number; // legacy, retained for migration — UI uses logMaxLines
|
logBufferSizeKb: number; // legacy, retained for migration — UI uses logMaxLines
|
||||||
logMaxLines: number; // line-count cap for the log buffer (replaces KB-based limit)
|
logMaxLines: number; // line-count cap for the log buffer (replaces KB-based limit)
|
||||||
defaultTimezone: string;
|
defaultTimezone: string;
|
||||||
@@ -77,6 +79,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
eventCleanupEnabled: true,
|
eventCleanupEnabled: true,
|
||||||
scannerCleanupCron: '0 3 * * 0',
|
scannerCleanupCron: '0 3 * * 0',
|
||||||
scannerCleanupEnabled: true,
|
scannerCleanupEnabled: true,
|
||||||
|
deployLogReconcileCron: '0 4 * * *',
|
||||||
|
deployLogReconcileEnabled: true,
|
||||||
logBufferSizeKb: 500,
|
logBufferSizeKb: 500,
|
||||||
logMaxLines: 2000,
|
logMaxLines: 2000,
|
||||||
defaultTimezone: 'UTC',
|
defaultTimezone: 'UTC',
|
||||||
@@ -167,6 +171,8 @@ function createSettingsStore() {
|
|||||||
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||||
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||||
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||||
|
deployLogReconcileCron: settings.deployLogReconcileCron ?? DEFAULT_SETTINGS.deployLogReconcileCron,
|
||||||
|
deployLogReconcileEnabled: settings.deployLogReconcileEnabled ?? DEFAULT_SETTINGS.deployLogReconcileEnabled,
|
||||||
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||||
logMaxLines: deriveLogMaxLines(settings),
|
logMaxLines: deriveLogMaxLines(settings),
|
||||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||||
@@ -227,6 +233,8 @@ function createSettingsStore() {
|
|||||||
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||||
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||||
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||||
|
deployLogReconcileCron: updatedSettings.deployLogReconcileCron ?? DEFAULT_SETTINGS.deployLogReconcileCron,
|
||||||
|
deployLogReconcileEnabled: updatedSettings.deployLogReconcileEnabled ?? DEFAULT_SETTINGS.deployLogReconcileEnabled,
|
||||||
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||||
logMaxLines: deriveLogMaxLines(updatedSettings),
|
logMaxLines: deriveLogMaxLines(updatedSettings),
|
||||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||||
@@ -416,6 +424,20 @@ function createSettingsStore() {
|
|||||||
return newSettings;
|
return newSettings;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setDeployLogReconcileCron: (value: string) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, deployLogReconcileCron: value };
|
||||||
|
saveSettings({ deployLogReconcileCron: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setDeployLogReconcileEnabled: (value: boolean) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, deployLogReconcileEnabled: value };
|
||||||
|
saveSettings({ deployLogReconcileEnabled: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
setLogBufferSizeKb: (value: number) => {
|
setLogBufferSizeKb: (value: number) => {
|
||||||
update((current) => {
|
update((current) => {
|
||||||
const newSettings = { ...current, logBufferSizeKb: value };
|
const newSettings = { ...current, logBufferSizeKb: value };
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Clamps a proposed number into `[min, max]`.
|
||||||
|
*
|
||||||
|
* A naive `Math.max(min, Math.min(max, value))` looks like it clamps any input, but
|
||||||
|
* it does not: if `value` is `NaN`, both `Math.min` and `Math.max` propagate the NaN
|
||||||
|
* -- any NaN operand makes them return NaN, regardless of the other operand -- so the
|
||||||
|
* "clamped" result is still NaN. That NaN then ends up as a CSS `height: NaN%` (or
|
||||||
|
* `width: NaNpx`), which the browser treats as invalid and the panel collapses.
|
||||||
|
*
|
||||||
|
* This is not a theoretical edge case for a drag-resize handler: the proposed value is
|
||||||
|
* usually computed from `(cursorPosition - containerEdge) / containerSize` (as a
|
||||||
|
* percentage) or `containerEdge - cursorPosition` (as a pixel distance), and a
|
||||||
|
* container's measured size is briefly `0` while a panel is transitioning (e.g. a
|
||||||
|
* panel appearing/disappearing) -- dividing by that zero produces NaN either way.
|
||||||
|
* `min`/`max` may be a percentage range (e.g. 15-70 for a split ratio) or a pixel
|
||||||
|
* range (e.g. 320-560 for a panel width) -- the clamp itself does not care about the
|
||||||
|
* unit, only about ordering `value` against two bounds, so both use it unchanged.
|
||||||
|
*
|
||||||
|
* `Infinity`/`-Infinity` do not need the same special-casing: they are well-ordered
|
||||||
|
* numbers, so `Math.min`/`Math.max` clamp them to `max`/`min` correctly on their own.
|
||||||
|
* Only `NaN` breaks the ordinary clamp.
|
||||||
|
*/
|
||||||
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a compose file declares a `build:` section for any service -- used to
|
||||||
|
* pre-check "Build images" in the Save & redeploy / Create & Start popover
|
||||||
|
* (RedeployPopover's defaultBuild).
|
||||||
|
*
|
||||||
|
* `build:` can be a bare string (`build: ./dir`) or an object
|
||||||
|
* (`build: {context: ., dockerfile: ...}`) -- this checks truthiness, the same way
|
||||||
|
* ComposeGraphViewer.svelte's `config.image || (config.build ? 'build' : 'custom')`
|
||||||
|
* already does, not `typeof`, so both forms are detected equally.
|
||||||
|
*
|
||||||
|
* Parse errors (mid-edit, invalid YAML) fall back to false rather than throwing: an
|
||||||
|
* unparseable draft shouldn't crash the checkbox, and "Build images" can still be
|
||||||
|
* checked manually while the operator is still typing.
|
||||||
|
*/
|
||||||
|
export function hasBuildSection(composeContent: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(composeContent) as { services?: Record<string, { build?: unknown }> } | undefined;
|
||||||
|
const services = parsed?.services;
|
||||||
|
if (!services || typeof services !== 'object') return false;
|
||||||
|
return Object.values(services).some((svc) => !!svc?.build);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Turns one recorded stack-deploy run (GET /api/stacks/{name}/deploys) into the
|
||||||
|
* display strings the Deploys tab shows for it. Every function here is pure text
|
||||||
|
* composition -- no fetch, no store, no formatting that depends on user settings
|
||||||
|
* (relative/exact timestamps stay in the component, which already has
|
||||||
|
* formatRelativeTime/formatDateTime available for that).
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free (mirrors deploy-summary-core.ts / run-status.ts):
|
||||||
|
* no import from $lib/server (better-sqlite3 isn't importable under Bun, and would
|
||||||
|
* drag down every other test file sharing this process) and no import from
|
||||||
|
* $lib/stores or $app -- those need a mounted Svelte/SvelteKit runtime this test
|
||||||
|
* file doesn't have. The shapes below intentionally mirror (not import)
|
||||||
|
* deploy-run-record-core.ts / deploy-summary-core.ts on the server: client code
|
||||||
|
* never imports $lib/server, even for types (see StackModal.svelte's own local
|
||||||
|
* BackupConfig for the same pattern).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DeployRunOptions {
|
||||||
|
pull: boolean;
|
||||||
|
build: boolean;
|
||||||
|
forceRecreate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployRunSummary {
|
||||||
|
containersCreated: number;
|
||||||
|
containersRecreated: number;
|
||||||
|
containersStarted: number;
|
||||||
|
imagesBuilt: string[];
|
||||||
|
imagesPulled: string[];
|
||||||
|
buildSteps: number;
|
||||||
|
buildStepsCached: number;
|
||||||
|
digest?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployRunDetails {
|
||||||
|
options?: DeployRunOptions;
|
||||||
|
summary?: DeployRunSummary;
|
||||||
|
/** Set when the run's output hit deploy-log-store's size budget and was cut off. */
|
||||||
|
truncated?: boolean;
|
||||||
|
/** Set by deploy-log-reconcile once it confirms the on-disk log file is gone
|
||||||
|
* (see deploy-log-reconcile.ts) -- never cleared, and never set for a run
|
||||||
|
* that is still in progress (the reconcile job skips those). */
|
||||||
|
logMissing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployRun {
|
||||||
|
id: number;
|
||||||
|
triggeredBy: string;
|
||||||
|
/** Null for a run that never got past the queued state. Display-only here --
|
||||||
|
* formatting (relative + exact) stays in the component, which already has
|
||||||
|
* the user's time-format settings available for it. */
|
||||||
|
startedAt?: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
details?: DeployRunDetails | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the Deploys tab renders for one run, pre-composed so the component
|
||||||
|
* only ever binds -- it never branches on raw run/details/summary shape itself. */
|
||||||
|
export interface DeployRunView {
|
||||||
|
statusLabel: string;
|
||||||
|
trigger: string;
|
||||||
|
duration: string;
|
||||||
|
containerSummary: string;
|
||||||
|
buildStatus: string;
|
||||||
|
optionChips: string[];
|
||||||
|
imagesBuilt: string[];
|
||||||
|
imagesPulled: string[];
|
||||||
|
/** Shortened last line of errorMessage -- only ever set for a failed run. */
|
||||||
|
errorSummary: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'Success';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
case 'running':
|
||||||
|
return 'Running';
|
||||||
|
default:
|
||||||
|
// An unrecognized status is a schema drift worth seeing as-is, not one
|
||||||
|
// more row silently rendered "Success" or hidden entirely.
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTrigger(triggeredBy: string): string {
|
||||||
|
switch (triggeredBy) {
|
||||||
|
case 'cron':
|
||||||
|
return 'Scheduled';
|
||||||
|
case 'webhook':
|
||||||
|
return 'Webhook';
|
||||||
|
case 'startup':
|
||||||
|
return 'Startup';
|
||||||
|
case 'manual':
|
||||||
|
return 'Manual';
|
||||||
|
default:
|
||||||
|
return triggeredBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors ExecutionHistoryList.svelte's local formatDuration so the two run-history
|
||||||
|
* surfaces (backups, deploys) read the same way. Kept here (not imported from
|
||||||
|
* there) because that one is a component-local, unexported function. */
|
||||||
|
export function formatRunDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms === null || ms === undefined) return '—';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run with NO summary at all (compose produced zero output lines -- see
|
||||||
|
* deploy-run-record.ts: `summary: this.lines.length > 0 ? summarize(this.lines) :
|
||||||
|
* undefined`) is not the same fact as a run whose summary says every count is
|
||||||
|
* zero. The first means "we don't know what happened"; the second means "we know,
|
||||||
|
* and nothing changed". Collapsing them into one "0 containers" string would
|
||||||
|
* report the first case as a fact it never established.
|
||||||
|
*/
|
||||||
|
export function formatContainerSummary(summary: DeployRunSummary | undefined): string {
|
||||||
|
if (!summary) return 'No summary recorded for this run';
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (summary.containersCreated > 0) parts.push(`${summary.containersCreated} created`);
|
||||||
|
if (summary.containersRecreated > 0) parts.push(`${summary.containersRecreated} recreated`);
|
||||||
|
if (summary.containersStarted > 0) parts.push(`${summary.containersStarted} started`);
|
||||||
|
return parts.length > 0 ? parts.join(', ') : 'No container changes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Was anything built, and how much of that came from the build cache" -- the one
|
||||||
|
* question this whole feature was built to answer (see the task brief). Same
|
||||||
|
* unknown-vs-zero distinction as formatContainerSummary: no summary reads as
|
||||||
|
* "unknown", a summary with buildSteps === 0 reads as "nothing built" -- never the
|
||||||
|
* same string.
|
||||||
|
*/
|
||||||
|
export function formatBuildStatus(summary: DeployRunSummary | undefined): string {
|
||||||
|
if (!summary) return 'Build status unknown';
|
||||||
|
const { buildSteps, buildStepsCached } = summary;
|
||||||
|
if (buildSteps === 0) return 'Nothing built';
|
||||||
|
if (buildStepsCached === buildSteps) {
|
||||||
|
return buildSteps === 1 ? 'Built from cache' : `Built from cache (${buildSteps} steps)`;
|
||||||
|
}
|
||||||
|
if (buildStepsCached === 0) {
|
||||||
|
return buildSteps === 1 ? 'Built (1 step)' : `Built (${buildSteps} steps)`;
|
||||||
|
}
|
||||||
|
return `Built (${buildSteps} steps, ${buildStepsCached} from cache)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOptionChips(options: DeployRunOptions | undefined): string[] {
|
||||||
|
if (!options) return [];
|
||||||
|
const chips: string[] = [];
|
||||||
|
if (options.pull) chips.push('Pull');
|
||||||
|
if (options.build) chips.push('Build');
|
||||||
|
if (options.forceRecreate) chips.push('Force recreate');
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last non-empty line of a (possibly multi-line) error, cut to maxLen with a
|
||||||
|
* trailing "…" marker. errorMessage is compose's raw stdout/stderr tail (already
|
||||||
|
* redacted server-side, see deploy-run-record.ts) -- often several lines of
|
||||||
|
* progress before the actual failure, so the LAST line is the one worth showing
|
||||||
|
* collapsed; the full text stays available via the run's log file.
|
||||||
|
*/
|
||||||
|
export function lastErrorLine(errorMessage: string | null | undefined, maxLen = 200): string | null {
|
||||||
|
if (!errorMessage) return null;
|
||||||
|
const lines = errorMessage
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.length > 0);
|
||||||
|
const last = lines.length > 0 ? lines[lines.length - 1] : errorMessage.trim();
|
||||||
|
if (last.length === 0) return null;
|
||||||
|
if (last.length <= maxLen) return last;
|
||||||
|
return `${last.slice(0, maxLen - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Deploys tab's expanded row shows for a run's LOG PANEL specifically --
|
||||||
|
* a separate, narrower question from buildDeployRunView's "how does the row
|
||||||
|
* summarize" above it. Three independent facts, each driving its own piece of
|
||||||
|
* UI (see the component): whether the log is worth fetching at all, whether a
|
||||||
|
* truncation notice belongs above it, and whether a delete action makes sense
|
||||||
|
* for a run that might still be writing to that very file.
|
||||||
|
*
|
||||||
|
* logMissing short-circuits the fetch entirely -- a run already marked missing
|
||||||
|
* by deploy-log-reconcile will 404 on GET .../log every time, so there is no
|
||||||
|
* reason to make that round trip just to learn what the record already says.
|
||||||
|
*/
|
||||||
|
export interface DeployLogPanelState {
|
||||||
|
/** True when the record already knows the log file is gone (details.logMissing).
|
||||||
|
* The panel shows "log no longer available" and never fires a GET for it. */
|
||||||
|
logMissing: boolean;
|
||||||
|
/** True when the stored log was cut off at deploy-log-store's size budget --
|
||||||
|
* same underlying flag DeployRunView.truncated surfaces for the collapsed
|
||||||
|
* row, repeated here so the log panel doesn't need the raw run for it too. */
|
||||||
|
truncated: boolean;
|
||||||
|
/** False for a run still in progress. Its log file may still be growing on
|
||||||
|
* disk, and deleting the record out from under an active write would race
|
||||||
|
* the writer -- so no delete action is offered until the run has settled. */
|
||||||
|
deletable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeployLogPanelState(run: DeployRun): DeployLogPanelState {
|
||||||
|
return {
|
||||||
|
logMissing: run.details?.logMissing === true,
|
||||||
|
truncated: run.details?.truncated === true,
|
||||||
|
deletable: run.status !== 'running'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeployRunView(run: DeployRun): DeployRunView {
|
||||||
|
const summary = run.details?.summary;
|
||||||
|
return {
|
||||||
|
statusLabel: formatStatusLabel(run.status),
|
||||||
|
trigger: formatTrigger(run.triggeredBy),
|
||||||
|
duration: formatRunDuration(run.duration),
|
||||||
|
containerSummary: formatContainerSummary(summary),
|
||||||
|
buildStatus: formatBuildStatus(summary),
|
||||||
|
optionChips: formatOptionChips(run.details?.options),
|
||||||
|
imagesBuilt: summary?.imagesBuilt ?? [],
|
||||||
|
imagesPulled: summary?.imagesPulled ?? [],
|
||||||
|
errorSummary: run.status === 'failed' ? lastErrorLine(run.errorMessage) : null,
|
||||||
|
truncated: run.details?.truncated === true
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseTimestamp } from './date-format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a byte count into a human-readable string.
|
* Format a byte count into a human-readable string.
|
||||||
*/
|
*/
|
||||||
@@ -36,9 +38,16 @@ export function formatErrorLines(msg: string | null | undefined): string {
|
|||||||
* Compact relative time like "just now", "5m ago", "2d ago", "3mo ago". Returns ''
|
* Compact relative time like "just now", "5m ago", "2d ago", "3mo ago". Returns ''
|
||||||
* for an unparseable/NaN date and "in the future" for a timestamp ahead of now.
|
* for an unparseable/NaN date and "in the future" for a timestamp ahead of now.
|
||||||
* Kept in this import-light module so it is unit-testable; stores/settings re-exports it.
|
* Kept in this import-light module so it is unit-testable; stores/settings re-exports it.
|
||||||
|
*
|
||||||
|
* Uses parseTimestamp so a naive server timestamp (PG `timestamp without time
|
||||||
|
* zone`, e.g. "2026-08-30 20:36:11.534") is read as UTC instead of the
|
||||||
|
* browser's local zone - see date-format.ts and #1183. Every caller of this
|
||||||
|
* function passes exactly that kind of raw server value alongside
|
||||||
|
* formatDateTime(), which already goes through parseTimestamp(); this kept
|
||||||
|
* the two out of sync until now.
|
||||||
*/
|
*/
|
||||||
export function formatRelativeTime(date: Date | string | number): string {
|
export function formatRelativeTime(date: Date | string | number): string {
|
||||||
const d = date instanceof Date ? date : new Date(date);
|
const d = parseTimestamp(date);
|
||||||
const ms = d.getTime();
|
const ms = d.getTime();
|
||||||
if (isNaN(ms)) return '';
|
if (isNaN(ms)) return '';
|
||||||
const sec = Math.round((Date.now() - ms) / 1000);
|
const sec = Math.round((Date.now() - ms) / 1000);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Builds the file name offered when downloading a log. The title is caller-supplied
|
||||||
|
* (a container name today, a stack name tomorrow), so path separators are stripped:
|
||||||
|
* a download name is not a path, and nothing good comes of letting one look like one.
|
||||||
|
*/
|
||||||
|
export function downloadFileName(title: string | undefined): string {
|
||||||
|
const safe = (title ?? '').replace(/[/\\]/g, '').replace(/\.\./g, '').trim();
|
||||||
|
return safe ? `${safe}-logs.txt` : 'logs.txt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A CSI sequence is ESC [ , then parameter bytes (0x30-0x3F, which includes '?' for
|
||||||
|
// private modes), then intermediate bytes (0x20-0x2F), then one final byte (0x40-0x7E).
|
||||||
|
// Matching the full grammar rather than just digits and semicolons matters here: compose's
|
||||||
|
// progress display hides and shows the cursor with ESC[?25l / ESC[?25h on every redraw, and
|
||||||
|
// '?' is a parameter byte. A pattern that misses those leaves them in the very output this
|
||||||
|
// viewer exists to show.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const ANSI_PATTERN = /\u001b\[[0-?]*[ -\/]*[@-~]/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips ANSI escape sequences (color codes, cursor movement, etc.) from log text.
|
||||||
|
* Copied or downloaded logs are read outside a terminal -- a `.log` file full of
|
||||||
|
* `\u001b[32m` sequences is unreadable with `cat` and unusable in a diff or grep.
|
||||||
|
*/
|
||||||
|
export function stripAnsi(text: string): string {
|
||||||
|
return text.replace(ANSI_PATTERN, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Formats the status line shown under a compose action's live output window.
|
||||||
|
*
|
||||||
|
* Pure and import-free on purpose (see run-status.test.ts / bun test tests/ —
|
||||||
|
* modules pulled in from src/lib/server or SvelteKit-generated code don't load
|
||||||
|
* under Bun's test runner). Keep it that way: no imports here.
|
||||||
|
*/
|
||||||
|
export interface RunStatus {
|
||||||
|
running: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
ms?: number;
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRunStatus(status: RunStatus): string {
|
||||||
|
if (status.running) return 'Running…';
|
||||||
|
|
||||||
|
const seconds = typeof status.ms === 'number' ? `${(status.ms / 1000).toFixed(1)}s` : undefined;
|
||||||
|
|
||||||
|
if (status.ok) {
|
||||||
|
return seconds ? `Succeeded · ${seconds}` : 'Succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitPart = typeof status.exitCode === 'number' ? `exit ${status.exitCode}` : undefined;
|
||||||
|
const parts = ['Failed', exitPart, seconds].filter((p): p is string => p !== undefined);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Whether and when the stack modal should close itself after a save/deploy settles.
|
||||||
|
*
|
||||||
|
* Superseded design note: an earlier version of this function only distinguished
|
||||||
|
* "deployed or not" and kept the modal open for every deploy, on the reasoning that
|
||||||
|
* closing would throw away the compose output now rendered below the editor. The
|
||||||
|
* operator saw that in practice (30.08.2026) and did not want it: a *successful*
|
||||||
|
* deploy leaving the modal open forever, needing a manual close every time, was as
|
||||||
|
* wrong as the previous behaviour of always closing regardless of outcome. Both
|
||||||
|
* pre-existed this file; this is the fix for both, decided by the operator, not
|
||||||
|
* inferred from the code.
|
||||||
|
*/
|
||||||
|
export type SaveCloseTiming = 'close' | 'close-delayed' | 'stay-open';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - A plain save (`deployed` false) has nothing new to show, so it closes -- same as
|
||||||
|
* before this file existed. The caller keeps its own short flash-then-close delay
|
||||||
|
* (500ms) for this case; this function only says "close", not how soon.
|
||||||
|
* - A deploy that succeeded (`deployed` true, `ok` true) closes too, but only after a
|
||||||
|
* longer delay, so the success and the compose output are still glimpsed before the
|
||||||
|
* modal goes away. Trade-off accepted knowingly, not an oversight: that output is
|
||||||
|
* then gone until the deploy-history view (Milestone 3) exists to show it again.
|
||||||
|
* - A deploy that failed (`deployed` true, `ok` false) stays open, full stop -- the
|
||||||
|
* output and the error dialog on top of it are exactly what the operator needs to
|
||||||
|
* read and act on.
|
||||||
|
*
|
||||||
|
* `ok` is meaningless when `deployed` is false (a plain save has no deploy result to
|
||||||
|
* be ok or not) -- callers may pass anything there, it is never read for that case.
|
||||||
|
*/
|
||||||
|
export function saveCloseTiming(deployed: boolean, ok: boolean): SaveCloseTiming {
|
||||||
|
if (!deployed) return 'close';
|
||||||
|
return ok ? 'close-delayed' : 'stay-open';
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Whether a scrollable element counts as "at the bottom" for auto-scroll purposes.
|
||||||
|
*
|
||||||
|
* Used both to decide whether to pause auto-scroll (the user scrolled up to read
|
||||||
|
* earlier output) and to decide when to resume it (the user scrolled back down).
|
||||||
|
* A small threshold rather than an exact match: sub-pixel scroll positions and
|
||||||
|
* fractional zoom levels mean scrollHeight - scrollTop - clientHeight is rarely
|
||||||
|
* ever exactly 0 even when the element visually is at the end.
|
||||||
|
*/
|
||||||
|
export function isScrolledToBottom(
|
||||||
|
scrollTop: number,
|
||||||
|
scrollHeight: number,
|
||||||
|
clientHeight: number,
|
||||||
|
threshold = 50
|
||||||
|
): boolean {
|
||||||
|
return scrollHeight - scrollTop - clientHeight <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scroll-up pause should be lifted because a fresh run is starting.
|
||||||
|
*
|
||||||
|
* autoScroll going from false to true is that signal (a stack redeploy sets
|
||||||
|
* `running = true`, which is what LogViewer's autoScroll prop is bound to, at
|
||||||
|
* the exact moment its output is cleared and a new run begins) -- so a viewer
|
||||||
|
* who scrolled up to read one run's output gets auto-scroll back for the next
|
||||||
|
* one, rather than it staying paused for the rest of the page session.
|
||||||
|
*
|
||||||
|
* Deliberately an edge (false -> true), not a level (autoScroll is true): a
|
||||||
|
* naive "reset whenever autoScroll is true" reads as similar but re-lifts the
|
||||||
|
* pause on every single update while a run is in progress, which defeats the
|
||||||
|
* pause entirely.
|
||||||
|
*/
|
||||||
|
export function shouldResetScrollPause(previousAutoScroll: boolean, autoScroll: boolean): boolean {
|
||||||
|
return autoScroll && !previousAutoScroll;
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ import type { JobLine } from '$lib/server/jobs';
|
|||||||
* Drop-in replacement for readSSEResponse when the endpoint has been migrated to jobs.
|
* Drop-in replacement for readSSEResponse when the endpoint has been migrated to jobs.
|
||||||
*
|
*
|
||||||
* Returns the job's final result (equivalent to the 'result' event data in SSE).
|
* Returns the job's final result (equivalent to the 'result' event data in SSE).
|
||||||
|
* With onLine, each 'progress' line message ({type:'line', line}) is reported as it
|
||||||
|
* arrives — callers that don't need live output can omit it.
|
||||||
*/
|
*/
|
||||||
export async function readJobResponse(
|
export async function readJobResponse(
|
||||||
response: Response
|
response: Response,
|
||||||
|
onLine?: (line: string) => void
|
||||||
): Promise<{ success?: boolean; error?: string; [key: string]: unknown }> {
|
): Promise<{ success?: boolean; error?: string; [key: string]: unknown }> {
|
||||||
// Fall through for non-JSON or error responses
|
// Fall through for non-JSON or error responses
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -22,8 +25,10 @@ export async function readJobResponse(
|
|||||||
|
|
||||||
// If the response is a { jobId } shape, poll the job endpoint
|
// If the response is a { jobId } shape, poll the job endpoint
|
||||||
if (data && typeof data === 'object' && 'jobId' in data) {
|
if (data && typeof data === 'object' && 'jobId' in data) {
|
||||||
const result = await watchJob(data.jobId as string, () => {
|
const result = await watchJob(data.jobId as string, (msg) => {
|
||||||
// readJobResponse callers don't need line-by-line updates
|
if (!onLine) return;
|
||||||
|
const payload = msg?.data as { type?: string; line?: string } | undefined;
|
||||||
|
if (payload?.type === 'line' && typeof payload.line === 'string') onLine(payload.line);
|
||||||
});
|
});
|
||||||
return result as { success?: boolean; error?: string; [key: string]: unknown };
|
return result as { success?: boolean; error?: string; [key: string]: unknown };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,11 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
if (data.deployNow) {
|
if (data.deployNow) {
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const deployResult = await deployGitStack(gitStack.id);
|
const deployResult = await deployGitStack(gitStack.id, {
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
userId: auth.user?.id,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
|
});
|
||||||
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
|
||||||
send('result', {
|
send('result', {
|
||||||
...gitStack,
|
...gitStack,
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ export const PUT: RequestHandler = async (event) => {
|
|||||||
if (data.deployNow) {
|
if (data.deployNow) {
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const deployResult = await deployGitStack(id);
|
const deployResult = await deployGitStack(id, {
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
userId: auth.user?.id,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
|
});
|
||||||
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
|
||||||
send('result', {
|
send('result', {
|
||||||
...updated,
|
...updated,
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const result = await deployGitStack(id);
|
const result = await deployGitStack(id, {
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
userId: auth.user?.id,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
|
});
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||||
const result = await deployGitStack(id, { force: false });
|
const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' });
|
||||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||||
method: 'POST', source, result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
method: 'POST', source, result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||||
});
|
});
|
||||||
@@ -125,7 +125,7 @@ export const GET: RequestHandler = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||||
const result = await deployGitStack(id, { force: false });
|
const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' });
|
||||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||||
method: 'GET', source: 'get', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
method: 'GET', source: 'get', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { authorize } from '$lib/server/authorize';
|
|||||||
|
|
||||||
export interface ScheduleInfo {
|
export interface ScheduleInfo {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
|
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile';
|
||||||
name: string;
|
name: string;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
* POST /api/schedules/[type]/[id]/run - Trigger a manual execution
|
* POST /api/schedules/[type]/[id]/run - Trigger a manual execution
|
||||||
*
|
*
|
||||||
* Path params:
|
* Path params:
|
||||||
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'
|
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'deploy_log_reconcile'
|
||||||
* - id: schedule ID
|
* - id: schedule ID
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
|
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune, triggerDeployLogReconcile } from '$lib/server/scheduler';
|
||||||
import { getBackupConfig, getBackupDestination } from '$lib/server/db';
|
import { getBackupConfig, getBackupDestination } from '$lib/server/db';
|
||||||
import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup';
|
import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup';
|
||||||
import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance';
|
import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance';
|
||||||
@@ -21,7 +21,7 @@ import { BACKUPS_ENABLED } from '$lib/server/features';
|
|||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* summary: Manually trigger a single run of a schedule (outside its cron), by type and id
|
* summary: Manually trigger a single run of a schedule (outside its cron), by type and id
|
||||||
* path: type:string! Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)
|
* path: type:string! Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile)
|
||||||
* path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules)
|
* path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules)
|
||||||
* resp-200: {success:boolean!, message:string!}
|
* resp-200: {success:boolean!, message:string!}
|
||||||
* resp-400: Invalid schedule id/type, or the triggered task itself reported failure
|
* resp-400: Invalid schedule id/type, or the triggered task itself reported failure
|
||||||
@@ -78,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
|||||||
scheduleEnvId = scheduleId;
|
scheduleEnvId = scheduleId;
|
||||||
break;
|
break;
|
||||||
case 'system_cleanup':
|
case 'system_cleanup':
|
||||||
|
case 'deploy_log_reconcile':
|
||||||
scheduleEnvId = null;
|
scheduleEnvId = null;
|
||||||
break;
|
break;
|
||||||
case 'backup': {
|
case 'backup': {
|
||||||
@@ -117,6 +118,9 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
|||||||
case 'system_cleanup':
|
case 'system_cleanup':
|
||||||
result = await triggerSystemJob(id);
|
result = await triggerSystemJob(id);
|
||||||
break;
|
break;
|
||||||
|
case 'deploy_log_reconcile':
|
||||||
|
result = await triggerDeployLogReconcile();
|
||||||
|
break;
|
||||||
case 'env_update_check':
|
case 'env_update_check':
|
||||||
result = await triggerEnvUpdateCheck(scheduleId);
|
result = await triggerEnvUpdateCheck(scheduleId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import { auditBackup, auditBackupDestination } from '$lib/server/audit';
|
|||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* summary: Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly)
|
* summary: Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly)
|
||||||
* path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)
|
* path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile)
|
||||||
* path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules)
|
* path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules)
|
||||||
* resp-200: {success:boolean!, enabled:boolean!}
|
* resp-200: {success:boolean!, enabled:boolean!}
|
||||||
* resp-400: Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)
|
* resp-400: Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused)
|
||||||
* resp-404: Schedule, backup config, or backup destination not found
|
* resp-404: Schedule, backup config, or backup destination not found
|
||||||
* resp-500: Unexpected error while toggling the schedule
|
* resp-500: Unexpected error while toggling the schedule
|
||||||
*/
|
*/
|
||||||
@@ -173,7 +173,11 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled });
|
await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled });
|
||||||
|
|
||||||
return json({ success: true, enabled: newEnabled });
|
return json({ success: true, enabled: newEnabled });
|
||||||
} else if (type === 'system_cleanup') {
|
} else if (type === 'system_cleanup' || type === 'deploy_log_reconcile') {
|
||||||
|
// Both are global, settings-driven system schedules (own enabled flag +
|
||||||
|
// cron, configured on the schedule settings page) -- not a per-item
|
||||||
|
// cron the way container_update/git_stack_sync are. stack_deploy never
|
||||||
|
// reaches here: it has no schedule to toggle (no cron, no settings row).
|
||||||
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
|
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
|
||||||
} else {
|
} else {
|
||||||
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
return json({ error: 'Invalid schedule type' }, { status: 400 });
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
setScannerCleanupCron,
|
setScannerCleanupCron,
|
||||||
getScannerCleanupEnabled,
|
getScannerCleanupEnabled,
|
||||||
setScannerCleanupEnabled,
|
setScannerCleanupEnabled,
|
||||||
|
getDeployLogReconcileCron,
|
||||||
|
setDeployLogReconcileCron,
|
||||||
|
getDeployLogReconcileEnabled,
|
||||||
|
setDeployLogReconcileEnabled,
|
||||||
getDefaultTimezone,
|
getDefaultTimezone,
|
||||||
setDefaultTimezone,
|
setDefaultTimezone,
|
||||||
getEventCollectionMode,
|
getEventCollectionMode,
|
||||||
@@ -69,6 +73,8 @@ export interface GeneralSettings {
|
|||||||
eventCleanupEnabled: boolean;
|
eventCleanupEnabled: boolean;
|
||||||
scannerCleanupCron: string;
|
scannerCleanupCron: string;
|
||||||
scannerCleanupEnabled: boolean;
|
scannerCleanupEnabled: boolean;
|
||||||
|
deployLogReconcileCron: string;
|
||||||
|
deployLogReconcileEnabled: boolean;
|
||||||
logBufferSizeKb: number; // legacy
|
logBufferSizeKb: number; // legacy
|
||||||
logMaxLines: number; // line-count cap for log buffer
|
logMaxLines: number; // line-count cap for log buffer
|
||||||
defaultTimezone: string;
|
defaultTimezone: string;
|
||||||
@@ -128,7 +134,7 @@ export interface GeneralSettings {
|
|||||||
defaultScannerDns: string[];
|
defaultScannerDns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
|
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled' | 'deployLogReconcileCron' | 'deployLogReconcileEnabled'> = {
|
||||||
confirmDestructive: true,
|
confirmDestructive: true,
|
||||||
showStoppedContainers: true,
|
showStoppedContainers: true,
|
||||||
highlightUpdates: true,
|
highlightUpdates: true,
|
||||||
@@ -253,6 +259,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
eventCleanupEnabled,
|
eventCleanupEnabled,
|
||||||
scannerCleanupCron,
|
scannerCleanupCron,
|
||||||
scannerCleanupEnabled,
|
scannerCleanupEnabled,
|
||||||
|
deployLogReconcileCron,
|
||||||
|
deployLogReconcileEnabled,
|
||||||
logBufferSizeKb,
|
logBufferSizeKb,
|
||||||
logMaxLines,
|
logMaxLines,
|
||||||
defaultTimezone,
|
defaultTimezone,
|
||||||
@@ -305,6 +313,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
getEventCleanupEnabled(),
|
getEventCleanupEnabled(),
|
||||||
getScannerCleanupCron(),
|
getScannerCleanupCron(),
|
||||||
getScannerCleanupEnabled(),
|
getScannerCleanupEnabled(),
|
||||||
|
getDeployLogReconcileCron(),
|
||||||
|
getDeployLogReconcileEnabled(),
|
||||||
getSetting('log_buffer_size_kb'),
|
getSetting('log_buffer_size_kb'),
|
||||||
getSetting('log_max_lines'),
|
getSetting('log_max_lines'),
|
||||||
getDefaultTimezone(),
|
getDefaultTimezone(),
|
||||||
@@ -359,6 +369,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
eventCleanupEnabled,
|
eventCleanupEnabled,
|
||||||
scannerCleanupCron,
|
scannerCleanupCron,
|
||||||
scannerCleanupEnabled,
|
scannerCleanupEnabled,
|
||||||
|
deployLogReconcileCron,
|
||||||
|
deployLogReconcileEnabled,
|
||||||
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||||
logMaxLines: (typeof logMaxLines === 'number' && logMaxLines > 0)
|
logMaxLines: (typeof logMaxLines === 'number' && logMaxLines > 0)
|
||||||
? Math.min(2000, Math.max(100, logMaxLines))
|
? Math.min(2000, Math.max(100, logMaxLines))
|
||||||
@@ -407,7 +419,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
* @openapi
|
* @openapi
|
||||||
* summary: Update global general settings (all fields optional; only supplied keys are written)
|
* summary: Update global general settings (all fields optional; only supplied keys are written)
|
||||||
* description: A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more.
|
* description: A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more.
|
||||||
* body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array<string>, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean}
|
* body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array<string>, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, deployLogReconcileCron:string, deployLogReconcileEnabled:boolean, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean}
|
||||||
* resp-403: Permission denied (needs settings:edit)
|
* resp-403: Permission denied (needs settings:edit)
|
||||||
* resp-500: Failed to save settings
|
* resp-500: Failed to save settings
|
||||||
*/
|
*/
|
||||||
@@ -419,7 +431,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, deployLogReconcileCron, deployLogReconcileEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
|
||||||
|
|
||||||
if (confirmDestructive !== undefined) {
|
if (confirmDestructive !== undefined) {
|
||||||
await setSetting('confirm_destructive', confirmDestructive);
|
await setSetting('confirm_destructive', confirmDestructive);
|
||||||
@@ -477,6 +489,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
await setScannerCleanupEnabled(scannerCleanupEnabled);
|
await setScannerCleanupEnabled(scannerCleanupEnabled);
|
||||||
await refreshSystemJobs();
|
await refreshSystemJobs();
|
||||||
}
|
}
|
||||||
|
if (deployLogReconcileCron !== undefined && typeof deployLogReconcileCron === 'string') {
|
||||||
|
await setDeployLogReconcileCron(deployLogReconcileCron);
|
||||||
|
await refreshSystemJobs();
|
||||||
|
}
|
||||||
|
if (deployLogReconcileEnabled !== undefined && typeof deployLogReconcileEnabled === 'boolean') {
|
||||||
|
await setDeployLogReconcileEnabled(deployLogReconcileEnabled);
|
||||||
|
await refreshSystemJobs();
|
||||||
|
}
|
||||||
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
|
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
|
||||||
// Legacy: clamp to 100KB-5MB range.
|
// Legacy: clamp to 100KB-5MB range.
|
||||||
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
|
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { upsertStackSource, getStackSources, secretProviderExists } from '$lib/s
|
|||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditStack } from '$lib/server/audit';
|
import { auditStack } from '$lib/server/audit';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
import { createJobResponse } from '$lib/server/sse';
|
||||||
|
import { createRunRecorder } from '$lib/server/deploy-run-record';
|
||||||
|
import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core';
|
||||||
|
import { parseEnvFileContent } from '$lib/server/git';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +89,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
* summary: Create and (optionally) deploy a compose stack
|
* summary: Create and (optionally) deploy a compose stack
|
||||||
* description: Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider. Target environment comes from the env query param, or from envId/environmentId in the body when the query is absent.
|
* description: Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider. Target environment comes from the env query param, or from envId/environmentId in the body when the query is absent.
|
||||||
* query: env:integer Target environment id (takes precedence over envId/environmentId in the body)
|
* query: env:integer Target environment id (takes precedence over envId/environmentId in the body)
|
||||||
* body: {name:string!, compose:string!, composePath:string, envPath:string, envVars:array<object>, rawEnvContent:string, secretProviderId:integer, start:boolean, envId:integer, environmentId:integer}
|
* body: {name:string!, compose:string!, composePath:string, envPath:string, envVars:array<object>, rawEnvContent:string, secretProviderId:integer, start:boolean, envId:integer, environmentId:integer, pull:boolean, build:boolean, forceRecreate:boolean}
|
||||||
* resp-400: Invalid request (e.g. missing name/compose, or secretProviderId wrong type)
|
* resp-400: Invalid request (e.g. missing name/compose, or secretProviderId wrong type)
|
||||||
* resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)
|
* resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)
|
||||||
* resp-500: Failed to create or deploy the stack
|
* resp-500: Failed to create or deploy the stack
|
||||||
@@ -120,7 +123,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, compose, start, envVars, rawEnvContent, composePath, envPath, secretProviderId } = body;
|
const { name, compose, start, envVars, rawEnvContent, composePath, envPath, secretProviderId, pull, build, forceRecreate } = body;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||||
@@ -236,6 +239,45 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
secretProviderId
|
secretProviderId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This endpoint has no requireComposeFile() call to hash the way the
|
||||||
|
// dedicated deploy endpoint does -- compose and the effective env are
|
||||||
|
// already sitting in the request body. rawEnvContent, when given, is the
|
||||||
|
// authoritative non-secret source (see the persistence logic above); the
|
||||||
|
// envVars array's secrets always layer on top of it.
|
||||||
|
const effectiveEnvVars: Record<string, string> = {};
|
||||||
|
if (rawEnvContent) {
|
||||||
|
Object.assign(effectiveEnvVars, parseEnvFileContent(rawEnvContent, name));
|
||||||
|
}
|
||||||
|
if (Array.isArray(envVars)) {
|
||||||
|
for (const v of envVars) {
|
||||||
|
if (v && typeof v.key === 'string' && typeof v.value === 'string' && (!rawEnvContent || v.isSecret)) {
|
||||||
|
effectiveEnvVars[v.key] = v.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build/pull/forceRecreate come from the caller (StackModal's "Create & Start"
|
||||||
|
// popover, see RedeployPopover) -- previously this endpoint always deployed with
|
||||||
|
// build:false and no pullPolicy regardless of what the compose file needed, so a
|
||||||
|
// service with a `build:` section silently never built on first start.
|
||||||
|
const pullOpt = !!pull;
|
||||||
|
const buildOpt = !!build;
|
||||||
|
const forceRecreateOpt = !!forceRecreate;
|
||||||
|
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: name,
|
||||||
|
envId: envIdNum ?? null,
|
||||||
|
userId: auth.user?.id,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt },
|
||||||
|
composeHash: hashComposeContent(compose),
|
||||||
|
envHash: hashEnvFingerprint(effectiveEnvVars),
|
||||||
|
// Same merged set passed to envHash above -- also the redaction list end()
|
||||||
|
// applies to the stored error text, so a leaked env value never reaches
|
||||||
|
// errorMessage on schedule_executions.
|
||||||
|
secrets: Object.values(effectiveEnvVars)
|
||||||
|
});
|
||||||
|
|
||||||
// Deploy via SSE to keep connection alive during long operations
|
// Deploy via SSE to keep connection alive during long operations
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
@@ -243,24 +285,38 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
name,
|
name,
|
||||||
compose,
|
compose,
|
||||||
envId: envIdNum,
|
envId: envIdNum,
|
||||||
|
forceRecreate: forceRecreateOpt,
|
||||||
|
build: buildOpt,
|
||||||
|
// pullPolicy undefined (pull unchecked) also skips deployStack's post-deploy
|
||||||
|
// reconcileStackPendingUpdates() call -- accepted tradeoff, not a bug.
|
||||||
|
pullPolicy: pullOpt ? 'always' : undefined,
|
||||||
composePath: composePath || undefined,
|
composePath: composePath || undefined,
|
||||||
envPath: envPath || undefined
|
envPath: envPath || undefined,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack resolves the bound secret provider's values
|
||||||
|
// internally, AFTER `recorder` above was already built from the DB-only
|
||||||
|
// `effectiveEnvVars`. Feed the provider-resolved values in now, before
|
||||||
|
// createJobResponse calls recorder.end() below -- see deploy-run-record.ts.
|
||||||
|
recorder.addSecrets(result.resolvedSecrets ?? []);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
send('result', { success: false, error: result.error, output: result.output });
|
send('result', { success: false, error: result.error, output: result.output });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audit log (create + deploy in one action)
|
// Audit log (create + deploy in one action)
|
||||||
await auditStack(event, 'deploy', name, envIdNum);
|
await auditStack(event, 'deploy', name, envIdNum, {
|
||||||
|
pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt
|
||||||
|
});
|
||||||
|
|
||||||
send('result', { success: true, started: true, output: result.output });
|
send('result', { success: true, started: true, output: result.output });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error deploying compose stack:', error);
|
console.error('Error deploying compose stack:', error);
|
||||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||||
}
|
}
|
||||||
}, request);
|
}, request, recorder);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error creating compose stack:', error);
|
console.error('Error creating compose stack:', error);
|
||||||
return json({ error: error.message || 'Failed to create stack' }, { status: 500 });
|
return json({ error: error.message || 'Failed to create stack' }, { status: 500 });
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
|
import { getStackComposeFile, deployStack, saveStackComposeFile, requireComposeFile } from '$lib/server/stacks';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
import { createJobResponse } from '$lib/server/sse';
|
||||||
|
import { createRunRecorder } from '$lib/server/deploy-run-record';
|
||||||
|
import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core';
|
||||||
|
|
||||||
// GET /api/stacks/[name]/compose - Get compose file content
|
// GET /api/stacks/[name]/compose - Get compose file content
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +60,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
* description: Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider.
|
* description: Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider.
|
||||||
* path: name:string The stack name
|
* path: name:string The stack name
|
||||||
* query: env:integer Environment id the stack belongs to
|
* query: env:integer Environment id the stack belongs to
|
||||||
* body: {content:string!, composePath:string, envPath:string, oldComposePath:string, oldEnvPath:string, moveFromDir:string, restart:boolean, secretProviderId:integer}
|
* body: {content:string!, composePath:string, envPath:string, oldComposePath:string, oldEnvPath:string, moveFromDir:string, restart:boolean, secretProviderId:integer, pull:boolean, build:boolean, forceRecreate:boolean}
|
||||||
* resp-400: Invalid request (e.g. missing content, or secretProviderId wrong type)
|
* resp-400: Invalid request (e.g. missing content, or secretProviderId wrong type)
|
||||||
* resp-403: Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)
|
* resp-403: Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)
|
||||||
* resp-500: Failed to save or deploy the compose file
|
* resp-500: Failed to save or deploy the compose file
|
||||||
@@ -79,7 +81,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } = body;
|
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId, pull, build, forceRecreate } = body;
|
||||||
|
|
||||||
if (!content || typeof content !== 'string') {
|
if (!content || typeof content !== 'string') {
|
||||||
return json({ error: 'Compose file content is required' }, { status: 400 });
|
return json({ error: 'Compose file content is required' }, { status: 400 });
|
||||||
@@ -120,10 +122,57 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (restart) {
|
if (restart) {
|
||||||
// Deploy with docker compose up -d --force-recreate.
|
// Build/pull/force-recreate come from the caller (StackModal's "Save & redeploy"
|
||||||
// Force recreate ensures env var changes are applied.
|
// popover, see RedeployPopover) -- previously this branch always deployed with
|
||||||
// Get authoritative paths from DB/filesystem for deploy (now reflects the saved content).
|
// build:false and pull skipped regardless of what the compose file needed, so a
|
||||||
const composeInfo = await getStackComposeFile(name, envIdNum);
|
// service with a `build:` section silently never rebuilt (the change was saved,
|
||||||
|
// but `docker compose up` just reused the stale local image). forceRecreate
|
||||||
|
// defaults to true when the caller omits it, preserving the prior hardcoded
|
||||||
|
// behavior for any caller that hasn't been updated to send it explicitly -- env
|
||||||
|
// var changes need --force-recreate to take effect.
|
||||||
|
const pullOpt = !!pull;
|
||||||
|
const buildOpt = !!build;
|
||||||
|
const forceRecreateOpt = forceRecreate === undefined ? true : !!forceRecreate;
|
||||||
|
|
||||||
|
// Get authoritative paths AND effective env vars from DB/filesystem for deploy
|
||||||
|
// (now reflects the saved content). requireComposeFile() wraps
|
||||||
|
// getStackComposeFile() and additionally resolves secretVars/nonSecretVars --
|
||||||
|
// the same DB-sourced values deployStack itself resolves again internally
|
||||||
|
// at deploy time (stacks.ts), and the same shape deploy/+server.ts already
|
||||||
|
// merges for ITS run record below.
|
||||||
|
const composeInfo = await requireComposeFile(name, envIdNum);
|
||||||
|
|
||||||
|
// Same run record as the dedicated deploy endpoint (deploy/+server.ts:75) --
|
||||||
|
// built up front since createJobResponse takes the recorder as an
|
||||||
|
// already-resolved value, not something it can await for itself.
|
||||||
|
//
|
||||||
|
// composeHash is hashed off the just-submitted `content`, NOT
|
||||||
|
// composeInfo.content: saveStackComposeFile() above wrote this exact string
|
||||||
|
// verbatim (no reformatting, see stacks.ts), and it's the same string handed
|
||||||
|
// to the deployStack call below -- hashing it directly ties the recorded hash to
|
||||||
|
// precisely what gets deployed, instead of a redundant re-read off disk that
|
||||||
|
// could only ever produce the same bytes anyway.
|
||||||
|
//
|
||||||
|
// Skipped entirely when composeInfo.success is false (the compose file is
|
||||||
|
// unexpectedly unreadable right here, immediately after we just wrote it) --
|
||||||
|
// the deploy below proceeds unchanged either way (composePath/envPath already
|
||||||
|
// tolerate this via `|| undefined`), but recording a run without secretVars
|
||||||
|
// would mean the recorder's end() redacts against an empty secrets list,
|
||||||
|
// risking a leaked secret value in the stored error text.
|
||||||
|
let recorder: Awaited<ReturnType<typeof createRunRecorder>> | undefined;
|
||||||
|
if (composeInfo.success) {
|
||||||
|
const effectiveEnvVars = { ...(composeInfo.nonSecretVars ?? {}), ...(composeInfo.secretVars ?? {}) };
|
||||||
|
recorder = await createRunRecorder({
|
||||||
|
stackName: name,
|
||||||
|
envId: envIdNum ?? null,
|
||||||
|
userId: auth.user?.id,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt },
|
||||||
|
composeHash: hashComposeContent(content),
|
||||||
|
envHash: hashEnvFingerprint(effectiveEnvVars),
|
||||||
|
secrets: Object.values(effectiveEnvVars)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Deploy via SSE to keep connection alive during long operations
|
// Deploy via SSE to keep connection alive during long operations
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
@@ -132,11 +181,26 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
|||||||
name,
|
name,
|
||||||
compose: content,
|
compose: content,
|
||||||
envId: envIdNum,
|
envId: envIdNum,
|
||||||
forceRecreate: true,
|
forceRecreate: forceRecreateOpt,
|
||||||
|
build: buildOpt,
|
||||||
|
// pullPolicy undefined (pull unchecked) means deployStack's post-deploy
|
||||||
|
// reconcileStackPendingUpdates() call is skipped too -- a stale "update
|
||||||
|
// available" badge on this stack won't clear until the next pull. Accepted
|
||||||
|
// tradeoff, not a bug.
|
||||||
|
pullPolicy: pullOpt ? 'always' : undefined,
|
||||||
composePath: composeInfo.composePath || undefined,
|
composePath: composeInfo.composePath || undefined,
|
||||||
envPath: composeInfo.envPath || undefined
|
envPath: composeInfo.envPath || undefined,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack resolves the bound secret provider's values
|
||||||
|
// internally, AFTER `recorder` above was already built from
|
||||||
|
// composeInfo's DB-only vars. Feed the provider-resolved values in
|
||||||
|
// now, before createJobResponse calls recorder.end() below -- see
|
||||||
|
// deploy-run-record.ts. recorder may be undefined (composeInfo
|
||||||
|
// wasn't readable), matching its optional-recorder construction above.
|
||||||
|
recorder?.addSecrets(result.resolvedSecrets ?? []);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
send('result', { success: false, error: result.error });
|
send('result', { success: false, error: result.error });
|
||||||
return;
|
return;
|
||||||
@@ -146,7 +210,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
|||||||
console.error(`Error deploying stack ${name}:`, error);
|
console.error(`Error deploying stack ${name}:`, error);
|
||||||
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
|
||||||
}
|
}
|
||||||
}, request);
|
}, request, recorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No restart: the content is already persisted above.
|
// No restart: the content is already persisted above.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { deployStack, requireComposeFile, ComposeFileNotFoundError } from '$lib/
|
|||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditStack } from '$lib/server/audit';
|
import { auditStack } from '$lib/server/audit';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
import { createJobResponse } from '$lib/server/sse';
|
||||||
|
import { createRunRecorder } from '$lib/server/deploy-run-record';
|
||||||
|
import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,23 +41,50 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
forceRecreate?: boolean;
|
forceRecreate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stackName = decodeURIComponent(params.name);
|
||||||
|
|
||||||
|
// Read up-front (not inside createJobResponse's callback, as before): the run
|
||||||
|
// record needs a compose/env hash and a schedule_executions row BEFORE the job
|
||||||
|
// stream opens, since createJobResponse takes the recorder as a plain (already
|
||||||
|
// resolved) argument, not something it can await for itself.
|
||||||
|
let composeResult: Awaited<ReturnType<typeof requireComposeFile>>;
|
||||||
|
try {
|
||||||
|
composeResult = await requireComposeFile(stackName, envIdNum);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof ComposeFileNotFoundError
|
||||||
|
? error.message
|
||||||
|
: 'Failed to deploy compose stack';
|
||||||
|
return createJobResponse(async (send) => {
|
||||||
|
send('result', { success: false, error: message });
|
||||||
|
}, event.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!composeResult.success) {
|
||||||
|
const message = composeResult.needsFileLocation
|
||||||
|
? 'Stack compose file location not configured'
|
||||||
|
: composeResult.error || 'Compose file not found';
|
||||||
|
return createJobResponse(async (send) => {
|
||||||
|
send('result', { success: false, error: message });
|
||||||
|
}, event.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same merged set used for envHash below -- also the redaction list end() applies
|
||||||
|
// to the stored error text, so a leaked env value never reaches errorMessage.
|
||||||
|
const effectiveEnvVars = { ...(composeResult.nonSecretVars ?? {}), ...(composeResult.secretVars ?? {}) };
|
||||||
|
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName,
|
||||||
|
envId: envIdNum ?? null,
|
||||||
|
userId: auth.user?.id,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: !!pull, build: !!build, forceRecreate: !!forceRecreate },
|
||||||
|
composeHash: hashComposeContent(composeResult.content!),
|
||||||
|
envHash: hashEnvFingerprint(effectiveEnvVars),
|
||||||
|
secrets: Object.values(effectiveEnvVars)
|
||||||
|
});
|
||||||
|
|
||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
|
||||||
|
|
||||||
send('progress', { status: 'Reading compose file...' });
|
|
||||||
const composeResult = await requireComposeFile(stackName, envIdNum);
|
|
||||||
|
|
||||||
if (!composeResult.success) {
|
|
||||||
send('result', {
|
|
||||||
success: false,
|
|
||||||
error: composeResult.needsFileLocation
|
|
||||||
? 'Stack compose file location not configured'
|
|
||||||
: composeResult.error || 'Compose file not found'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send('progress', { status: 'Deploying stack...' });
|
send('progress', { status: 'Deploying stack...' });
|
||||||
const result = await deployStack({
|
const result = await deployStack({
|
||||||
name: stackName,
|
name: stackName,
|
||||||
@@ -65,9 +94,18 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
build,
|
build,
|
||||||
forceRecreate,
|
forceRecreate,
|
||||||
composePath: composeResult.composePath,
|
composePath: composeResult.composePath,
|
||||||
envPath: composeResult.envPath
|
envPath: composeResult.envPath,
|
||||||
|
onLine: (line) => send('progress', { type: 'line', line })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack resolves the bound secret provider's values
|
||||||
|
// internally, AFTER `recorder` above was already built from the DB-only
|
||||||
|
// `effectiveEnvVars`. Feed the provider-resolved values in now, before
|
||||||
|
// createJobResponse calls recorder.end() below (on both the JSON and the
|
||||||
|
// streaming path) -- otherwise a provider secret leaking into result.error
|
||||||
|
// would bypass end()'s redaction entirely. See deploy-run-record.ts.
|
||||||
|
recorder.addSecrets(result.resolvedSecrets ?? []);
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'deploy', stackName, envIdNum, {
|
await auditStack(event, 'deploy', stackName, envIdNum, {
|
||||||
pull, build, forceRecreate
|
pull, build, forceRecreate
|
||||||
@@ -86,5 +124,5 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
console.error('Error deploying compose stack:', error);
|
console.error('Error deploying compose stack:', error);
|
||||||
send('result', { success: false, error: 'Failed to deploy compose stack' });
|
send('result', { success: false, error: 'Failed to deploy compose stack' });
|
||||||
}
|
}
|
||||||
}, event.request);
|
}, event.request, recorder);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getScheduleExecutions } from '$lib/server/db';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stacks/[name]/deploys?env=X
|
||||||
|
*
|
||||||
|
* List of deploy runs recorded for this stack. No log text here — the log
|
||||||
|
* lives in a file (Task 5), not in this response; fetch it separately via
|
||||||
|
* GET .../deploys/{runId}/log.
|
||||||
|
*
|
||||||
|
* This route does NOT inherit the no-auth model of /api/jobs/{id}: a job id
|
||||||
|
* is an unguessable UUID that lives ten minutes in memory, while a schedule
|
||||||
|
* execution id is a small sequential integer that persists forever. Every
|
||||||
|
* route under .../deploys checks stacks:view for itself.
|
||||||
|
*
|
||||||
|
* `env` identifies WHICH environment's runs to list, unlike the other two
|
||||||
|
* routes under .../deploys (which derive the environment from the loaded run
|
||||||
|
* itself via loadOwnedDeployRun). A stack NAME alone is not unique -- the
|
||||||
|
* same name can exist in more than one environment -- so a list without a
|
||||||
|
* definite environment would either merge runs from unrelated environments
|
||||||
|
* into one response, or get evaluated against the caller's globally-merged
|
||||||
|
* permission instead of their permission for one specific environment:
|
||||||
|
* `auth.can(..., undefined)` checks the OR of every role's grant across all
|
||||||
|
* environments, not "does this caller have stacks:view for env X". A caller
|
||||||
|
* with stacks:view scoped to exactly one environment could otherwise get
|
||||||
|
* runs -- including timestamps, status, error text and details.userId --
|
||||||
|
* from every environment that happens to have a same-named stack.
|
||||||
|
*
|
||||||
|
* Two `env` shapes both resolve to the LOCAL environment (environmentId ===
|
||||||
|
* NULL in schedule_executions): the parameter OMITTED entirely, and the
|
||||||
|
* literal string "null". This is not a relaxation of the paragraph above --
|
||||||
|
* it is the environment id createRunRecorder() actually writes for a deploy
|
||||||
|
* triggered without an explicit envId, which is the case on a single
|
||||||
|
* (local-only) environment install: appendEnvParam() in
|
||||||
|
* $lib/stores/environment.ts only appends `env` to the request when envId is
|
||||||
|
* truthy, so the UI's own "deploy this stack" call for the local environment
|
||||||
|
* NEVER sends an `env` param in the first place. Before this NULL shape was
|
||||||
|
* recognized here, EVERY recorded run on a single-environment install was
|
||||||
|
* unreachable through this endpoint: any non-null `env` value matches zero
|
||||||
|
* rows (`env=0` included -- environment ids are never 0), and omitting `env`
|
||||||
|
* was a hard 400. There is no legitimate case where "list runs, no env
|
||||||
|
* specified" should silently mean "all environments, merged" -- an explicit,
|
||||||
|
* distinguishable id (a real integer, or the NULL/local shape) is still
|
||||||
|
* required for every request; only what counts as that definite id changed.
|
||||||
|
* Anything that is neither a parseable integer nor the omitted/"null" shape
|
||||||
|
* remains a hard 400: parsing happens before the permission check, so
|
||||||
|
* `can()` and `canAccessEnvironment()` always see a definite id (a number,
|
||||||
|
* or `undefined` for the local case -- never an ambiguous "no filter").
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* summary: List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log)
|
||||||
|
* path: name:string! Stack name (from GET /api/stacks)
|
||||||
|
* query: env:integer Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string "null", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install).
|
||||||
|
* resp-200: {runs:array<{id:integer!, environmentId:integer, triggeredBy:string!, triggeredAt:string!, startedAt:string, completedAt:string, duration:integer, status:string!, errorMessage:string, details:{}}>!}
|
||||||
|
* resp-400: env query parameter must be an integer, or omitted/"null" for the local environment
|
||||||
|
* resp-401: Authentication required
|
||||||
|
* resp-403: Permission denied (needs stacks:view), or access denied to this environment
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
|
// Authentication gate first, checked BEFORE anything else runs: a caller
|
||||||
|
// without a session gets 401 here, not a 403 indistinguishable from "logged
|
||||||
|
// in but lacking the permission", and not some later error from touching the
|
||||||
|
// database without a user.
|
||||||
|
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||||
|
return json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// env is resolved to a definite id -- a real integer, or `null` for the
|
||||||
|
// local environment (see the module doc comment above) -- BEFORE the
|
||||||
|
// permission check below, so `can()` never falls back to the caller's
|
||||||
|
// globally-merged permission across all environments. NEVER left
|
||||||
|
// `undefined` past this point: passed straight through to
|
||||||
|
// getScheduleExecutions(), where `environmentId: undefined` means "do not
|
||||||
|
// filter on environment at all" (every environment's runs), while
|
||||||
|
// `environmentId: null` means "only NULL-environment runs" -- the two are
|
||||||
|
// not interchangeable, and only the second is what "local environment"
|
||||||
|
// must mean here.
|
||||||
|
const envIdParam = url.searchParams.get('env');
|
||||||
|
let envIdNum: number | null;
|
||||||
|
if (envIdParam === null || envIdParam === 'null') {
|
||||||
|
envIdNum = null;
|
||||||
|
} else {
|
||||||
|
const parsed = parseInt(envIdParam, 10);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
return json(
|
||||||
|
{ error: 'env query parameter must be an integer, or omitted/"null" for the local environment' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
envIdNum = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `?? undefined` maps the local environment (null) to the same "no
|
||||||
|
// environment context" global-permission check `can()` performs for
|
||||||
|
// git-stack routes with a nullable environmentId (`gitStack.environmentId
|
||||||
|
// || undefined` in api/git/stacks/[id]/+server.ts) -- there is no
|
||||||
|
// per-environment role to scope against for a run that was never
|
||||||
|
// attributed to one.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum ?? undefined))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The local environment has no environment record to check access
|
||||||
|
// against -- canAccessEnvironment() takes a required numeric id and would
|
||||||
|
// throw or misbehave on null/undefined, and there is nothing to be denied
|
||||||
|
// access TO for a run that isn't attributed to any environment.
|
||||||
|
if (envIdNum !== null && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
|
||||||
|
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackName = decodeURIComponent(params.name);
|
||||||
|
const { executions } = await getScheduleExecutions({
|
||||||
|
scheduleType: 'stack_deploy',
|
||||||
|
entityName: stackName,
|
||||||
|
environmentId: envIdNum,
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
runs: executions.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
environmentId: e.environmentId,
|
||||||
|
triggeredBy: e.triggeredBy,
|
||||||
|
triggeredAt: e.triggeredAt,
|
||||||
|
startedAt: e.startedAt,
|
||||||
|
completedAt: e.completedAt,
|
||||||
|
duration: e.duration,
|
||||||
|
status: e.status,
|
||||||
|
errorMessage: e.errorMessage,
|
||||||
|
details: e.details
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { deleteScheduleExecution } from '$lib/server/db';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { deleteRunLog } from '$lib/server/deploy-log-store';
|
||||||
|
import { loadOwnedDeployRun, isTerminalRunStatus } from '$lib/server/deploy-run-access';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stacks/[name]/deploys/[runId]
|
||||||
|
*
|
||||||
|
* A single deploy run with all its metadata (no log text -- see .../log).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* summary: Get a single deploy run's metadata (no log text -- see GET .../{runId}/log)
|
||||||
|
* description: 400 ("Invalid run id") and 404 ("Deploy run not found", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description).
|
||||||
|
* path: name:string! Stack name (from GET /api/stacks)
|
||||||
|
* path: runId:integer! Run id (from GET /api/stacks/{name}/deploys)
|
||||||
|
* resp-200: {id:integer!, environmentId:integer, triggeredBy:string!, triggeredAt:string!, startedAt:string, completedAt:string, duration:integer, status:string!, errorMessage:string, details:{}}
|
||||||
|
* resp-401: Authentication required
|
||||||
|
* resp-403: Permission denied (needs stacks:view for the run's own environment), or access denied to this environment
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||||
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
|
// Authentication gate first: a caller without a session gets 401 here, not
|
||||||
|
// a 403 indistinguishable from "logged in but lacking the permission".
|
||||||
|
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||||
|
return json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Coarse gate: reject a caller who lacks stacks:view EVERYWHERE, before
|
||||||
|
// even touching the database. This does not by itself scope to the run's
|
||||||
|
// environment (see the check below) -- it only screens out someone with
|
||||||
|
// no stacks:view grant on any role at all.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackName = decodeURIComponent(params.name);
|
||||||
|
const loaded = await loadOwnedDeployRun(stackName, params.runId, auth);
|
||||||
|
if ('response' in loaded) return loaded.response;
|
||||||
|
|
||||||
|
const { run } = loaded;
|
||||||
|
|
||||||
|
// Scoped to the RUN's OWN environment, checked ADDITIONALLY and AFTER the
|
||||||
|
// coarse gate above -- it needs run.environmentId, which only exists once
|
||||||
|
// loadOwnedDeployRun has resolved it. Without this, a caller with
|
||||||
|
// stacks:view for SOME environment (which already satisfied the coarse
|
||||||
|
// gate above via the global OR-across-roles merge) but not for THIS run's
|
||||||
|
// environment could read a run from an environment they have no
|
||||||
|
// stacks:view grant on at all. Same idea the sibling list route
|
||||||
|
// (deploys/+server.ts) already documents for itself, and the same pattern
|
||||||
|
// api/git/stacks/[id]/+server.ts and env-files/+server.ts use: derive the
|
||||||
|
// environment from the loaded entity, then check permission against THAT.
|
||||||
|
// `?? undefined` maps a local run (NULL environmentId) to the same
|
||||||
|
// "no environment context" global-permission check those routes use for
|
||||||
|
// their own nullable environmentId (`gitStack.environmentId || undefined`).
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'view', run.environmentId ?? undefined))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
id: run.id,
|
||||||
|
environmentId: run.environmentId,
|
||||||
|
triggeredBy: run.triggeredBy,
|
||||||
|
triggeredAt: run.triggeredAt,
|
||||||
|
startedAt: run.startedAt,
|
||||||
|
completedAt: run.completedAt,
|
||||||
|
duration: run.duration,
|
||||||
|
status: run.status,
|
||||||
|
errorMessage: run.errorMessage,
|
||||||
|
details: run.details
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/stacks/[name]/deploys/[runId]
|
||||||
|
*
|
||||||
|
* Removes both the database record AND the on-disk log file. Deleting only
|
||||||
|
* one half would either leave an orphaned log file nobody can reach (record
|
||||||
|
* gone), or a record that 404s the moment someone tries to read its log (file
|
||||||
|
* gone) -- so both happen together, file first: if the file delete throws,
|
||||||
|
* the record still exists and a retry can clean up the rest; the reverse
|
||||||
|
* order would leave a log file with no record pointing at it, findable only
|
||||||
|
* by scanning the log directory.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* summary: Delete a deploy run's database record and its on-disk log file
|
||||||
|
* description: 400 ("Invalid run id") and 404 ("Deploy run not found", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines.
|
||||||
|
* path: name:string! Stack name (from GET /api/stacks)
|
||||||
|
* path: runId:integer! Run id (from GET /api/stacks/{name}/deploys)
|
||||||
|
* resp-200: {success:boolean!}
|
||||||
|
* resp-401: Authentication required
|
||||||
|
* resp-403: Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment
|
||||||
|
* resp-409: The run has not finished yet (status is not a terminal one) -- deleting a
|
||||||
|
* still-running run would let its background deploy process keep writing to a log
|
||||||
|
* file with no record behind it, which the deploy-log reconcile job then purges as an
|
||||||
|
* orphan, erasing every trace that the deploy ever happened. Retry once it finishes.
|
||||||
|
*/
|
||||||
|
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||||
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
|
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||||
|
return json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Coarse gate -- see the identical comment on GET above.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackName = decodeURIComponent(params.name);
|
||||||
|
const loaded = await loadOwnedDeployRun(stackName, params.runId, auth);
|
||||||
|
if ('response' in loaded) return loaded.response;
|
||||||
|
|
||||||
|
const { run } = loaded;
|
||||||
|
|
||||||
|
// Scoped to the RUN's OWN environment -- see the identical comment on GET
|
||||||
|
// above for why this is checked in addition to, and after, the coarse
|
||||||
|
// gate. Checked before either delete happens: neither the log file nor
|
||||||
|
// the database record is touched if this denies.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'edit', run.environmentId ?? undefined))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding B fix: refuse to delete a run that has not finished yet. Checked AFTER
|
||||||
|
// the ownership/permission checks above (a caller who is not allowed to see this
|
||||||
|
// run at all must not learn its status via a 409 either -- 404/403 still win), and
|
||||||
|
// BEFORE either delete happens below, same as the permission check it follows. See
|
||||||
|
// isTerminalRunStatus()'s doc comment (deploy-run-access.ts) for why an unrecognized
|
||||||
|
// status is treated as NOT finished rather than assumed safe to delete.
|
||||||
|
if (!isTerminalRunStatus(run.status)) {
|
||||||
|
return json({ error: 'Cannot delete a running deploy run' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// F5 fix: scoped to the run's OWN environment directory (deploy-log-store.ts).
|
||||||
|
await deleteRunLog(run.environmentId, String(run.id));
|
||||||
|
await deleteScheduleExecution(run.id);
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { loadOwnedDeployRun } from '$lib/server/deploy-run-access';
|
||||||
|
import { readRunLog } from '$lib/server/deploy-log-store';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stacks/[name]/deploys/[runId]/log
|
||||||
|
*
|
||||||
|
* The run's protocol text, straight from the file (Task 5) as text/plain.
|
||||||
|
*
|
||||||
|
* This is the most sensitive of the four operations -- it is the one that can
|
||||||
|
* actually carry secrets that survived redaction -- so it MUST NOT come out
|
||||||
|
* looking safe just because a file path happened to be unresolvable. Every
|
||||||
|
* check below runs BEFORE the file is ever touched: authentication, then
|
||||||
|
* stacks:view, then ownership+environment access via loadOwnedDeployRun (the
|
||||||
|
* exact same helper GET/DELETE .../deploys/{runId} use, so this route can
|
||||||
|
* never drift into trusting a run it shouldn't). Only once all of that has
|
||||||
|
* passed does readRunLog() run at all.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* summary: Get a deploy run's log text (text/plain, from the on-disk file)
|
||||||
|
* description: 400 ("Invalid run id") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's "run not found / belongs to a different stack" AND this handler's own "log file missing" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line.
|
||||||
|
* path: name:string! Stack name (from GET /api/stacks)
|
||||||
|
* path: runId:integer! Run id (from GET /api/stacks/{name}/deploys)
|
||||||
|
* resp-200: Plain-text log content
|
||||||
|
* resp-401: Authentication required
|
||||||
|
* resp-403: Permission denied (needs stacks:view for the run's own environment), or access denied to this environment
|
||||||
|
* resp-404: Deploy run not found (also returned for a run that belongs to a different stack), or log file missing
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||||
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
|
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||||
|
return json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Coarse gate -- see the identical comment in
|
||||||
|
// .../deploys/[runId]/+server.ts's GET handler.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackName = decodeURIComponent(params.name);
|
||||||
|
const loaded = await loadOwnedDeployRun(stackName, params.runId, auth);
|
||||||
|
if ('response' in loaded) return loaded.response;
|
||||||
|
|
||||||
|
const { run } = loaded;
|
||||||
|
|
||||||
|
// Scoped to the RUN's OWN environment -- see the identical comment in
|
||||||
|
// .../deploys/[runId]/+server.ts's GET handler for why this runs in
|
||||||
|
// addition to, and after, the coarse gate above. This is the MOST
|
||||||
|
// sensitive of the three routes (it is the one that can carry secrets
|
||||||
|
// that survived redaction, see the module doc comment below), so this
|
||||||
|
// check runs BEFORE readRunLog() ever touches the file.
|
||||||
|
if (auth.authEnabled && !(await auth.can('stacks', 'view', run.environmentId ?? undefined))) {
|
||||||
|
return json({ error: 'Permission denied' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// F5 fix: scoped to the run's OWN environment directory (deploy-log-store.ts) --
|
||||||
|
// readRunLog() only reaches a file another environment's run wrote.
|
||||||
|
const log = await readRunLog(run.environmentId, String(run.id));
|
||||||
|
if (log === null) {
|
||||||
|
return json({ error: 'Log not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(log, { headers: { 'content-type': 'text/plain' } });
|
||||||
|
};
|
||||||
@@ -46,7 +46,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
const stackName = decodeURIComponent(params.name);
|
||||||
const result = await downStack(stackName, envIdNum, removeVolumes);
|
const result = await downStack(stackName, envIdNum, removeVolumes, (line) => send('progress', { type: 'line', line }));
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
const stackName = decodeURIComponent(params.name);
|
||||||
const result = await restartStack(stackName, envIdNum, mode);
|
const result = await restartStack(stackName, envIdNum, mode, (line) => send('progress', { type: 'line', line }));
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'restart', stackName, envIdNum);
|
await auditStack(event, 'restart', stackName, envIdNum);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
const stackName = decodeURIComponent(params.name);
|
||||||
const result = await startStack(stackName, envIdNum);
|
const result = await startStack(stackName, envIdNum, (line) => send('progress', { type: 'line', line }));
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'start', stackName, envIdNum);
|
await auditStack(event, 'start', stackName, envIdNum);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
return createJobResponse(async (send) => {
|
return createJobResponse(async (send) => {
|
||||||
try {
|
try {
|
||||||
const stackName = decodeURIComponent(params.name);
|
const stackName = decodeURIComponent(params.name);
|
||||||
const result = await stopStack(stackName, envIdNum);
|
const result = await stopStack(stackName, envIdNum, (line) => send('progress', { type: 'line', line }));
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditStack(event, 'stop', stackName, envIdNum);
|
await auditStack(event, 'stop', stackName, envIdNum);
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
|
|
||||||
import { wrapHtmlLines } from '$lib/utils/log-lines';
|
|
||||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
|
||||||
import { themeStore } from '$lib/stores/theme';
|
|
||||||
import { getMonospaceFont } from '$lib/themes';
|
|
||||||
import { AnsiUp } from 'ansi_up';
|
|
||||||
const ansiUp = new AnsiUp();
|
|
||||||
ansiUp.use_classes = true;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
logs: string;
|
|
||||||
containerName: string;
|
|
||||||
loading?: boolean;
|
|
||||||
autoRefresh?: boolean;
|
|
||||||
autoScroll?: boolean;
|
|
||||||
onRefresh?: () => void;
|
|
||||||
onClear?: () => void;
|
|
||||||
onAutoRefreshChange?: (value: boolean) => void;
|
|
||||||
onAutoScrollChange?: (value: boolean) => void;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
logs,
|
|
||||||
containerName,
|
|
||||||
loading = false,
|
|
||||||
autoRefresh = true,
|
|
||||||
autoScroll = true,
|
|
||||||
onRefresh,
|
|
||||||
onClear,
|
|
||||||
onAutoRefreshChange,
|
|
||||||
onAutoScrollChange,
|
|
||||||
class: className = ''
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let logsRef: HTMLDivElement;
|
|
||||||
let wordWrap = $state(true);
|
|
||||||
let showLineNumbers = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
|
|
||||||
let fontSize = $state(12);
|
|
||||||
|
|
||||||
// RAF-based auto-scroll
|
|
||||||
let scrollRafPending = false;
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
let logSearchActive = $state(false);
|
|
||||||
let logSearchQuery = $state('');
|
|
||||||
let logSearchFilterMode = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-filter-mode') === 'true');
|
|
||||||
let currentMatchIndex = $state(0);
|
|
||||||
let matchCount = $state(0);
|
|
||||||
let logSearchInputRef: HTMLInputElement;
|
|
||||||
|
|
||||||
const fontSizeOptions = [10, 12, 14, 16];
|
|
||||||
|
|
||||||
// Get terminal font family from theme preferences
|
|
||||||
let terminalFontFamily = $derived(() => {
|
|
||||||
const fontMeta = getMonospaceFont($themeStore.terminalFont);
|
|
||||||
return fontMeta?.family || 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-scroll when logs change
|
|
||||||
$effect(() => {
|
|
||||||
if (autoScroll && logsRef && logs) {
|
|
||||||
if (!scrollRafPending) {
|
|
||||||
scrollRafPending = true;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
|
|
||||||
scrollRafPending = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy logs to clipboard
|
|
||||||
async function copyLogs() {
|
|
||||||
if (logs) {
|
|
||||||
await copyToClipboard(logs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download logs as txt file
|
|
||||||
function downloadLogs() {
|
|
||||||
if (logs && containerName) {
|
|
||||||
const blob = new Blob([logs], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${containerName}-logs.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search functions
|
|
||||||
function toggleLogSearch() {
|
|
||||||
logSearchActive = !logSearchActive;
|
|
||||||
if (logSearchActive) {
|
|
||||||
setTimeout(() => logSearchInputRef?.focus(), 50);
|
|
||||||
} else {
|
|
||||||
logSearchQuery = '';
|
|
||||||
currentMatchIndex = 0;
|
|
||||||
matchCount = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLogSearch() {
|
|
||||||
logSearchActive = false;
|
|
||||||
logSearchQuery = '';
|
|
||||||
logSearchFilterMode = false;
|
|
||||||
currentMatchIndex = 0;
|
|
||||||
matchCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSearchFilterMode() {
|
|
||||||
logSearchFilterMode = !logSearchFilterMode;
|
|
||||||
localStorage.setItem('dockhand-log-filter-mode', String(logSearchFilterMode));
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateMatch(direction: 'prev' | 'next') {
|
|
||||||
if (!logsRef || matchCount === 0) return;
|
|
||||||
|
|
||||||
const matches = logsRef.querySelectorAll('.search-match');
|
|
||||||
if (matches.length === 0) return;
|
|
||||||
|
|
||||||
matches[currentMatchIndex]?.classList.remove('current-match');
|
|
||||||
|
|
||||||
if (direction === 'next') {
|
|
||||||
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
|
|
||||||
} else {
|
|
||||||
currentMatchIndex = (currentMatchIndex - 1 + matches.length) % matches.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEl = matches[currentMatchIndex];
|
|
||||||
if (currentEl) {
|
|
||||||
currentEl.classList.add('current-match');
|
|
||||||
currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogSearchKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
navigateMatch('prev');
|
|
||||||
} else {
|
|
||||||
navigateMatch('next');
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
closeLogSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlighted logs with search matches and ANSI color support
|
|
||||||
let highlightedLogs = $derived(() => {
|
|
||||||
let text = logs || '';
|
|
||||||
if ($appSettings.formatLogTimestamps) {
|
|
||||||
text = formatLogTimestamps(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = logSearchQuery.trim();
|
|
||||||
|
|
||||||
// Filter lines before ANSI conversion (plain text matching)
|
|
||||||
if (logSearchFilterMode && query) {
|
|
||||||
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const filterRegex = new RegExp(escapedForRegex, 'i');
|
|
||||||
const lines = text.split('\n');
|
|
||||||
text = lines.filter(line => filterRegex.test(line)).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const withAnsi = ansiUp.ansi_to_html(text);
|
|
||||||
if (!query) return withAnsi;
|
|
||||||
|
|
||||||
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const escapedQuery = escapedForRegex.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
// Split by HTML tags and only process text parts
|
|
||||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
|
||||||
return parts.map(part => {
|
|
||||||
if (part.startsWith('<')) return part;
|
|
||||||
return part.replace(new RegExp(`(${escapedQuery})`, 'gi'), '<mark class="search-match">$1</mark>');
|
|
||||||
}).join('');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update match count after render
|
|
||||||
$effect(() => {
|
|
||||||
const html = highlightedLogs();
|
|
||||||
|
|
||||||
if (logSearchQuery && logsRef) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const matches = logsRef.querySelectorAll('.search-match');
|
|
||||||
matchCount = matches.length;
|
|
||||||
currentMatchIndex = 0;
|
|
||||||
if (matches.length > 0) {
|
|
||||||
matches[0].classList.add('current-match');
|
|
||||||
matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} else {
|
|
||||||
matchCount = 0;
|
|
||||||
currentMatchIndex = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden {className}">
|
|
||||||
<!-- Header bar -->
|
|
||||||
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if loading}
|
|
||||||
<RefreshCw class="w-3 h-3 text-zinc-500 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Auto-refresh button -->
|
|
||||||
<button
|
|
||||||
onclick={() => onAutoRefreshChange?.(!autoRefresh)}
|
|
||||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoRefresh ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
|
|
||||||
title="Toggle auto-refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<!-- Auto-scroll button -->
|
|
||||||
<button
|
|
||||||
onclick={() => onAutoScrollChange?.(!autoScroll)}
|
|
||||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoScroll ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
|
|
||||||
title="Toggle auto-scroll"
|
|
||||||
>
|
|
||||||
<ArrowDownToLine class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
<!-- Font size -->
|
|
||||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => fontSize = Number(v)}>
|
|
||||||
<Select.Trigger class="h-6 w-16 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5">
|
|
||||||
<Type class="w-3 h-3 mr-1 text-zinc-400" />
|
|
||||||
<span>{fontSize}px</span>
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each fontSizeOptions as size}
|
|
||||||
<Select.Item value={String(size)} label="{size}px">
|
|
||||||
<Type class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
|
||||||
{size}px
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<!-- Word wrap -->
|
|
||||||
<button
|
|
||||||
onclick={() => wordWrap = !wordWrap}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors {wordWrap ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
|
|
||||||
title="Toggle word wrap"
|
|
||||||
>
|
|
||||||
<WrapText class="w-3 h-3 transition-colors {wordWrap ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
|
|
||||||
</button>
|
|
||||||
<!-- Line numbers -->
|
|
||||||
<button
|
|
||||||
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors {showLineNumbers ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
|
|
||||||
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
|
||||||
>
|
|
||||||
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
|
|
||||||
</button>
|
|
||||||
<!-- Search -->
|
|
||||||
{#if logSearchActive}
|
|
||||||
<div class="flex items-center gap-1 bg-zinc-800 rounded px-1.5 py-0.5">
|
|
||||||
<Search class="w-3 h-3 text-amber-400" />
|
|
||||||
<input
|
|
||||||
bind:this={logSearchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
bind:value={logSearchQuery}
|
|
||||||
onkeydown={handleLogSearchKeydown}
|
|
||||||
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onclick={toggleSearchFilterMode}
|
|
||||||
class="p-0.5 rounded transition-colors {logSearchFilterMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'hover:bg-zinc-700'}"
|
|
||||||
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
|
|
||||||
>
|
|
||||||
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? 'text-amber-400' : 'text-zinc-400'}" />
|
|
||||||
</button>
|
|
||||||
{#if matchCount > 0}
|
|
||||||
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
|
|
||||||
{:else if logSearchQuery}
|
|
||||||
<span class="text-xs text-zinc-500">0/0</span>
|
|
||||||
{/if}
|
|
||||||
<button onclick={() => navigateMatch('prev')} class="p-0.5 rounded hover:bg-zinc-700" title="Previous">
|
|
||||||
<ChevronUp class="w-3 h-3 text-zinc-400" />
|
|
||||||
</button>
|
|
||||||
<button onclick={() => navigateMatch('next')} class="p-0.5 rounded hover:bg-zinc-700" title="Next">
|
|
||||||
<ChevronDown class="w-3 h-3 text-zinc-400" />
|
|
||||||
</button>
|
|
||||||
<button onclick={closeLogSearch} class="p-0.5 rounded hover:bg-zinc-700" title="Close">
|
|
||||||
<X class="w-3 h-3 text-zinc-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={toggleLogSearch}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
title="Search logs"
|
|
||||||
>
|
|
||||||
<Search class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<!-- Copy -->
|
|
||||||
<button
|
|
||||||
onclick={copyLogs}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
title="Copy logs"
|
|
||||||
>
|
|
||||||
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
<!-- Download -->
|
|
||||||
<button
|
|
||||||
onclick={downloadLogs}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
title="Download logs"
|
|
||||||
>
|
|
||||||
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
<!-- Clear -->
|
|
||||||
<button
|
|
||||||
onclick={() => onClear?.()}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
title="Clear logs"
|
|
||||||
>
|
|
||||||
<Eraser class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
<!-- Refresh -->
|
|
||||||
<button
|
|
||||||
onclick={() => onRefresh?.()}
|
|
||||||
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
||||||
title="Refresh logs"
|
|
||||||
>
|
|
||||||
<RefreshCw class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logs content -->
|
|
||||||
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
|
|
||||||
{#if logs}
|
|
||||||
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
|
|
||||||
{:else if loading}
|
|
||||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
|
|
||||||
Loading logs...
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-zinc-500 text-sm">No logs available</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.search-match) {
|
|
||||||
background-color: rgba(234, 179, 8, 0.4);
|
|
||||||
color: #fef3c7;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 1px 2px;
|
|
||||||
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
|
|
||||||
}
|
|
||||||
:global(.search-match.current-match) {
|
|
||||||
background-color: rgba(234, 179, 8, 0.8);
|
|
||||||
color: #1a1a1a;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
|
|
||||||
outline: 2px solid rgb(250, 204, 21);
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
|
|
||||||
interface ScheduleExecution {
|
interface ScheduleExecution {
|
||||||
id: number;
|
id: number;
|
||||||
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
|
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile';
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
environmentId: number | null;
|
environmentId: number | null;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
interface Schedule {
|
interface Schedule {
|
||||||
key: string; // Unique key: type-id
|
key: string; // Unique key: type-id
|
||||||
id: number;
|
id: number;
|
||||||
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify';
|
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile';
|
||||||
name: string;
|
name: string;
|
||||||
entityName: string;
|
entityName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -508,9 +508,10 @@
|
|||||||
|
|
||||||
async function toggleHideSystemJobs() {
|
async function toggleHideSystemJobs() {
|
||||||
hideSystemJobs = !hideSystemJobs;
|
hideSystemJobs = !hideSystemJobs;
|
||||||
// Remove system_cleanup from filter if hiding system jobs
|
// Remove system_cleanup and deploy_log_reconcile from filter if hiding system jobs
|
||||||
if (hideSystemJobs && filterTypes.includes('system_cleanup')) {
|
// (both are system schedules -- isSystem: true -- so they're gone from the list too)
|
||||||
filterTypes = filterTypes.filter(t => t !== 'system_cleanup');
|
if (hideSystemJobs && (filterTypes.includes('system_cleanup') || filterTypes.includes('deploy_log_reconcile'))) {
|
||||||
|
filterTypes = filterTypes.filter(t => t !== 'system_cleanup' && t !== 'deploy_log_reconcile');
|
||||||
}
|
}
|
||||||
// Save preference in background
|
// Save preference in background
|
||||||
try {
|
try {
|
||||||
@@ -975,6 +976,10 @@
|
|||||||
Repo check
|
Repo check
|
||||||
{:else if filterTypes[0] === 'repo_verify'}
|
{:else if filterTypes[0] === 'repo_verify'}
|
||||||
Data verify
|
Data verify
|
||||||
|
{:else if filterTypes[0] === 'stack_deploy'}
|
||||||
|
Stack deploy
|
||||||
|
{:else if filterTypes[0] === 'deploy_log_reconcile'}
|
||||||
|
Deploy log reconcile
|
||||||
{:else}
|
{:else}
|
||||||
System jobs
|
System jobs
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1009,6 +1014,10 @@
|
|||||||
<Trash2 class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
|
<Trash2 class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
|
||||||
Image prune
|
Image prune
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
|
<Select.Item value="stack_deploy">
|
||||||
|
<Server class="w-4 h-4 mr-2 inline text-indigo-500 drop-shadow-[0_0_3px_rgba(99,102,241,0.4)]" />
|
||||||
|
Stack deploy
|
||||||
|
</Select.Item>
|
||||||
<!-- BETA GATE: backup schedule filters hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
|
<!-- BETA GATE: backup schedule filters hidden unless FEAT_BACKUPS_ENABLED (see features.ts) -->
|
||||||
{#if $page.data.backupsEnabled}
|
{#if $page.data.backupsEnabled}
|
||||||
<Select.Item value="backup">
|
<Select.Item value="backup">
|
||||||
@@ -1033,6 +1042,10 @@
|
|||||||
<Wrench class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
|
<Wrench class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
|
||||||
System jobs
|
System jobs
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
|
<Select.Item value="deploy_log_reconcile">
|
||||||
|
<FileText class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
|
||||||
|
Deploy log reconcile
|
||||||
|
</Select.Item>
|
||||||
{/if}
|
{/if}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
@@ -1604,7 +1617,7 @@
|
|||||||
Execution details
|
Execution details
|
||||||
{#if selectedExecution}
|
{#if selectedExecution}
|
||||||
<span class="text-muted-foreground font-normal">
|
<span class="text-muted-foreground font-normal">
|
||||||
({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'backup'}Backup{:else if selectedExecution.scheduleType === 'repo_prune'}Repo prune{:else if selectedExecution.scheduleType === 'repo_check'}Repo check{:else if selectedExecution.scheduleType === 'repo_verify'}Data verify{:else}System job{/if})
|
({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'backup'}Backup{:else if selectedExecution.scheduleType === 'repo_prune'}Repo prune{:else if selectedExecution.scheduleType === 'repo_check'}Repo check{:else if selectedExecution.scheduleType === 'repo_verify'}Data verify{:else if selectedExecution.scheduleType === 'stack_deploy'}Stack deploy{:else if selectedExecution.scheduleType === 'deploy_log_reconcile'}Deploy log reconcile{:else}System job{/if})
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ services:
|
|||||||
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
|
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
|
||||||
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
|
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
|
||||||
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
|
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
|
||||||
|
let deployLogReconcileCron = $derived($appSettings.deployLogReconcileCron);
|
||||||
|
let deployLogReconcileEnabled = $derived($appSettings.deployLogReconcileEnabled);
|
||||||
let logMaxLines = $derived($appSettings.logMaxLines);
|
let logMaxLines = $derived($appSettings.logMaxLines);
|
||||||
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
|
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
|
||||||
let defaultTimezone = $derived($appSettings.defaultTimezone);
|
let defaultTimezone = $derived($appSettings.defaultTimezone);
|
||||||
@@ -187,6 +189,17 @@ services:
|
|||||||
toast.success(newState ? 'Scanner cleanup enabled' : 'Scanner cleanup disabled');
|
toast.success(newState ? 'Scanner cleanup enabled' : 'Scanner cleanup disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeployLogReconcileCronChange(cron: string) {
|
||||||
|
appSettings.setDeployLogReconcileCron(cron);
|
||||||
|
toast.success('Deploy log reconcile cron updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeployLogReconcileEnabledChange() {
|
||||||
|
const newState = !deployLogReconcileEnabled;
|
||||||
|
appSettings.setDeployLogReconcileEnabled(newState);
|
||||||
|
toast.success(newState ? 'Deploy log reconcile enabled' : 'Deploy log reconcile disabled');
|
||||||
|
}
|
||||||
|
|
||||||
function handleGrypeImageBlur(e: Event) {
|
function handleGrypeImageBlur(e: Event) {
|
||||||
const value = (e.target as HTMLInputElement).value.trim();
|
const value = (e.target as HTMLInputElement).value.trim();
|
||||||
if (value && value !== defaultGrypeImage) {
|
if (value && value !== defaultGrypeImage) {
|
||||||
@@ -1121,6 +1134,26 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-1 pt-2 border-t">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Label>Deploy log reconcile</Label>
|
||||||
|
<TogglePill
|
||||||
|
checked={deployLogReconcileEnabled}
|
||||||
|
onchange={handleDeployLogReconcileEnabledChange}
|
||||||
|
disabled={!$canAccess('settings', 'edit')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">Delete deploy-log files that no longer have a matching deploy record; deploy records that lost their log file are only flagged, never deleted</p>
|
||||||
|
{#if deployLogReconcileEnabled}
|
||||||
|
<div class="mt-2">
|
||||||
|
<CronEditor
|
||||||
|
value={deployLogReconcileCron}
|
||||||
|
onchange={handleDeployLogReconcileCronChange}
|
||||||
|
disabled={!$canAccess('settings', 'edit')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="space-y-1 pt-2 border-t">
|
<div class="space-y-1 pt-2 border-t">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Label>Protect scanner images from prune</Label>
|
<Label>Protect scanner images from prune</Label>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
import type { ComposeStackInfo, ContainerStats, StackContainer } from '$lib/types';
|
import type { ComposeStackInfo, ContainerStats, StackContainer } from '$lib/types';
|
||||||
import StackModal from './StackModal.svelte';
|
import StackModal from './StackModal.svelte';
|
||||||
import DeleteStackModal from './DeleteStackModal.svelte';
|
import DeleteStackModal from './DeleteStackModal.svelte';
|
||||||
|
import ComposeOutputModal from './ComposeOutputModal.svelte';
|
||||||
import GitSourceBadge from './GitSourceBadge.svelte';
|
import GitSourceBadge from './GitSourceBadge.svelte';
|
||||||
import GitStackModal from './GitStackModal.svelte';
|
import GitStackModal from './GitStackModal.svelte';
|
||||||
import ImportStackModal from './ImportStackModal.svelte';
|
import ImportStackModal from './ImportStackModal.svelte';
|
||||||
@@ -520,6 +521,52 @@
|
|||||||
let restartPopoverOpen = $state<Record<string, boolean>>({});
|
let restartPopoverOpen = $state<Record<string, boolean>>({});
|
||||||
let stackDownLoading = $state<string | null>(null);
|
let stackDownLoading = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Live compose output window: shown while a stack action (start/stop/restart/
|
||||||
|
// redeploy/down) runs, fed line-by-line via readJobResponse's onLine callback.
|
||||||
|
let composeOutputOpen = $state(false);
|
||||||
|
let composeOutputTitle = $state('');
|
||||||
|
let composeOutputLines = $state<string[]>([]);
|
||||||
|
let composeOutputRunning = $state(false);
|
||||||
|
// Status-line inputs, measured here in the browser (wall-clock incl. the round
|
||||||
|
// trip through fetch/polling) — NOT the server-side execution time recorded
|
||||||
|
// separately for the deploy-history dataset (Task 10). Different path, different
|
||||||
|
// number; don't conflate the two.
|
||||||
|
let composeOutputStartedAt = 0;
|
||||||
|
let composeOutputOk = $state<boolean | undefined>(undefined);
|
||||||
|
let composeOutputMs = $state<number | undefined>(undefined);
|
||||||
|
let composeOutputExitCode = $state<number | undefined>(undefined);
|
||||||
|
|
||||||
|
function startComposeOutput(title: string) {
|
||||||
|
composeOutputTitle = title;
|
||||||
|
composeOutputLines = [];
|
||||||
|
composeOutputRunning = true;
|
||||||
|
composeOutputOpen = true;
|
||||||
|
composeOutputStartedAt = Date.now();
|
||||||
|
composeOutputOk = undefined;
|
||||||
|
composeOutputMs = undefined;
|
||||||
|
composeOutputExitCode = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendComposeOutputLine(line: string) {
|
||||||
|
composeOutputLines = [...composeOutputLines, line];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called once the job settles. Older agent versions (pre line-streaming, see Task 5)
|
||||||
|
// only return a batched `output` string — show it if nothing arrived incrementally.
|
||||||
|
// `ok`/`exitCode` come from the job result (or `false`/undefined on a thrown
|
||||||
|
// network error, from the catch blocks below) — the payload does not carry an
|
||||||
|
// exit code today (StackOperationResult has no such field, see stacks.ts), so
|
||||||
|
// `exitCode` stays undefined in practice until the server starts sending one.
|
||||||
|
function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number) {
|
||||||
|
composeOutputRunning = false;
|
||||||
|
composeOutputOk = ok;
|
||||||
|
composeOutputMs = Date.now() - composeOutputStartedAt;
|
||||||
|
composeOutputExitCode = exitCode;
|
||||||
|
if (composeOutputLines.length === 0 && output) {
|
||||||
|
composeOutputLines = output.split('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Container-level confirmation popover state
|
// Container-level confirmation popover state
|
||||||
let confirmStopContainerId = $state<string | null>(null);
|
let confirmStopContainerId = $state<string | null>(null);
|
||||||
let confirmRestartContainerId = $state<string | null>(null);
|
let confirmRestartContainerId = $state<string | null>(null);
|
||||||
@@ -965,9 +1012,15 @@
|
|||||||
async function startStack(name: string) {
|
async function startStack(name: string) {
|
||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
|
startComposeOutput(`Starting ${name}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
|
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
|
||||||
const data = await readJobResponse(response);
|
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||||
|
finishComposeOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack');
|
showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack');
|
||||||
return;
|
return;
|
||||||
@@ -975,6 +1028,7 @@
|
|||||||
toast.success(`Started ${name}`);
|
toast.success(`Started ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finishComposeOutput(undefined, false);
|
||||||
console.error('Failed to start stack:', error);
|
console.error('Failed to start stack:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
|
||||||
showErrorDialog(`Failed to start ${name}`, errorMsg);
|
showErrorDialog(`Failed to start ${name}`, errorMsg);
|
||||||
@@ -986,9 +1040,15 @@
|
|||||||
async function stopStack(name: string) {
|
async function stopStack(name: string) {
|
||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
|
startComposeOutput(`Stopping ${name}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
|
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
|
||||||
const data = await readJobResponse(response);
|
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||||
|
finishComposeOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack');
|
showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack');
|
||||||
return;
|
return;
|
||||||
@@ -996,6 +1056,7 @@
|
|||||||
toast.success(`Stopped ${name}`);
|
toast.success(`Stopped ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finishComposeOutput(undefined, false);
|
||||||
console.error('Failed to stop stack:', error);
|
console.error('Failed to stop stack:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
|
||||||
showErrorDialog(`Failed to stop ${name}`, errorMsg);
|
showErrorDialog(`Failed to stop ${name}`, errorMsg);
|
||||||
@@ -1007,13 +1068,19 @@
|
|||||||
async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') {
|
async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') {
|
||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
|
startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`);
|
||||||
try {
|
try {
|
||||||
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
|
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
|
||||||
if (mode === 'recreate' || mode === 'ordered') {
|
if (mode === 'recreate' || mode === 'ordered') {
|
||||||
url += (url.includes('?') ? '&' : '?') + `mode=${mode}`;
|
url += (url.includes('?') ? '&' : '?') + `mode=${mode}`;
|
||||||
}
|
}
|
||||||
const response = await fetch(url, { method: 'POST' });
|
const response = await fetch(url, { method: 'POST' });
|
||||||
const data = await readJobResponse(response);
|
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||||
|
finishComposeOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
||||||
return;
|
return;
|
||||||
@@ -1021,6 +1088,7 @@
|
|||||||
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
|
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finishComposeOutput(undefined, false);
|
||||||
console.error('Failed to restart stack:', error);
|
console.error('Failed to restart stack:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
|
||||||
showErrorDialog(`Failed to restart ${name}`, errorMsg);
|
showErrorDialog(`Failed to restart ${name}`, errorMsg);
|
||||||
@@ -1032,13 +1100,19 @@
|
|||||||
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
|
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
|
||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
|
startComposeOutput(`Redeploying ${name}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), {
|
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(options)
|
body: JSON.stringify(options)
|
||||||
});
|
});
|
||||||
const data = await readJobResponse(response);
|
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||||
|
finishComposeOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack');
|
showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack');
|
||||||
return;
|
return;
|
||||||
@@ -1046,6 +1120,7 @@
|
|||||||
toast.success(`Redeployed ${name}`);
|
toast.success(`Redeployed ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finishComposeOutput(undefined, false);
|
||||||
console.error('Failed to redeploy stack:', error);
|
console.error('Failed to redeploy stack:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack';
|
||||||
showErrorDialog(`Failed to redeploy ${name}`, errorMsg);
|
showErrorDialog(`Failed to redeploy ${name}`, errorMsg);
|
||||||
@@ -1058,9 +1133,15 @@
|
|||||||
operationError = null;
|
operationError = null;
|
||||||
stackActionLoading = name;
|
stackActionLoading = name;
|
||||||
stackDownLoading = name;
|
stackDownLoading = name;
|
||||||
|
startComposeOutput(`Bringing down ${name}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
|
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
|
||||||
const data = await readJobResponse(response);
|
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||||
|
finishComposeOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack');
|
showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack');
|
||||||
return;
|
return;
|
||||||
@@ -1068,6 +1149,7 @@
|
|||||||
toast.success(`Brought down ${name}`);
|
toast.success(`Brought down ${name}`);
|
||||||
await fetchStacks();
|
await fetchStacks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
finishComposeOutput(undefined, false);
|
||||||
console.error('Failed to bring down stack:', error);
|
console.error('Failed to bring down stack:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
|
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
|
||||||
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
|
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
|
||||||
@@ -2764,6 +2846,16 @@
|
|||||||
onConfirm={(opts) => removeStack(deleteStackName, opts)}
|
onConfirm={(opts) => removeStack(deleteStackName, opts)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ComposeOutputModal
|
||||||
|
bind:open={composeOutputOpen}
|
||||||
|
title={composeOutputTitle}
|
||||||
|
lines={composeOutputLines}
|
||||||
|
running={composeOutputRunning}
|
||||||
|
ok={composeOutputOk}
|
||||||
|
ms={composeOutputMs}
|
||||||
|
exitCode={composeOutputExitCode}
|
||||||
|
/>
|
||||||
|
|
||||||
<ContainerInspectModal
|
<ContainerInspectModal
|
||||||
bind:open={showInspectModal}
|
bind:open={showInspectModal}
|
||||||
containerId={inspectContainerId}
|
containerId={inspectContainerId}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||||
|
import { formatRunStatus } from '$lib/utils/run-status';
|
||||||
|
|
||||||
|
// A plain display window for compose output. It owns none of the polling —
|
||||||
|
// the parent pushes lines in as they arrive via readJobResponse's onLine callback
|
||||||
|
// and flips `running` to false once the job settles, at which point it also
|
||||||
|
// supplies `ok`/`ms`/`exitCode` for the status line below.
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
lines: string[];
|
||||||
|
running: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
ms?: number;
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false), title, lines, running, ok, ms, exitCode }: Props = $props();
|
||||||
|
|
||||||
|
let statusLine = $derived(formatRunStatus({ running, ok, ms, exitCode }));
|
||||||
|
|
||||||
|
// This modal is opened straight from the stack list, with no editor in scope to
|
||||||
|
// borrow a theme from (unlike the docked panel and Deploys tab inside StackModal,
|
||||||
|
// which both follow `dockhand-editor-theme`). Its Dialog.Content already tracks
|
||||||
|
// the app-wide light/dark switch via bg-background, so the log viewer inside
|
||||||
|
// follows the same switch instead of introducing a third, unrelated toggle.
|
||||||
|
// Same read-once-on-mount + system-preference-fallback pattern as
|
||||||
|
// ComposeGraphViewer's graphTheme -- this component is mounted once at the page
|
||||||
|
// root and toggled via `open`, so onMount runs once per page load.
|
||||||
|
let outputTheme = $state<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const appTheme = localStorage.getItem('theme');
|
||||||
|
if (appTheme === 'dark' || appTheme === 'light') {
|
||||||
|
outputTheme = appTheme;
|
||||||
|
} else {
|
||||||
|
outputTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
// Closing only hides this window. The operation it is watching keeps running
|
||||||
|
// against the server regardless (watchJob's poll loop in sse-fetch.ts has no
|
||||||
|
// abort mechanism and isn't meant to gain one here) — never stop it on close.
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<!-- Build output is line-oriented and often wide (BuildKit step lines, image digests).
|
||||||
|
A narrow window forces wrapping or horizontal scrolling on every other line, so this
|
||||||
|
one is sized generously -- but clamped to the viewport so it never overflows on a
|
||||||
|
laptop screen: min() picks the smaller of the two, and the 4rem keeps a margin. -->
|
||||||
|
<Dialog.Content class="max-w-[min(90rem,calc(100vw-4rem))] h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<Dialog.Header class="shrink-0">
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
{#if running}
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{statusLine}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<LogViewer
|
||||||
|
logs={lines.join('\n')}
|
||||||
|
{title}
|
||||||
|
autoRefresh={false}
|
||||||
|
autoScroll={running}
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
theme={outputTheme}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog.Footer class="shrink-0">
|
||||||
|
<Button variant="outline" onclick={handleClose}>Close</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Deploys tab: the recorded history of deploy runs for this stack (GET
|
||||||
|
* /api/stacks/{name}/deploys). Each run is a collapsed row by default --
|
||||||
|
* status, relative time, duration, and a one-line summary -- expandable for the
|
||||||
|
* full picture (containers, build/cache, images, options, a truncated notice,
|
||||||
|
* and on failure the shortened last error line). All of that text composition
|
||||||
|
* lives in deploy-run-view.ts, not here -- this component only fetches and binds.
|
||||||
|
*
|
||||||
|
* Mirrors BackupPanel/ExecutionHistoryList's shape (loading / empty / list),
|
||||||
|
* the sibling tab in this same modal -- see StackModal.svelte's Backups tab
|
||||||
|
* for the tab-gating convention this one follows.
|
||||||
|
*
|
||||||
|
* Expanding a row additionally loads that run's full protocol text (GET
|
||||||
|
* .../deploys/{id}/log) into the same LogViewer the live output window and
|
||||||
|
* the docked panel use -- but only on expand, never while the list itself
|
||||||
|
* loads: a run's log can be large, and the list is meant to stay quick.
|
||||||
|
* buildDeployLogPanelState (deploy-run-view.ts) decides what that fetch
|
||||||
|
* should even attempt: a run already known to be missing its log file
|
||||||
|
* (reconciled by deploy-log-reconcile) never triggers the request at all,
|
||||||
|
* since it would just 404.
|
||||||
|
*
|
||||||
|
* `envId === null` is NOT "nothing to ask for" -- it is the LOCAL
|
||||||
|
* environment (see the list route's own doc comment, `.../deploys/+server.ts`):
|
||||||
|
* appendEnvParam() only appends `env` when envId is truthy, so a
|
||||||
|
* single-environment install's own "deploy" calls never send one either,
|
||||||
|
* and every one of ITS runs is recorded with environmentId === null. The
|
||||||
|
* route now resolves an omitted `env` to exactly that filter. onMount
|
||||||
|
* below fetches unconditionally for that reason -- there is no longer a
|
||||||
|
* distinct "no environment selected" state to guard against here; the
|
||||||
|
* type this component receives (number | null) has no third value that
|
||||||
|
* could mean anything else.
|
||||||
|
*/
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
ChevronDown,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
Hand,
|
||||||
|
Timer,
|
||||||
|
Webhook,
|
||||||
|
Power,
|
||||||
|
AlertTriangle,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { formatRelativeTime, formatDateTime } from '$lib/stores/settings';
|
||||||
|
import { appendEnvParam } from '$lib/stores/environment';
|
||||||
|
import { buildDeployRunView, buildDeployLogPanelState, type DeployRun } from '$lib/utils/deploy-run-view';
|
||||||
|
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||||
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stackName: string;
|
||||||
|
envId: number | null;
|
||||||
|
// Forwarded to LogViewer so an expanded run's log follows the caller's editor
|
||||||
|
// theme toggle instead of LogViewer's own 'dark' default.
|
||||||
|
theme?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
let { stackName, envId, theme = 'dark' }: Props = $props();
|
||||||
|
|
||||||
|
let runs = $state<DeployRun[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let expanded = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
|
/** One entry per run whose log has been requested. 'loading'/'loaded'/'missing'/'error'
|
||||||
|
* -- deliberately not reused for "not yet requested" (absent from the map), so a
|
||||||
|
* fresh row doesn't render as if a fetch had already failed. */
|
||||||
|
interface LogFetch {
|
||||||
|
status: 'loading' | 'loaded' | 'missing' | 'error';
|
||||||
|
text?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
let logs = $state<Map<number, LogFetch>>(new Map());
|
||||||
|
let confirmDeleteId = $state<number | null>(null);
|
||||||
|
let deletingIds = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
|
function toggle(run: DeployRun) {
|
||||||
|
const s = new Set(expanded);
|
||||||
|
if (s.has(run.id)) {
|
||||||
|
s.delete(run.id);
|
||||||
|
} else {
|
||||||
|
s.add(run.id);
|
||||||
|
void loadLog(run);
|
||||||
|
}
|
||||||
|
expanded = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a run's protocol text the first time its row opens. A prior 'error'
|
||||||
|
* result is retried on the next open (transient network failures shouldn't
|
||||||
|
* require a page reload to recover from); 'loading'/'loaded'/'missing' are all
|
||||||
|
* left alone -- there is nothing a second fetch would tell us that the first
|
||||||
|
* one (or the run's own details.logMissing) didn't already establish.
|
||||||
|
*/
|
||||||
|
async function loadLog(run: DeployRun) {
|
||||||
|
const existing = logs.get(run.id);
|
||||||
|
if (existing && existing.status !== 'error') return;
|
||||||
|
|
||||||
|
if (buildDeployLogPanelState(run).logMissing) {
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'missing' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'loading' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/deploys/${run.id}/log`);
|
||||||
|
if (res.status === 404) {
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'missing' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'error', error: `Failed to load log (${res.status})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'loaded', text });
|
||||||
|
} catch {
|
||||||
|
logs = new Map(logs).set(run.id, { status: 'error', error: 'Failed to load log.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes both the database record and the on-disk log file (see the DELETE
|
||||||
|
* route's own doc comment) -- there is no "delete just the log" option, so a
|
||||||
|
* removed run disappears from the list entirely rather than turning into a
|
||||||
|
* metadata-only row nobody can act on. */
|
||||||
|
async function deleteRun(run: DeployRun) {
|
||||||
|
deletingIds = new Set(deletingIds).add(run.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/deploys/${run.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
toast.error(data?.error || 'Failed to delete deploy run');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runs = runs.filter((r) => r.id !== run.id);
|
||||||
|
const s = new Set(expanded);
|
||||||
|
s.delete(run.id);
|
||||||
|
expanded = s;
|
||||||
|
const l = new Map(logs);
|
||||||
|
l.delete(run.id);
|
||||||
|
logs = l;
|
||||||
|
toast.success('Deploy run deleted');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete deploy run');
|
||||||
|
} finally {
|
||||||
|
const d = new Set(deletingIds);
|
||||||
|
d.delete(run.id);
|
||||||
|
deletingIds = d;
|
||||||
|
confirmDeleteId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
loadError = data?.error || `Failed to load deploy history (${res.status})`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
runs = Array.isArray(data?.runs) ? data.runs : [];
|
||||||
|
} catch {
|
||||||
|
loadError = 'Failed to load deploy history.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return { cls: 'bg-emerald-500/15 text-emerald-500', icon: Check };
|
||||||
|
case 'failed':
|
||||||
|
return { cls: 'bg-red-500/15 text-red-500', icon: X };
|
||||||
|
case 'running':
|
||||||
|
return { cls: 'bg-sky-500/15 text-sky-500', icon: Loader2 };
|
||||||
|
default:
|
||||||
|
return { cls: 'bg-muted text-muted-foreground', icon: AlertTriangle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerIcon(triggeredBy: string) {
|
||||||
|
switch (triggeredBy) {
|
||||||
|
case 'cron':
|
||||||
|
return Timer;
|
||||||
|
case 'webhook':
|
||||||
|
return Webhook;
|
||||||
|
case 'startup':
|
||||||
|
return Power;
|
||||||
|
default:
|
||||||
|
return Hand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-8"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
||||||
|
<AlertTriangle class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||||
|
<p class="text-sm text-muted-foreground">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if runs.length === 0}
|
||||||
|
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
||||||
|
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||||
|
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{#each runs as run (run.id)}
|
||||||
|
{@const view = buildDeployRunView(run)}
|
||||||
|
{@const badge = statusBadge(run.status)}
|
||||||
|
{@const BadgeIcon = badge.icon}
|
||||||
|
{@const TrigIcon = triggerIcon(run.triggeredBy)}
|
||||||
|
{@const isOpen = expanded.has(run.id)}
|
||||||
|
{@const panelState = buildDeployLogPanelState(run)}
|
||||||
|
{@const logEntry = logs.get(run.id)}
|
||||||
|
<div class="rounded-md border border-zinc-200 dark:border-zinc-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs hover:bg-muted/30"
|
||||||
|
onclick={() => toggle(run)}
|
||||||
|
>
|
||||||
|
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform {isOpen ? '' : '-rotate-90'}" />
|
||||||
|
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded {badge.cls}" title={view.statusLabel}>
|
||||||
|
<BadgeIcon class="h-3 w-3 {run.status === 'running' ? 'animate-spin' : ''}" />
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0" title={run.startedAt ? formatDateTime(run.startedAt, true) : ''}>
|
||||||
|
{run.startedAt ? formatRelativeTime(run.startedAt) : '—'}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-muted-foreground">{view.duration}</span>
|
||||||
|
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground" title={view.trigger}>
|
||||||
|
<TrigIcon class="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-muted-foreground">
|
||||||
|
{view.containerSummary} · {view.buildStatus}
|
||||||
|
</span>
|
||||||
|
{#if view.errorSummary}
|
||||||
|
<span class="max-w-[300px] shrink truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="border-t border-zinc-200 px-3 py-2.5 text-xs dark:border-zinc-700">
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Containers</div>
|
||||||
|
<div>{view.containerSummary}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Build</div>
|
||||||
|
<div>{view.buildStatus}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground">Triggered by</div>
|
||||||
|
<div>{view.trigger}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if view.optionChips.length > 0}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
{#each view.optionChips as chip}
|
||||||
|
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#if view.imagesBuilt.length > 0}
|
||||||
|
<div><span class="text-muted-foreground">Built:</span> {view.imagesBuilt.join(', ')}</div>
|
||||||
|
{/if}
|
||||||
|
{#if view.imagesPulled.length > 0}
|
||||||
|
<div><span class="text-muted-foreground">Pulled:</span> {view.imagesPulled.join(', ')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if view.errorSummary}
|
||||||
|
<!-- The task-17 spec called for the shortened last error line here (same
|
||||||
|
text the collapsed row already truncates to) -- not the raw errorMessage,
|
||||||
|
which is compose's full stderr and, for a failed run, duplicates almost
|
||||||
|
everything the log panel below shows. One line of "what went wrong" is a
|
||||||
|
useful recap next to the run's other details; the full text belongs in
|
||||||
|
exactly one place, the LogViewer beneath it. -->
|
||||||
|
<div class="mt-2 rounded-md border border-l-[3px] border-destructive/40 border-l-destructive bg-destructive/5 px-2 py-1.5">
|
||||||
|
<span class="font-mono text-[11px] text-destructive/90">{view.errorSummary}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="mb-1 flex items-center justify-between">
|
||||||
|
<span class="text-muted-foreground">Log</span>
|
||||||
|
{#if panelState.deletable}
|
||||||
|
<ConfirmPopover
|
||||||
|
open={confirmDeleteId === run.id}
|
||||||
|
action="Delete"
|
||||||
|
itemType="deploy run"
|
||||||
|
title="Delete this run"
|
||||||
|
position="left"
|
||||||
|
disabled={deletingIds.has(run.id)}
|
||||||
|
onConfirm={() => deleteRun(run)}
|
||||||
|
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
|
||||||
|
>
|
||||||
|
{#snippet children({ open })}
|
||||||
|
{#if deletingIds.has(run.id)}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<Trash2 class="h-3 w-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ConfirmPopover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if panelState.truncated}
|
||||||
|
<!-- The notice belongs directly above the viewer it describes, not
|
||||||
|
among the run's other metadata -- it says something about the
|
||||||
|
text below it, not about the run as a whole. -->
|
||||||
|
<div class="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if panelState.logMissing || logEntry?.status === 'missing'}
|
||||||
|
<!-- A confirmed-gone log file reads the same as an empty one if it's
|
||||||
|
just handed to LogViewer with logs="" -- the two mean opposite
|
||||||
|
things (nothing happened vs. the record of what happened is
|
||||||
|
gone), so this case gets its own, unambiguous message instead. -->
|
||||||
|
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
|
||||||
|
Protocol no longer available.
|
||||||
|
</div>
|
||||||
|
{:else if logEntry?.status === 'error'}
|
||||||
|
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
|
||||||
|
{logEntry.error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<LogViewer
|
||||||
|
logs={logEntry?.text ?? ''}
|
||||||
|
loading={logEntry?.status !== 'loaded'}
|
||||||
|
title={`${stackName}-deploy-${run.id}`}
|
||||||
|
autoRefresh={false}
|
||||||
|
autoScroll={false}
|
||||||
|
class="h-64"
|
||||||
|
{theme}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Loader2 } from 'lucide-svelte';
|
import { Loader2 } from 'lucide-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
stackName: string;
|
stackName: string;
|
||||||
@@ -12,6 +13,33 @@
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
side?: 'top' | 'bottom';
|
side?: 'top' | 'bottom';
|
||||||
align?: 'start' | 'center' | 'end';
|
align?: 'start' | 'center' | 'end';
|
||||||
|
/**
|
||||||
|
* 'icon' (default): small icon-only trigger, as used standalone in the stack
|
||||||
|
* grid rows -- the icon itself IS the action, there is no separate main button.
|
||||||
|
* 'chevron': a small square trigger meant to sit flush against a separate,
|
||||||
|
* directly-clickable main button (a split button), as used next to
|
||||||
|
* "Save & redeploy" / "Create & Start" in StackModal. The main button is NOT
|
||||||
|
* part of this component -- the caller renders it, wraps both in one flex
|
||||||
|
* container, and gives the main button `rounded-r-none` / this trigger
|
||||||
|
* `rounded-l-none` so they read as one control with two separate, independently
|
||||||
|
* reachable click/focus targets (never one element that does different things
|
||||||
|
* depending on where exactly it's clicked).
|
||||||
|
*/
|
||||||
|
triggerVariant?: 'icon' | 'chevron';
|
||||||
|
/** Extra classes merged onto the trigger. */
|
||||||
|
triggerClass?: string;
|
||||||
|
/**
|
||||||
|
* Initial state of the "Pull images" / "Build images" / "Force recreate"
|
||||||
|
* checkboxes each time the popover opens. Re-read on every open (not just once
|
||||||
|
* at mount) so a caller whose default is derived from live content (e.g. "does
|
||||||
|
* the compose have a build: section right now") stays correct across repeated
|
||||||
|
* opens.
|
||||||
|
*/
|
||||||
|
defaultPull?: boolean;
|
||||||
|
defaultBuild?: boolean;
|
||||||
|
defaultForceRecreate?: boolean;
|
||||||
|
/** Short note shown under the checkboxes, e.g. explaining why Build is pre-checked. */
|
||||||
|
reason?: string;
|
||||||
onDeploy: (options: { pull: boolean; build: boolean; forceRecreate: boolean }) => Promise<void>;
|
onDeploy: (options: { pull: boolean; build: boolean; forceRecreate: boolean }) => Promise<void>;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
@@ -22,14 +50,20 @@
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
align = 'end',
|
align = 'end',
|
||||||
|
triggerVariant = 'icon',
|
||||||
|
triggerClass = '',
|
||||||
|
defaultPull = true,
|
||||||
|
defaultBuild = false,
|
||||||
|
defaultForceRecreate = false,
|
||||||
|
reason,
|
||||||
onDeploy,
|
onDeploy,
|
||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let pull = $state(true);
|
let pull = $state(defaultPull);
|
||||||
let build = $state(false);
|
let build = $state(defaultBuild);
|
||||||
let forceRecreate = $state(false);
|
let forceRecreate = $state(defaultForceRecreate);
|
||||||
let deploying = $state(false);
|
let deploying = $state(false);
|
||||||
|
|
||||||
async function handleDeploy() {
|
async function handleDeploy() {
|
||||||
@@ -45,6 +79,14 @@
|
|||||||
function handleTriggerClick(e: MouseEvent) {
|
function handleTriggerClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
if (!open) {
|
||||||
|
// Re-seed from the current defaults on every open, not just once at mount --
|
||||||
|
// defaultBuild in particular tracks the live compose content and can change
|
||||||
|
// between two opens of the same popover.
|
||||||
|
pull = defaultPull;
|
||||||
|
build = defaultBuild;
|
||||||
|
forceRecreate = defaultForceRecreate;
|
||||||
|
}
|
||||||
open = !open;
|
open = !open;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -54,10 +96,16 @@
|
|||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Redeploy"
|
title={triggerVariant === 'icon' ? 'Redeploy' : 'More deploy options'}
|
||||||
{...props}
|
{...props}
|
||||||
onclick={handleTriggerClick}
|
onclick={handleTriggerClick}
|
||||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
|
{disabled}
|
||||||
|
class={triggerVariant === 'chevron'
|
||||||
|
? cn(buttonVariants({ variant: 'default', size: 'icon' }), 'rounded-l-none border-l border-primary-foreground/20', triggerClass)
|
||||||
|
: cn(
|
||||||
|
'p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center',
|
||||||
|
triggerClass
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</button>
|
</button>
|
||||||
@@ -80,6 +128,9 @@
|
|||||||
<Checkbox bind:checked={build} disabled={deploying} />
|
<Checkbox bind:checked={build} disabled={deploying} />
|
||||||
<span class="text-xs">Build images</span>
|
<span class="text-xs">Build images</span>
|
||||||
</label>
|
</label>
|
||||||
|
{#if reason}
|
||||||
|
<p class="text-2xs text-muted-foreground pl-6 -mt-1">{reason}</p>
|
||||||
|
{/if}
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<Checkbox bind:checked={forceRecreate} disabled={deploying} />
|
<Checkbox bind:checked={forceRecreate} disabled={deploying} />
|
||||||
<span class="text-xs">Force recreate</span>
|
<span class="text-xs">Force recreate</span>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
import { SELECTOR_VARS } from '$lib/utils/bulk-selector';
|
import { SELECTOR_VARS } from '$lib/utils/bulk-selector';
|
||||||
import { classifyMarker, resolvedRefVarNames } from '$lib/utils/invault-markers';
|
import { classifyMarker, resolvedRefVarNames } from '$lib/utils/invault-markers';
|
||||||
import { applyQuickFix, findingKey } from '$lib/utils/compose-quick-fix';
|
import { applyQuickFix, findingKey } from '$lib/utils/compose-quick-fix';
|
||||||
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive, ListChecks } from 'lucide-svelte';
|
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, GripHorizontal, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive, ListChecks, History, ChevronDown } from 'lucide-svelte';
|
||||||
import ComposeValidatePanel from './ComposeValidatePanel.svelte';
|
import ComposeValidatePanel from './ComposeValidatePanel.svelte';
|
||||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||||
|
import DeploysPanel from './DeploysPanel.svelte';
|
||||||
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
@@ -32,12 +33,33 @@
|
|||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||||
import { readJobResponse } from '$lib/utils/sse-fetch';
|
import { readJobResponse } from '$lib/utils/sse-fetch';
|
||||||
|
import { saveCloseTiming } from '$lib/utils/save-close-policy';
|
||||||
|
import { clampNumber } from '$lib/utils/clamp-number';
|
||||||
|
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||||
|
import { formatRunStatus } from '$lib/utils/run-status';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||||
|
import RedeployPopover from './RedeployPopover.svelte';
|
||||||
|
import { hasBuildSection as detectBuildSection } from '$lib/utils/compose-build-detect';
|
||||||
|
|
||||||
|
|
||||||
// localStorage key for persisted split ratio
|
// localStorage key for persisted split ratio
|
||||||
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
|
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
|
||||||
|
// Own key: this ratio has nothing to do with the compose/env split ratio above --
|
||||||
|
// reusing STORAGE_KEY_SPLIT would mean resizing the editor/env divider also resizes
|
||||||
|
// the unrelated output panel on the next load.
|
||||||
|
const STORAGE_KEY_OUTPUT_SPLIT = 'dockhand-stack-modal-output-split';
|
||||||
|
|
||||||
|
// How long a successful deploy leaves the modal open before auto-closing, so the
|
||||||
|
// operator still glimpses the success and the compose output before it goes away
|
||||||
|
// (operator decision, 30.08.2026 -- see save-close-policy.ts). The plain-save flash-
|
||||||
|
// then-close delay (500ms, below) predates this and is left as it was.
|
||||||
|
const DEPLOY_SUCCESS_CLOSE_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
// Options picked in the RedeployPopover next to "Save & redeploy" / "Create & Start"
|
||||||
|
// (see the pull/build/forceRecreate contract RedeployPopover.svelte already uses for
|
||||||
|
// the stack-grid redeploy actions).
|
||||||
|
type DeployOptions = { pull: boolean; build: boolean; forceRecreate: boolean };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -115,7 +137,29 @@
|
|||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let errors = $state<{ stackName?: string; compose?: string }>({});
|
let errors = $state<{ stackName?: string; compose?: string }>({});
|
||||||
let composeContent = $state('');
|
let composeContent = $state('');
|
||||||
let activeTab = $state<'editor' | 'graph' | 'backups'>('editor');
|
// Whether the current compose content declares a build: section for any service --
|
||||||
|
// pre-checks "Build images" in the Save & redeploy / Create & Start popover
|
||||||
|
// (RedeployPopover's defaultBuild). Logic lives in compose-build-detect.ts, not
|
||||||
|
// inline, so it has its own unit test independent of mounting this component.
|
||||||
|
let hasBuildSection = $derived(detectBuildSection(composeContent));
|
||||||
|
// Single source of truth for "what does a direct click on the main button do",
|
||||||
|
// shared between the button's own onclick AND the split-button popover's
|
||||||
|
// defaultPull/defaultBuild/defaultForceRecreate props next to it -- so the two
|
||||||
|
// paths (one-click vs pick-then-Deploy) can never drift into deploying with
|
||||||
|
// different options for what looks like the same defaults.
|
||||||
|
//
|
||||||
|
// pull is always false on both: Save/Create already run against whatever images
|
||||||
|
// are already local, pulling is an explicit extra step the operator reaches for
|
||||||
|
// via the popover, not something either main button does silently.
|
||||||
|
//
|
||||||
|
// forceRecreate differs: Save & redeploy defaults to true, preserving the
|
||||||
|
// endpoint's prior always-on behavior (env var changes need --force-recreate to
|
||||||
|
// take effect) now that it's a real choice instead of hardcoded. Create & Start
|
||||||
|
// defaults to false -- there is nothing to recreate on a stack that doesn't exist
|
||||||
|
// yet.
|
||||||
|
let saveRedeployDefaults = $derived<DeployOptions>({ pull: false, build: hasBuildSection, forceRecreate: true });
|
||||||
|
let createStartDefaults = $derived<DeployOptions>({ pull: false, build: hasBuildSection, forceRecreate: false });
|
||||||
|
let activeTab = $state<'editor' | 'graph' | 'backups' | 'deploys'>('editor');
|
||||||
let backupCount = $state(0);
|
let backupCount = $state(0);
|
||||||
let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
|
let backupTally = $state<{ ok: number; failed: number }>({ ok: 0, failed: 0 });
|
||||||
let showConfirmClose = $state(false);
|
let showConfirmClose = $state(false);
|
||||||
@@ -174,6 +218,47 @@
|
|||||||
// Error dialog state
|
// Error dialog state
|
||||||
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
|
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
|
||||||
|
|
||||||
|
// Live compose output for deploying operations (Create & Start, Save & redeploy),
|
||||||
|
// rendered inline below the editor instead of in a separate window -- the modal
|
||||||
|
// stays open while it runs, and afterwards for exactly as long as saveCloseTiming
|
||||||
|
// (see save-close-policy.ts) says it should, so the output stays visible where the
|
||||||
|
// result or the error it might explain also lives.
|
||||||
|
let outputTitle = $state('');
|
||||||
|
let outputLines = $state<string[]>([]);
|
||||||
|
let outputRunning = $state(false);
|
||||||
|
let outputOk = $state<boolean | undefined>(undefined);
|
||||||
|
let outputMs = $state<number | undefined>(undefined);
|
||||||
|
let outputExitCode = $state<number | undefined>(undefined);
|
||||||
|
let outputStartedAt = 0;
|
||||||
|
|
||||||
|
function startOutput(title: string) {
|
||||||
|
outputTitle = title;
|
||||||
|
outputLines = [];
|
||||||
|
outputRunning = true;
|
||||||
|
outputStartedAt = Date.now();
|
||||||
|
outputOk = undefined;
|
||||||
|
outputMs = undefined;
|
||||||
|
outputExitCode = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOutputLine(line: string) {
|
||||||
|
outputLines = [...outputLines, line];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishOutput(output: string | undefined, ok: boolean, exitCode?: number) {
|
||||||
|
outputRunning = false;
|
||||||
|
outputOk = ok;
|
||||||
|
outputMs = Date.now() - outputStartedAt;
|
||||||
|
outputExitCode = exitCode;
|
||||||
|
if (outputLines.length === 0 && output) {
|
||||||
|
outputLines = output.split('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputStatusLine = $derived(
|
||||||
|
formatRunStatus({ running: outputRunning, ok: outputOk, ms: outputMs, exitCode: outputExitCode })
|
||||||
|
);
|
||||||
|
|
||||||
// Stack exists warning dialog state
|
// Stack exists warning dialog state
|
||||||
let showExistsWarning = $state(false);
|
let showExistsWarning = $state(false);
|
||||||
|
|
||||||
@@ -395,6 +480,11 @@
|
|||||||
let pathChangeOldDir = $state<string | null>(null); // Old directory to move files from
|
let pathChangeOldDir = $state<string | null>(null); // Old directory to move files from
|
||||||
let pathChangeFileCount = $state(0); // Number of files in old directory
|
let pathChangeFileCount = $state(0); // Number of files in old directory
|
||||||
let pendingSaveRestart = $state(false); // Whether user clicked "Save & restart" vs "Save"
|
let pendingSaveRestart = $state(false); // Whether user clicked "Save & restart" vs "Save"
|
||||||
|
// Pull/build/forceRecreate chosen in the "Save & redeploy" RedeployPopover (see
|
||||||
|
// handleSave below) -- carried across the path-change confirmation dialog the same
|
||||||
|
// way pendingSaveRestart is, so re-entering handleSave() after the user confirms a
|
||||||
|
// path move still deploys with the options they actually picked.
|
||||||
|
let pendingSaveOptions = $state<DeployOptions | undefined>(undefined);
|
||||||
|
|
||||||
// Browse confirmation dialog state (when selecting different file would replace content)
|
// Browse confirmation dialog state (when selecting different file would replace content)
|
||||||
let showBrowseConfirm = $state(false);
|
let showBrowseConfirm = $state(false);
|
||||||
@@ -892,6 +982,18 @@
|
|||||||
let isDraggingSplit = $state(false);
|
let isDraggingSplit = $state(false);
|
||||||
let containerRef: HTMLDivElement | null = $state(null);
|
let containerRef: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
// Resizable output-panel split state -- height (not width) of the live output
|
||||||
|
// panel below the editor, as a percentage of the combined editor+output area.
|
||||||
|
// Bounds (15/70, default 30): the editor must stay usable even at the output
|
||||||
|
// panel's largest size (30% left for the editor is several lines, not one), and
|
||||||
|
// the output panel must stay usable even at its smallest (15% is enough for the
|
||||||
|
// status line plus a handful of log lines).
|
||||||
|
const OUTPUT_SPLIT_MIN = 15;
|
||||||
|
const OUTPUT_SPLIT_MAX = 70;
|
||||||
|
let outputSplitRatio = $state(30); // percentage of the editor+output area given to output
|
||||||
|
let isDraggingOutputSplit = $state(false);
|
||||||
|
let outputAreaRef: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
// Debounce timer for validation
|
// Debounce timer for validation
|
||||||
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -1076,6 +1178,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load saved output-panel split ratio
|
||||||
|
const savedOutputSplit = localStorage.getItem(STORAGE_KEY_OUTPUT_SPLIT);
|
||||||
|
if (savedOutputSplit) {
|
||||||
|
const ratio = parseFloat(savedOutputSplit);
|
||||||
|
if (!isNaN(ratio) && ratio >= OUTPUT_SPLIT_MIN && ratio <= OUTPUT_SPLIT_MAX) {
|
||||||
|
outputSplitRatio = ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add global mouse event listeners for split dragging
|
// Add global mouse event listeners for split dragging
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -1105,6 +1216,12 @@
|
|||||||
isDraggingSplit = true;
|
isDraggingSplit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Output-panel split drag handler (vertical drag -- resizes height, not width).
|
||||||
|
function startOutputSplitDrag(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingOutputSplit = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate side-panel width (px), drag-resizable, persisted.
|
// Validate side-panel width (px), drag-resizable, persisted.
|
||||||
const STORAGE_KEY_VALIDATE_W = 'dockhand-validate-panel-width';
|
const STORAGE_KEY_VALIDATE_W = 'dockhand-validate-panel-width';
|
||||||
let validatePanelWidth = $state(
|
let validatePanelWidth = $state(
|
||||||
@@ -1123,7 +1240,7 @@
|
|||||||
if (isDraggingSplit && containerRef) {
|
if (isDraggingSplit && containerRef) {
|
||||||
const rect = containerRef.getBoundingClientRect();
|
const rect = containerRef.getBoundingClientRect();
|
||||||
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
splitRatio = Math.max(30, Math.min(80, newRatio));
|
splitRatio = clampNumber(newRatio, 30, 80);
|
||||||
}
|
}
|
||||||
if (isDraggingValidate && editorRowRef) {
|
if (isDraggingValidate && editorRowRef) {
|
||||||
const rect = editorRowRef.getBoundingClientRect();
|
const rect = editorRowRef.getBoundingClientRect();
|
||||||
@@ -1131,7 +1248,14 @@
|
|||||||
const w = rect.right - e.clientX;
|
const w = rect.right - e.clientX;
|
||||||
// Floor at 320px: below that the header's title + count chips + re-check button
|
// Floor at 320px: below that the header's title + count chips + re-check button
|
||||||
// no longer fit on one line and start clipping.
|
// no longer fit on one line and start clipping.
|
||||||
validatePanelWidth = Math.max(320, Math.min(560, w));
|
validatePanelWidth = clampNumber(w, 320, 560);
|
||||||
|
}
|
||||||
|
if (isDraggingOutputSplit && outputAreaRef) {
|
||||||
|
const rect = outputAreaRef.getBoundingClientRect();
|
||||||
|
// output panel is on the bottom: its height = distance from cursor to the
|
||||||
|
// area's bottom edge, as a percentage of the whole editor+output area.
|
||||||
|
const newRatio = ((rect.bottom - e.clientY) / rect.height) * 100;
|
||||||
|
outputSplitRatio = clampNumber(newRatio, OUTPUT_SPLIT_MIN, OUTPUT_SPLIT_MAX);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,6 +1269,10 @@
|
|||||||
isDraggingValidate = false;
|
isDraggingValidate = false;
|
||||||
localStorage.setItem(STORAGE_KEY_VALIDATE_W, String(validatePanelWidth));
|
localStorage.setItem(STORAGE_KEY_VALIDATE_W, String(validatePanelWidth));
|
||||||
}
|
}
|
||||||
|
if (isDraggingOutputSplit) {
|
||||||
|
isDraggingOutputSplit = false;
|
||||||
|
localStorage.setItem(STORAGE_KEY_OUTPUT_SPLIT, outputSplitRatio.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the backup picker's volume/bind list from this stack's containers'
|
// Populate the backup picker's volume/bind list from this stack's containers'
|
||||||
@@ -1333,7 +1461,7 @@
|
|||||||
composeContent = newContent;
|
composeContent = newContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(start: boolean = false, persistenceAcked = false) {
|
async function handleCreate(start: boolean = false, persistenceAcked = false, deployOptions?: DeployOptions) {
|
||||||
errors = {};
|
errors = {};
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
|
|
||||||
@@ -1355,7 +1483,7 @@
|
|||||||
|
|
||||||
// Warn if the chosen compose path won't survive a recreate (#1524) - before creating.
|
// Warn if the chosen compose path won't survive a recreate (#1524) - before creating.
|
||||||
if (!persistenceAcked && workingComposePath.trim()) {
|
if (!persistenceAcked && workingComposePath.trim()) {
|
||||||
return guardComposePersistence(workingComposePath.trim(), () => handleCreate(start, true));
|
return guardComposePersistence(workingComposePath.trim(), () => handleCreate(start, true, deployOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
const envId = $currentEnvironment?.id ?? null;
|
const envId = $currentEnvironment?.id ?? null;
|
||||||
@@ -1414,6 +1542,16 @@
|
|||||||
|
|
||||||
requestBody.secretProviderId = formSecretProviderId;
|
requestBody.secretProviderId = formSecretProviderId;
|
||||||
|
|
||||||
|
// Only meaningful when start is true -- deployOptions is undefined for the
|
||||||
|
// plain "Create" button, which never reaches deployStack server-side anyway.
|
||||||
|
if (start && deployOptions) {
|
||||||
|
requestBody.pull = deployOptions.pull;
|
||||||
|
requestBody.build = deployOptions.build;
|
||||||
|
requestBody.forceRecreate = deployOptions.forceRecreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start) startOutput(`Starting ${newStackName.trim()}`);
|
||||||
|
|
||||||
// Create the stack
|
// Create the stack
|
||||||
response = await fetch(appendEnvParam('/api/stacks', envId), {
|
response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1422,7 +1560,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When start=true, response is a job or JSON; when start=false, it's plain JSON
|
// When start=true, response is a job or JSON; when start=false, it's plain JSON
|
||||||
const data = start ? await readJobResponse(response) : await response.json();
|
const data = start
|
||||||
|
? await readJobResponse(response, (line) => appendOutputLine(line))
|
||||||
|
: await response.json();
|
||||||
|
if (start) finishOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok && !data.success) {
|
if (!response.ok && !data.success) {
|
||||||
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack');
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack');
|
||||||
@@ -1435,8 +1580,28 @@
|
|||||||
|
|
||||||
toast.success(`Created stack "${newStackName.trim()}"`);
|
toast.success(`Created stack "${newStackName.trim()}"`);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
handleClose();
|
switch (saveCloseTiming(start, Boolean(data.success))) {
|
||||||
|
case 'close':
|
||||||
|
handleClose();
|
||||||
|
break;
|
||||||
|
case 'close-delayed':
|
||||||
|
setTimeout(() => handleClose(), DEPLOY_SUCCESS_CLOSE_DELAY_MS);
|
||||||
|
break;
|
||||||
|
case 'stay-open':
|
||||||
|
// Reachable only if a future change to the create/deploy endpoint ever
|
||||||
|
// resolves without throwing on failure -- today both throw checks above
|
||||||
|
// always catch a failed deploy first, so this case is currently dead code
|
||||||
|
// at this exact call site. Kept so the switch stays exhaustive and this
|
||||||
|
// call site doesn't silently start closing on failure if that changes.
|
||||||
|
break;
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
// The success path above already calls finishOutput with the server's real
|
||||||
|
// ok/exitCode before it throws (a deploy that ran but reported failure).
|
||||||
|
// Only mark the run failed here if it never got that far -- overwriting a
|
||||||
|
// real exit code with `undefined` would erase the very thing the docked
|
||||||
|
// output panel exists to show.
|
||||||
|
if (start && outputRunning) finishOutput(undefined, false);
|
||||||
operationError = {
|
operationError = {
|
||||||
title: 'Failed to create stack',
|
title: 'Failed to create stack',
|
||||||
message: e.message || 'An error occurred while creating the stack',
|
message: e.message || 'An error occurred while creating the stack',
|
||||||
@@ -1455,7 +1620,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(restart = false, moveFromDir: string | null | undefined = undefined, persistenceAcked = false) {
|
async function handleSave(restart = false, moveFromDir: string | null | undefined = undefined, persistenceAcked = false, deployOptions?: DeployOptions) {
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
// Validate compose content (unless file location is needed and we have a path)
|
// Validate compose content (unless file location is needed and we have a path)
|
||||||
@@ -1493,7 +1658,7 @@
|
|||||||
// Non-persisted path is the more serious warning - surface it first (#1524).
|
// Non-persisted path is the more serious warning - surface it first (#1524).
|
||||||
if (checkData.persistenceWarning && !persistenceAcked) {
|
if (checkData.persistenceWarning && !persistenceAcked) {
|
||||||
persistenceWarnText = checkData.persistenceWarning;
|
persistenceWarnText = checkData.persistenceWarning;
|
||||||
persistenceWarnProceed = () => handleSave(restart, moveFromDir, true);
|
persistenceWarnProceed = () => handleSave(restart, moveFromDir, true, deployOptions);
|
||||||
showPersistenceWarn = true;
|
showPersistenceWarn = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1502,6 +1667,7 @@
|
|||||||
pathChangeOldDir = checkData.oldDir;
|
pathChangeOldDir = checkData.oldDir;
|
||||||
pathChangeFileCount = checkData.fileCount;
|
pathChangeFileCount = checkData.fileCount;
|
||||||
pendingSaveRestart = restart;
|
pendingSaveRestart = restart;
|
||||||
|
pendingSaveOptions = deployOptions;
|
||||||
showPathChangeConfirm = true;
|
showPathChangeConfirm = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1556,6 +1722,14 @@
|
|||||||
|
|
||||||
requestBody.secretProviderId = formSecretProviderId;
|
requestBody.secretProviderId = formSecretProviderId;
|
||||||
|
|
||||||
|
// Only meaningful when restart is true -- deployOptions is undefined for the
|
||||||
|
// plain "Save" button, which never reaches deployStack server-side anyway.
|
||||||
|
if (restart && deployOptions) {
|
||||||
|
requestBody.pull = deployOptions.pull;
|
||||||
|
requestBody.build = deployOptions.build;
|
||||||
|
requestBody.forceRecreate = deployOptions.forceRecreate;
|
||||||
|
}
|
||||||
|
|
||||||
// Save env files BEFORE compose to ensure deploy reads fresh values
|
// Save env files BEFORE compose to ensure deploy reads fresh values
|
||||||
// Save raw content to .env file (non-secrets only, comments preserved)
|
// Save raw content to .env file (non-secrets only, comments preserved)
|
||||||
const rawEnvResponse = await fetch(
|
const rawEnvResponse = await fetch(
|
||||||
@@ -1601,6 +1775,8 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (restart) startOutput(`Redeploying ${stackName}`);
|
||||||
|
|
||||||
// Save compose file (with optional paths) - after env so deploy reads fresh .env
|
// Save compose file (with optional paths) - after env so deploy reads fresh .env
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
|
||||||
@@ -1612,7 +1788,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
// When restart=true, response is a job or JSON; when restart=false, it's plain JSON
|
// When restart=true, response is a job or JSON; when restart=false, it's plain JSON
|
||||||
const data = restart ? await readJobResponse(response) : await response.json();
|
const data = restart
|
||||||
|
? await readJobResponse(response, (line) => appendOutputLine(line))
|
||||||
|
: await response.json();
|
||||||
|
if (restart) finishOutput(
|
||||||
|
typeof data.output === 'string' ? data.output : undefined,
|
||||||
|
Boolean(data.success),
|
||||||
|
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok && !data.success) {
|
if (!response.ok && !data.success) {
|
||||||
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file');
|
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file');
|
||||||
@@ -1625,13 +1808,26 @@
|
|||||||
toast.success(restart ? 'Stack applied' : 'Stack saved');
|
toast.success(restart ? 'Stack applied' : 'Stack saved');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|
||||||
if (!restart) {
|
switch (saveCloseTiming(restart, Boolean(data.success))) {
|
||||||
// Show success briefly then close
|
case 'close':
|
||||||
setTimeout(() => handleClose(), 500);
|
// Show success briefly then close.
|
||||||
} else {
|
setTimeout(() => handleClose(), 500);
|
||||||
handleClose();
|
break;
|
||||||
|
case 'close-delayed':
|
||||||
|
setTimeout(() => handleClose(), DEPLOY_SUCCESS_CLOSE_DELAY_MS);
|
||||||
|
break;
|
||||||
|
case 'stay-open':
|
||||||
|
// Reachable only if a future change to the compose/deploy endpoint ever
|
||||||
|
// resolves without throwing on failure -- today both throw checks above
|
||||||
|
// always catch a failed deploy first, so this case is currently dead code
|
||||||
|
// at this exact call site. Kept so the switch stays exhaustive and this
|
||||||
|
// call site doesn't silently start closing on failure if that changes.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
// Same reasoning as handleCreate's catch block: don't clobber a real
|
||||||
|
// ok/exitCode that finishOutput already recorded before this throw.
|
||||||
|
if (restart && outputRunning) finishOutput(undefined, false);
|
||||||
operationError = {
|
operationError = {
|
||||||
title: restart ? 'Failed to apply stack' : 'Failed to save stack',
|
title: restart ? 'Failed to apply stack' : 'Failed to save stack',
|
||||||
message: e.message || (restart ? 'An error occurred while applying the stack' : 'An error occurred while saving the stack'),
|
message: e.message || (restart ? 'An error occurred while applying the stack' : 'An error occurred while saving the stack'),
|
||||||
@@ -1645,14 +1841,14 @@
|
|||||||
// Handle path change confirmation - move files to new location and proceed
|
// Handle path change confirmation - move files to new location and proceed
|
||||||
function confirmPathChangeAndMove() {
|
function confirmPathChangeAndMove() {
|
||||||
showPathChangeConfirm = false;
|
showPathChangeConfirm = false;
|
||||||
handleSave(pendingSaveRestart, pathChangeOldDir);
|
handleSave(pendingSaveRestart, pathChangeOldDir, false, pendingSaveOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle path change - keep old files and proceed (just save without moving)
|
// Handle path change - keep old files and proceed (just save without moving)
|
||||||
function confirmPathChangeKeepFiles() {
|
function confirmPathChangeKeepFiles() {
|
||||||
showPathChangeConfirm = false;
|
showPathChangeConfirm = false;
|
||||||
// Pass empty string to skip move check (undefined means "not checked yet")
|
// Pass empty string to skip move check (undefined means "not checked yet")
|
||||||
handleSave(pendingSaveRestart, '');
|
handleSave(pendingSaveRestart, '', false, pendingSaveOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryClose() {
|
function tryClose() {
|
||||||
@@ -1706,6 +1902,7 @@
|
|||||||
pathChangeOldDir = null;
|
pathChangeOldDir = null;
|
||||||
pathChangeFileCount = 0;
|
pathChangeFileCount = 0;
|
||||||
pendingSaveRestart = false;
|
pendingSaveRestart = false;
|
||||||
|
pendingSaveOptions = undefined;
|
||||||
// Reset browse confirmation state
|
// Reset browse confirmation state
|
||||||
showBrowseConfirm = false;
|
showBrowseConfirm = false;
|
||||||
pendingBrowsePath = null;
|
pendingBrowsePath = null;
|
||||||
@@ -1734,6 +1931,23 @@
|
|||||||
validateLoading = false;
|
validateLoading = false;
|
||||||
validateActiveLine = null;
|
validateActiveLine = null;
|
||||||
validateSeq++;
|
validateSeq++;
|
||||||
|
// Same reasoning for the docked deploy output: without this, reopening the
|
||||||
|
// modal for a *different* stack would still show the previous stack's title,
|
||||||
|
// lines and status ("Redeploying A" / "Succeeded" while looking at stack B).
|
||||||
|
// A reopen of the *same* stack while its own deploy is still running loses the
|
||||||
|
// lines accumulated so far, but only until the next line arrives (the poll loop
|
||||||
|
// in sse-fetch.ts keeps running regardless of this modal's open state, same as
|
||||||
|
// ComposeOutputModal) or the job finishes -- finishOutput's `outputLines.length
|
||||||
|
// === 0 && output` fallback then refills the whole transcript from the job's
|
||||||
|
// final output. That brief, self-correcting gap is preferable to a stale result
|
||||||
|
// silently misattributed to the wrong stack.
|
||||||
|
outputTitle = '';
|
||||||
|
outputLines = [];
|
||||||
|
outputRunning = false;
|
||||||
|
outputOk = undefined;
|
||||||
|
outputMs = undefined;
|
||||||
|
outputExitCode = undefined;
|
||||||
|
outputStartedAt = 0;
|
||||||
if (mode === 'edit' && stackName) {
|
if (mode === 'edit' && stackName) {
|
||||||
loadComposeFile().then(() => {
|
loadComposeFile().then(() => {
|
||||||
// Auto-validate after loading
|
// Auto-validate after loading
|
||||||
@@ -1953,8 +2167,28 @@
|
|||||||
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
|
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Deploys tab: recorded run history for this stack (Task 17). Same gate
|
||||||
|
shape as Backups above minus the beta flag -- hidden for untracked
|
||||||
|
stacks (no compose file means no deploy history either) and only in
|
||||||
|
edit mode, where stackName/envId are actually known. -->
|
||||||
|
{#if mode === 'edit' && !needsFileLocation}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'deploys' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => activeTab = 'deploys'}
|
||||||
|
>
|
||||||
|
<History class="h-3.5 w-3.5" /> Deploys
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Wrapper spanning the editor area + the live output panel below it, so the
|
||||||
|
output panel's height can be a percentage of THIS combined space rather than
|
||||||
|
of the whole dialog (which also includes the fixed header/tabs/footer). -->
|
||||||
|
<div
|
||||||
|
bind:this={outputAreaRef}
|
||||||
|
class="flex-1 min-h-0 flex flex-col {isDraggingOutputSplit ? 'select-none' : ''}"
|
||||||
|
>
|
||||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
{#if errors.compose}
|
{#if errors.compose}
|
||||||
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
<Alert.Root variant="destructive" class="mx-6 mt-4">
|
||||||
@@ -2288,11 +2522,53 @@
|
|||||||
onTally={(t) => (backupTally = t)}
|
onTally={(t) => (backupTally = t)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'deploys' && !needsFileLocation}
|
||||||
|
<!-- Deploys tab (never for untracked stacks — same reasoning as Backups above) -->
|
||||||
|
<div class="h-full flex-1 overflow-auto p-4">
|
||||||
|
<DeploysPanel {stackName} envId={$currentEnvironment?.id ?? null} theme={editorTheme} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live output for Create & Start / Save & redeploy, rendered below the editor instead
|
||||||
|
of handing the user off to a separate window (see save-close-policy.ts for how long
|
||||||
|
the dialog then stays open). Only takes up space once there is something to show,
|
||||||
|
and only then does its resize divider exist -- a handle that drags nothing is worse
|
||||||
|
than no handle. -->
|
||||||
|
{#if outputRunning || outputLines.length > 0}
|
||||||
|
<!-- Resizable divider (height, not width -- drag up/down to resize the output panel) -->
|
||||||
|
<div
|
||||||
|
class="h-1 shrink-0 bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-row-resize transition-colors flex items-center justify-center group {isDraggingOutputSplit ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||||
|
onmousedown={startOutputSplitDrag}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingOutputSplit ? 'opacity-100' : ''}">
|
||||||
|
<GripHorizontal class="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 flex flex-col min-h-0" style="height: {outputSplitRatio}%">
|
||||||
|
<div class="px-5 py-1.5 text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2 flex-shrink-0">
|
||||||
|
{#if outputRunning}
|
||||||
|
<Loader2 class="w-3 h-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{outputTitle} — {outputStatusLine}
|
||||||
|
</div>
|
||||||
|
<LogViewer
|
||||||
|
logs={outputLines.join('\n')}
|
||||||
|
title={outputTitle}
|
||||||
|
autoRefresh={false}
|
||||||
|
autoScroll={outputRunning}
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
theme={editorTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
|
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
|
||||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
@@ -2325,15 +2601,41 @@
|
|||||||
Create
|
Create
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={() => handleCreate(true)} disabled={saving}>
|
<!-- Split button: the label itself stays a direct, one-click action (the
|
||||||
{#if saving}
|
common case) with the current defaults baked in; the chevron opens a
|
||||||
<Loader2 class="w-4 h-4 animate-spin" />
|
popover to override pull/build/forceRecreate before deploying. Two
|
||||||
Starting...
|
separate <button> elements, both independently reachable by keyboard --
|
||||||
{:else}
|
never one element whose behavior depends on click position. -->
|
||||||
<Play class="w-4 h-4" />
|
<div class="inline-flex">
|
||||||
Create & Start
|
<Button
|
||||||
{/if}
|
class="rounded-r-none"
|
||||||
</Button>
|
onclick={() => handleCreate(true, false, createStartDefaults)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{#if saving}
|
||||||
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
Starting...
|
||||||
|
{:else}
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
Create & Start
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<RedeployPopover
|
||||||
|
stackName={newStackName}
|
||||||
|
envId={$currentEnvironment?.id ?? null}
|
||||||
|
disabled={saving}
|
||||||
|
triggerVariant="chevron"
|
||||||
|
defaultPull={createStartDefaults.pull}
|
||||||
|
defaultBuild={createStartDefaults.build}
|
||||||
|
defaultForceRecreate={createStartDefaults.forceRecreate}
|
||||||
|
reason={hasBuildSection ? 'Auto-checked: this compose file has a build: section' : undefined}
|
||||||
|
onDeploy={(options) => handleCreate(true, false, options)}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
{/snippet}
|
||||||
|
</RedeployPopover>
|
||||||
|
</div>
|
||||||
{:else if !readonly}
|
{:else if !readonly}
|
||||||
<!-- Edit mode buttons -->
|
<!-- Edit mode buttons -->
|
||||||
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
|
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
|
||||||
@@ -2345,15 +2647,37 @@
|
|||||||
Save
|
Save
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="w-36" onclick={() => handleSave(true)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
|
<!-- Same split-button shape as Create & Start above. -->
|
||||||
{#if saving && savingWithRestart}
|
<div class="inline-flex">
|
||||||
<Loader2 class="w-4 h-4 animate-spin" />
|
<Button
|
||||||
Deploying...
|
class="w-36 rounded-r-none"
|
||||||
{:else}
|
onclick={() => handleSave(true, undefined, false, saveRedeployDefaults)}
|
||||||
<Play class="w-4 h-4" />
|
disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}
|
||||||
Save & redeploy
|
>
|
||||||
{/if}
|
{#if saving && savingWithRestart}
|
||||||
</Button>
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
|
Deploying...
|
||||||
|
{:else}
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
Save & redeploy
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<RedeployPopover
|
||||||
|
{stackName}
|
||||||
|
envId={$currentEnvironment?.id ?? null}
|
||||||
|
disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}
|
||||||
|
triggerVariant="chevron"
|
||||||
|
defaultPull={saveRedeployDefaults.pull}
|
||||||
|
defaultBuild={saveRedeployDefaults.build}
|
||||||
|
defaultForceRecreate={saveRedeployDefaults.forceRecreate}
|
||||||
|
reason={hasBuildSection ? 'Auto-checked: this compose file has a build: section' : undefined}
|
||||||
|
onDeploy={(options) => handleSave(true, undefined, false, options)}
|
||||||
|
>
|
||||||
|
{#snippet children()}
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
{/snippet}
|
||||||
|
</RedeployPopover>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+388
-5
@@ -22110,7 +22110,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)"
|
"description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -22184,7 +22184,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)"
|
"description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -22224,7 +22224,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)"
|
"description": "Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused)"
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Schedule, backup config, or backup destination not found"
|
"description": "Schedule, backup config, or backup destination not found"
|
||||||
@@ -23777,6 +23777,12 @@
|
|||||||
"defaultTrivyImage": {
|
"defaultTrivyImage": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"deployLogReconcileCron": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deployLogReconcileEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"downloadFormat": {
|
"downloadFormat": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -23895,6 +23901,8 @@
|
|||||||
"defaultScannerNetworkMode": "string",
|
"defaultScannerNetworkMode": "string",
|
||||||
"defaultTrivyArgs": "string",
|
"defaultTrivyArgs": "string",
|
||||||
"defaultTrivyImage": "string",
|
"defaultTrivyImage": "string",
|
||||||
|
"deployLogReconcileCron": "string",
|
||||||
|
"deployLogReconcileEnabled": true,
|
||||||
"downloadFormat": "string",
|
"downloadFormat": "string",
|
||||||
"editorFont": "string",
|
"editorFont": "string",
|
||||||
"eventCleanupCron": "string",
|
"eventCleanupCron": "string",
|
||||||
@@ -24675,6 +24683,15 @@
|
|||||||
},
|
},
|
||||||
"environmentId": {
|
"environmentId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pull": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"forceRecreate": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -24694,7 +24711,10 @@
|
|||||||
"secretProviderId": 0,
|
"secretProviderId": 0,
|
||||||
"start": true,
|
"start": true,
|
||||||
"envId": 0,
|
"envId": 0,
|
||||||
"environmentId": 0
|
"environmentId": 0,
|
||||||
|
"pull": true,
|
||||||
|
"build": true,
|
||||||
|
"forceRecreate": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25036,6 +25056,15 @@
|
|||||||
},
|
},
|
||||||
"secretProviderId": {
|
"secretProviderId": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"pull": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"forceRecreate": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -25050,7 +25079,10 @@
|
|||||||
"oldEnvPath": "string",
|
"oldEnvPath": "string",
|
||||||
"moveFromDir": "string",
|
"moveFromDir": "string",
|
||||||
"restart": true,
|
"restart": true,
|
||||||
"secretProviderId": 0
|
"secretProviderId": 0,
|
||||||
|
"pull": true,
|
||||||
|
"build": true,
|
||||||
|
"forceRecreate": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25221,6 +25253,357 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/stacks/{name}/deploys": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "env",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string \"null\", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"runs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"triggeredBy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggeredAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"errorMessage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"triggeredBy",
|
||||||
|
"triggeredAt",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"runs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"environmentId": 0,
|
||||||
|
"triggeredBy": "string",
|
||||||
|
"triggeredAt": "string",
|
||||||
|
"startedAt": "string",
|
||||||
|
"completedAt": "string",
|
||||||
|
"duration": 0,
|
||||||
|
"status": "string",
|
||||||
|
"errorMessage": "string",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "env query parameter must be an integer, or omitted/\"null\" for the local environment"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view), or access denied to this environment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stacks/{name}/deploys/{runId}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys_runId",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Get a single deploy run's metadata (no log text -- see GET .../{runId}/log)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"environmentId": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"triggeredBy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggeredAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"startedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"completedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"errorMessage": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"triggeredBy",
|
||||||
|
"triggeredAt",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"id": 0,
|
||||||
|
"environmentId": 0,
|
||||||
|
"triggeredBy": "string",
|
||||||
|
"triggeredAt": "string",
|
||||||
|
"startedAt": "string",
|
||||||
|
"completedAt": "string",
|
||||||
|
"duration": 0,
|
||||||
|
"status": "string",
|
||||||
|
"errorMessage": "string",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description)."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "delete_api_stacks_name_deploys_runId",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Delete a deploy run's database record and its on-disk log file",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"success"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "The run has not finished yet (status is not a terminal one) -- deleting a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/stacks/{name}/deploys/{runId}/log": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_api_stacks_name_deploys_runId_log",
|
||||||
|
"tags": [
|
||||||
|
"stacks"
|
||||||
|
],
|
||||||
|
"summary": "Get a deploy run's log text (text/plain, from the on-disk file)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Stack name (from GET /api/stacks)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Run id (from GET /api/stacks/{name}/deploys)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Plain-text log content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Deploy run not found (also returned for a run that belongs to a different stack), or log file missing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "400 (\"Invalid run id\") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's \"run not found / belongs to a different stack\" AND this handler's own \"log file missing\" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line."
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/stacks/{name}/down": {
|
"/api/stacks/{name}/down": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_api_stacks_name_down",
|
"operationId": "post_api_stacks_name_down",
|
||||||
|
|||||||
@@ -4,10 +4,15 @@
|
|||||||
* #14). Security-critical + fail-closed, so every deny branch is pinned here.
|
* #14). Security-critical + fail-closed, so every deny branch is pinned here.
|
||||||
*
|
*
|
||||||
* The module transitively imports the DB/engine via ./index, so we mock.module()
|
* The module transitively imports the DB/engine via ./index, so we mock.module()
|
||||||
* ./index and ../../db BEFORE importing it — the guard LOGIC under test is pure
|
* ./index and register a getBackupConfig fake via ../helpers/db-fake (see that
|
||||||
* (decisions over an injected AuthorizationContext + the two resolver results).
|
* file for why: mock.module() replaces $lib/server/db WHOLESALE for the entire
|
||||||
|
* process, so this file no longer registers its own separate mock.module() for
|
||||||
|
* it — that used to collide with any other file doing the same) — the guard
|
||||||
|
* LOGIC under test is pure (decisions over an injected AuthorizationContext +
|
||||||
|
* the two resolver results).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, mock, beforeAll } from 'bun:test';
|
import { describe, it, expect, mock, beforeAll } from 'bun:test';
|
||||||
|
import { registerDbFake } from '../helpers/db-fake';
|
||||||
|
|
||||||
// Controllable fakes for the heavy imports, set per-test.
|
// Controllable fakes for the heavy imports, set per-test.
|
||||||
let resolveResult: { envId: number | null | undefined; resolved: boolean } | (() => never) = { envId: 5, resolved: true };
|
let resolveResult: { envId: number | null | undefined; resolved: boolean } | (() => never) = { envId: 5, resolved: true };
|
||||||
@@ -30,9 +35,7 @@ mock.module('../../src/lib/server/backups/index', () => ({
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
mock.module('../../src/lib/server/db', () => ({
|
registerDbFake('getBackupConfig', async (id: number) => configById(id));
|
||||||
getBackupConfig: async (id: number) => configById(id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let rg: typeof import('../../src/lib/server/backups/route-guards');
|
let rg: typeof import('../../src/lib/server/backups/route-guards');
|
||||||
beforeAll(async () => { rg = await import('../../src/lib/server/backups/route-guards'); });
|
beforeAll(async () => { rg = await import('../../src/lib/server/backups/route-guards'); });
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { clampNumber } from '../src/lib/utils/clamp-number';
|
||||||
|
|
||||||
|
// One test per case (not one test with several assertions) -- same reasoning as
|
||||||
|
// save-close-policy.test.ts: a failure in one case must not hide the others behind a
|
||||||
|
// single aborted test run.
|
||||||
|
describe('clampNumber', () => {
|
||||||
|
test('a value below the minimum is pulled up to the minimum', () => {
|
||||||
|
expect(clampNumber(5, 15, 70)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a value above the maximum is pulled down to the maximum', () => {
|
||||||
|
expect(clampNumber(90, 15, 70)).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a value already inside the bounds is left unchanged', () => {
|
||||||
|
expect(clampNumber(42, 15, 70)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NaN is not passed through -- it falls back to the minimum', () => {
|
||||||
|
// This is the real-world trigger: the dragged container's height (or width) is
|
||||||
|
// briefly 0 (e.g. mid-drag while a panel is transitioning), so the ratio/distance
|
||||||
|
// computation divides by zero and produces NaN. Math.max(30, Math.min(80, NaN))
|
||||||
|
// itself evaluates to NaN -- any NaN operand makes both Math.min and Math.max
|
||||||
|
// return NaN, so the naive clamp does not clamp at all.
|
||||||
|
expect(clampNumber(NaN, 15, 70)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a negative proposal is clamped to the minimum, not left negative', () => {
|
||||||
|
expect(clampNumber(-20, 15, 70)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('positive Infinity clamps to the maximum via the ordinary Math.min/max path', () => {
|
||||||
|
// Unlike NaN, Infinity is a well-ordered number -- Math.min(max, Infinity) is
|
||||||
|
// max, so no special-casing is needed for it to land on the maximum.
|
||||||
|
expect(clampNumber(Infinity, 15, 70)).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bounds above (15-70) are a percentage split ratio. This repo has a second,
|
||||||
|
// independent caller with pixel bounds (the validate-panel width resizer,
|
||||||
|
// 320-560px) -- proving the same function clamps correctly regardless of unit,
|
||||||
|
// since it only compares `value` against `min`/`max` and never assumes a unit.
|
||||||
|
test('pixel-range bounds (not a percentage) clamp the same way', () => {
|
||||||
|
expect(clampNumber(200, 320, 560)).toBe(320);
|
||||||
|
expect(clampNumber(9999, 320, 560)).toBe(560);
|
||||||
|
expect(clampNumber(NaN, 320, 560)).toBe(320);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { hasBuildSection } from '../src/lib/utils/compose-build-detect';
|
||||||
|
|
||||||
|
describe('hasBuildSection', () => {
|
||||||
|
test('no services at all -- false', () => {
|
||||||
|
expect(hasBuildSection('networks:\n default: {}\n')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every service uses only image: -- false', () => {
|
||||||
|
const compose = 'services:\n app:\n image: demo:1\n db:\n image: postgres:16\n';
|
||||||
|
expect(hasBuildSection(compose)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// build: can be a bare string (`build: ./dir`)...
|
||||||
|
test('build: as a bare string on one service -- true', () => {
|
||||||
|
const compose = 'services:\n app:\n build: ./app\n db:\n image: postgres:16\n';
|
||||||
|
expect(hasBuildSection(compose)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ...or an object (`build: {context: ., dockerfile: ...}`) -- both forms must be
|
||||||
|
// detected the same way (truthy check, not typeof), matching
|
||||||
|
// ComposeGraphViewer.svelte's `config.image || (config.build ? 'build' : 'custom')`.
|
||||||
|
test('build: as an object with context/dockerfile on one service -- true', () => {
|
||||||
|
const compose = 'services:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n db:\n image: postgres:16\n';
|
||||||
|
expect(hasBuildSection(compose)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the SECOND service has build: -- still true (checks every service, not just the first)', () => {
|
||||||
|
const compose = 'services:\n db:\n image: postgres:16\n app:\n build: ./app\n';
|
||||||
|
expect(hasBuildSection(compose)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid YAML mid-edit -- false, does not throw', () => {
|
||||||
|
expect(hasBuildSection('services:\n app:\n build: [unterminated')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty content -- false', () => {
|
||||||
|
expect(hasBuildSection('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { formatRunStatus } from '../src/lib/utils/run-status';
|
||||||
|
|
||||||
|
describe('formatRunStatus', () => {
|
||||||
|
test('reports success with a duration', () => {
|
||||||
|
expect(formatRunStatus({ running: false, ok: true, ms: 6200 })).toBe('Succeeded · 6.2s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports failure with the exit code when one is known', () => {
|
||||||
|
expect(formatRunStatus({ running: false, ok: false, ms: 900, exitCode: 1 }))
|
||||||
|
.toBe('Failed · exit 1 · 0.9s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says nothing about duration while still running', () => {
|
||||||
|
expect(formatRunStatus({ running: true })).toBe('Running…');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
/**
|
||||||
|
* Authorization + data-shape tests for the three deploy-run routes under
|
||||||
|
* api/stacks/[name]/deploys/. These do NOT inherit the no-auth model of
|
||||||
|
* GET/DELETE /api/jobs/{id} (unguessable UUID, ten-minute in-memory job) --
|
||||||
|
* every route here checks stacks:view/stacks:edit for itself, against a
|
||||||
|
* small sequential integer id that persists forever in schedule_executions.
|
||||||
|
*
|
||||||
|
* $lib/server/db transitively loads better-sqlite3 (ERR_DLOPEN_FAILED under
|
||||||
|
* Bun), so it has to be faked before the route modules are imported -- same
|
||||||
|
* problem tests/backups/route-guards.test.ts already solved. Fakes for it are
|
||||||
|
* registered via ../helpers/db-fake (registerDbFake), NOT via a separate
|
||||||
|
* mock.module('$lib/server/db', ...) call here -- see that helper's doc
|
||||||
|
* comment for why a second direct call collides with route-guards.test.ts's
|
||||||
|
* (mock.module() replaces the whole module for the whole process; whichever
|
||||||
|
* of the two files registers last wins for BOTH, deterministically, and the
|
||||||
|
* loser's import throws "Export named 'X' not found").
|
||||||
|
*
|
||||||
|
* $lib/server/authorize is faked via tests/helpers/authorize-fake.ts (same
|
||||||
|
* collision reason as the db fake above -- see that helper's doc comment), driven
|
||||||
|
* by a per-test `authState`. $lib/server/deploy-log-store is NOT mocked: it only
|
||||||
|
* touches node:fs/promises, so real file operations against a throwaway DATA_DIR
|
||||||
|
* exercise the actual read/delete paths.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { registerDbFake } from './helpers/db-fake';
|
||||||
|
import { registerAuthorizeFake } from './helpers/authorize-fake';
|
||||||
|
|
||||||
|
// -- $lib/server/authorize: fully replaced, driven by `authState` ----------
|
||||||
|
|
||||||
|
let authState: {
|
||||||
|
authEnabled: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isEnterprise: boolean;
|
||||||
|
can: boolean;
|
||||||
|
// H1 regression coverage needs `can()` to answer DIFFERENTLY depending on
|
||||||
|
// which environmentId it's asked about (a caller can have stacks:view for
|
||||||
|
// environment 1 but not environment 9) -- a single flat boolean can't
|
||||||
|
// express that. Left undefined, `can` (the flat boolean below) is the
|
||||||
|
// answer for every environmentId, unchanged from before -- every
|
||||||
|
// pre-existing test in this file that never touches canByEnv keeps its
|
||||||
|
// original behavior. Set only by the tests that specifically exercise
|
||||||
|
// per-environment scoping.
|
||||||
|
canByEnv?: (environmentId: number | undefined) => boolean;
|
||||||
|
accessibleEnvs: number[] | 'all';
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetAuthState() {
|
||||||
|
authState = { authEnabled: true, isAuthenticated: true, isEnterprise: false, can: true, canByEnv: undefined, accessibleEnvs: 'all' };
|
||||||
|
}
|
||||||
|
resetAuthState();
|
||||||
|
|
||||||
|
registerAuthorizeFake(async () => ({
|
||||||
|
authEnabled: authState.authEnabled,
|
||||||
|
isAuthenticated: authState.isAuthenticated,
|
||||||
|
isEnterprise: authState.isEnterprise,
|
||||||
|
can: async (_resource: string, _action: string, environmentId?: number) =>
|
||||||
|
authState.canByEnv ? authState.canByEnv(environmentId) : authState.can,
|
||||||
|
canAccessEnvironment: async (id: number) =>
|
||||||
|
authState.accessibleEnvs === 'all' || authState.accessibleEnvs.includes(id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -- $lib/server/db: an in-memory schedule_executions fake -----------------
|
||||||
|
|
||||||
|
let execStore: Map<number, any>;
|
||||||
|
let deletedIds: number[];
|
||||||
|
let lastListFilters: any;
|
||||||
|
|
||||||
|
function resetDbState() {
|
||||||
|
execStore = new Map();
|
||||||
|
deletedIds = [];
|
||||||
|
lastListFilters = undefined;
|
||||||
|
}
|
||||||
|
resetDbState();
|
||||||
|
|
||||||
|
registerDbFake('getScheduleExecution', async (id: number) => execStore.get(id));
|
||||||
|
registerDbFake('deleteScheduleExecution', async (id: number) => {
|
||||||
|
deletedIds.push(id);
|
||||||
|
execStore.delete(id);
|
||||||
|
});
|
||||||
|
registerDbFake('getScheduleExecutions', async (filters: any) => {
|
||||||
|
lastListFilters = filters;
|
||||||
|
const all = [...execStore.values()].filter(
|
||||||
|
(e) =>
|
||||||
|
(filters.scheduleType === undefined || e.scheduleType === filters.scheduleType) &&
|
||||||
|
(filters.entityName === undefined || e.entityName === filters.entityName) &&
|
||||||
|
(filters.environmentId === undefined || e.environmentId === filters.environmentId)
|
||||||
|
);
|
||||||
|
return { executions: all, total: all.length, limit: filters.limit ?? 50, offset: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Real deploy-log-store against a throwaway DATA_DIR ---------------------
|
||||||
|
//
|
||||||
|
// DATA_DIR is process-global and read at call time by every other test file
|
||||||
|
// too (belatedly discovered the hard way for exactly this variable in
|
||||||
|
// tests/fs-guard.test.ts / tests/selfhst-icons.test.ts -- a file that sets it
|
||||||
|
// and never restores it can break an unrelated, later-running file). Save and
|
||||||
|
// restore the prior value so this file leaves no trace once its tests finish.
|
||||||
|
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
let dataDir: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'dv-t12-deploy-logs-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
if (previousDataDir === undefined) {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.DATA_DIR = previousDataDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { appendRunLog, readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
|
||||||
|
// -- Routes under test, imported AFTER all the fakes above are registered ---
|
||||||
|
|
||||||
|
const listRoute = await import('../src/routes/api/stacks/[name]/deploys/+server');
|
||||||
|
const runRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/+server');
|
||||||
|
const logRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/log/+server');
|
||||||
|
|
||||||
|
// -- Fixtures ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const STACK = 'demo-stack';
|
||||||
|
|
||||||
|
const RUN = {
|
||||||
|
id: 1,
|
||||||
|
scheduleType: 'stack_deploy',
|
||||||
|
scheduleId: 0,
|
||||||
|
environmentId: null,
|
||||||
|
entityName: STACK,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
triggeredAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
duration: 4200,
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
details: { summary: 'ok' },
|
||||||
|
logs: null,
|
||||||
|
createdAt: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeEvent(over: { params?: Record<string, string>; url?: string } = {}) {
|
||||||
|
return {
|
||||||
|
params: { name: STACK, runId: '1', ...over.params },
|
||||||
|
url: new URL(over.url ?? 'http://x/'),
|
||||||
|
cookies: { get: () => undefined } as any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetAuthState();
|
||||||
|
resetDbState();
|
||||||
|
// Every test defaults to runId '1' -- clear its log file so tests don't
|
||||||
|
// leak content into each other via the shared DATA_DIR. F5 fix: the log
|
||||||
|
// file now lives under its OWN environment's directory, and this file's
|
||||||
|
// tests use both RUN's default (environmentId: null) and an overridden
|
||||||
|
// environmentId: 9 -- clear both so leftovers from either can't leak
|
||||||
|
// between tests.
|
||||||
|
await deleteRunLog(null, '1');
|
||||||
|
await deleteRunLog(9, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /api/stacks/[name]/deploys (list)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('GET /api/stacks/[name]/deploys (list)', () => {
|
||||||
|
test('no session -> 401 at the permission check, not a later error', async () => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
const res = await listRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()).error).toBe('Authentication required');
|
||||||
|
});
|
||||||
|
|
||||||
|
// H3 regression: on a single (local-only) environment install, EVERY
|
||||||
|
// recorded run had environmentId === NULL and was completely unreachable
|
||||||
|
// through this route -- `env` was hard-required, and there was no value a
|
||||||
|
// caller could pass that matched a NULL row (env=0 included; environment
|
||||||
|
// ids are never 0). See the module doc comment for the full chain.
|
||||||
|
test('H3: omitted env -> 200, treated as the LOCAL environment (environmentId null), not a 400', async () => {
|
||||||
|
execStore.set(1, { ...RUN, environmentId: null });
|
||||||
|
const res = await listRoute.GET(makeEvent()); // no ?env=
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.runs).toHaveLength(1);
|
||||||
|
expect(body.runs[0].id).toBe(1);
|
||||||
|
expect(lastListFilters.environmentId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('H3: env=null (the literal string) -> the SAME local-environment result as omitting env', async () => {
|
||||||
|
execStore.set(1, { ...RUN, environmentId: null });
|
||||||
|
const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=null' }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.runs).toHaveLength(1);
|
||||||
|
expect(lastListFilters.environmentId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric, non-"null" env -> 400 (still not a value getScheduleExecutions can filter on)', async () => {
|
||||||
|
const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=abc' }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()).error).toBe(
|
||||||
|
'env query parameter must be an integer, or omitted/"null" for the local environment'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-environment leak: omitting env must NOT surface a run from a DIFFERENT (non-null) environment', async () => {
|
||||||
|
authState.isEnterprise = true;
|
||||||
|
authState.accessibleEnvs = 'all'; // access is not the point of this test -- the null filter itself is
|
||||||
|
execStore.set(1, { ...RUN, environmentId: null }); // local run, matches the omitted-env filter
|
||||||
|
execStore.set(2, { ...RUN, id: 2, environmentId: 9 }); // foreign, non-null env, same stack name
|
||||||
|
const res = await listRoute.GET(makeEvent()); // no ?env=
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.runs).toHaveLength(1);
|
||||||
|
expect(body.runs[0].id).toBe(1);
|
||||||
|
expect(JSON.stringify(body)).not.toContain('"environmentId":9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caller lacking stacks:view for the local environment (global merge) -> 403, even though env is omitted', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
const res = await listRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated but lacking permission -> 403', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists only runs for THIS stack, without log text', async () => {
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 1 });
|
||||||
|
execStore.set(2, { ...RUN, id: 2, environmentId: 1, entityName: 'other-stack' });
|
||||||
|
const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.runs).toHaveLength(1);
|
||||||
|
expect(body.runs[0].id).toBe(1);
|
||||||
|
expect(body.runs[0]).not.toHaveProperty('logs');
|
||||||
|
expect(lastListFilters.scheduleType).toBe('stack_deploy');
|
||||||
|
expect(lastListFilters.entityName).toBe(STACK);
|
||||||
|
expect(lastListFilters.environmentId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a non-deploy schedule execution that happens to share the entity name', async () => {
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 1, scheduleType: 'backup' });
|
||||||
|
const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' }));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.runs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /api/stacks/[name]/deploys/[runId] (single)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('GET /api/stacks/[name]/deploys/[runId] (single)', () => {
|
||||||
|
test('no session -> 401 at the permission check, not a later error', async () => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()).error).toBe('Authentication required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated but lacking permission -> 403', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nonexistent run -> 404', async () => {
|
||||||
|
const res = await runRoute.GET(makeEvent({ params: { runId: '999' } }));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe('Deploy run not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run belonging to a DIFFERENT stack -> the SAME 404 as nonexistent', async () => {
|
||||||
|
execStore.set(1, { ...RUN, entityName: 'other-stack' });
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe('Deploy run not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric run id -> 400', async () => {
|
||||||
|
const res = await runRoute.GET(makeEvent({ params: { runId: 'abc' } }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-environment bypass: a caller-accessible env does not grant access to a DIFFERENT run env', async () => {
|
||||||
|
authState.isEnterprise = true;
|
||||||
|
authState.accessibleEnvs = [1]; // caller can access env 1
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 }); // but this run belongs to env 9
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accessible run -> 200 with metadata, no log text', async () => {
|
||||||
|
execStore.set(1, RUN);
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.id).toBe(1);
|
||||||
|
expect(body.duration).toBe(4200);
|
||||||
|
expect(body).not.toHaveProperty('logs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DELETE /api/stacks/[name]/deploys/[runId]
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('DELETE /api/stacks/[name]/deploys/[runId]', () => {
|
||||||
|
test('no session -> 401 at the permission check, not a later error', async () => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()).error).toBe('Authentication required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated but lacking permission -> 403', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run belonging to a DIFFERENT stack -> 404, nothing deleted', async () => {
|
||||||
|
execStore.set(1, { ...RUN, entityName: 'other-stack' });
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(deletedIds).toEqual([]);
|
||||||
|
expect(execStore.has(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the DB record AND the log file', async () => {
|
||||||
|
execStore.set(1, RUN); // RUN.environmentId is null
|
||||||
|
await appendRunLog(null, '1', 'some deploy output');
|
||||||
|
expect(await readRunLog(null, '1')).toBe('some deploy output');
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).success).toBe(true);
|
||||||
|
expect(deletedIds).toEqual([1]);
|
||||||
|
expect(execStore.has(1)).toBe(false);
|
||||||
|
expect(await readRunLog(null, '1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a terminal, non-success status (failed) is still deletable', async () => {
|
||||||
|
execStore.set(1, { ...RUN, status: 'failed' });
|
||||||
|
await appendRunLog(null, '1', 'compose exploded');
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).success).toBe(true);
|
||||||
|
expect(deletedIds).toEqual([1]);
|
||||||
|
expect(execStore.has(1)).toBe(false);
|
||||||
|
expect(await readRunLog(null, '1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finding B: DELETE had no status check at all -- a caller could delete
|
||||||
|
// their OWN still-running deploy's record. The background deploy process
|
||||||
|
// keeps writing to the log file after the record is gone (appendRunLog
|
||||||
|
// recreates it), updateScheduleExecution() on end() becomes a silent
|
||||||
|
// no-op (UPDATE WHERE id=x, 0 rows), and the default-enabled reconcile
|
||||||
|
// job then purges the recreated file as an orphan -- erasing every trace
|
||||||
|
// of a deploy that actually ran and had real host side effects.
|
||||||
|
test('Finding B: a RUNNING run cannot be deleted -> 409, nothing touched', async () => {
|
||||||
|
execStore.set(1, { ...RUN, status: 'running' });
|
||||||
|
await appendRunLog(null, '1', 'still going...');
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect((await res.json()).error).toBe('Cannot delete a running deploy run');
|
||||||
|
expect(deletedIds).toEqual([]);
|
||||||
|
expect(execStore.has(1)).toBe(true);
|
||||||
|
expect(await readRunLog(null, '1')).toBe('still going...');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Finding B: a QUEUED run cannot be deleted -> 409, nothing touched', async () => {
|
||||||
|
execStore.set(1, { ...RUN, status: 'queued' });
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(deletedIds).toEqual([]);
|
||||||
|
expect(execStore.has(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fail-safe direction: a status this repo's ScheduleStatus type doesn't
|
||||||
|
// even list (never actually produced by this code, but the column is
|
||||||
|
// free text -- see db.ts's ScheduleStatus comment) must NOT be treated as
|
||||||
|
// terminal just because it isn't 'queued'/'running'. Guessing "finished"
|
||||||
|
// for an unrecognized status is exactly the kind of assumption that lets
|
||||||
|
// a genuinely in-progress run slip through under a status this code
|
||||||
|
// hasn't been taught about yet.
|
||||||
|
test('Finding B: an unrecognized status is treated as NOT terminal (fail safe) -> 409', async () => {
|
||||||
|
execStore.set(1, { ...RUN, status: 'some-future-status' });
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(deletedIds).toEqual([]);
|
||||||
|
expect(execStore.has(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Finding B: the 404 (wrong stack) still wins over the running-run 409 -- ownership is checked first', async () => {
|
||||||
|
execStore.set(1, { ...RUN, status: 'running', entityName: 'other-stack' });
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe('Deploy run not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Finding B: the 403 (permission denied) still wins over the running-run 409', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
execStore.set(1, { ...RUN, status: 'running' });
|
||||||
|
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GET /api/stacks/[name]/deploys/[runId]/log
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('GET /api/stacks/[name]/deploys/[runId]/log', () => {
|
||||||
|
test('no session -> 401 at the permission check, not a later error', async () => {
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()).error).toBe('Authentication required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated but lacking permission -> 403', async () => {
|
||||||
|
authState.can = false;
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run belonging to a DIFFERENT stack -> 404, log file never touched', async () => {
|
||||||
|
execStore.set(1, { ...RUN, entityName: 'other-stack' }); // still environmentId: null
|
||||||
|
await appendRunLog(null, '1', 'secret content for the other stack');
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe('Deploy run not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('run exists but the log file does not -> 404, does not wrongly look safe', async () => {
|
||||||
|
execStore.set(1, RUN);
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe('Log not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-environment bypass applies here too', async () => {
|
||||||
|
authState.isEnterprise = true;
|
||||||
|
authState.accessibleEnvs = [1];
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
await appendRunLog(9, '1', 'should not be reachable');
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the log text as text/plain', async () => {
|
||||||
|
execStore.set(1, RUN); // RUN.environmentId is null
|
||||||
|
await appendRunLog(null, '1', 'line one\nline two');
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('text/plain');
|
||||||
|
expect(await res.text()).toBe('line one\nline two');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// H1 regression: env-scoped permission on the three single-run routes
|
||||||
|
//
|
||||||
|
// Reproduces the branch-review scenario: a caller has stacks:view/edit for
|
||||||
|
// SOME environment (which alone is enough to pass the coarse, un-scoped
|
||||||
|
// `can('stacks', 'view'|'edit')` gate every route still runs first -- see
|
||||||
|
// "authenticated but lacking permission -> 403" above) but NOT for the
|
||||||
|
// environment the run under test actually belongs to. Before the fix, none
|
||||||
|
// of the three routes checked permission against run.environmentId at all,
|
||||||
|
// so the coarse gate's global OR-across-roles merge was the only check --
|
||||||
|
// a caller scoped to environment 1 could read/delete a run that belongs to
|
||||||
|
// environment 9.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe('H1: permission is scoped to the RUN\'s own environment, not the global merge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
authState.isEnterprise = true;
|
||||||
|
authState.accessibleEnvs = 'all'; // isolate this from the SEPARATE canAccessEnvironment check (already correct, see "kein Befund")
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET single run: caller has stacks:view broadly (passes the coarse gate) but NOT for env 9, and the run belongs to env 9 -> 403, not the 200 the pre-fix global merge produced', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId !== 9; // granted everywhere EXCEPT env 9 -- still passes the coarse can('stacks','view') gate (called with environmentId=undefined), so this isolates the NEW scoped check
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect((await res.json()).error).toBe('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET single run: caller has stacks:view for the run\'s OWN env -> 200', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId === undefined || envId === 9; // undefined: satisfies the coarse gate; 9: satisfies the scoped check
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE: caller has stacks:edit broadly but NOT for env 9, and the run belongs to env 9 -> 403, nothing deleted', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId !== 9;
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(deletedIds).toEqual([]);
|
||||||
|
expect(execStore.has(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE: caller has stacks:edit for the run\'s OWN env -> 200, both halves deleted', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId === undefined || envId === 9;
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
await appendRunLog(9, '1', 'output');
|
||||||
|
const res = await runRoute.DELETE(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(deletedIds).toEqual([1]);
|
||||||
|
expect(await readRunLog(9, '1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET log: caller has stacks:view broadly but NOT for env 9, and the run belongs to env 9 -> 403, log file never read', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId !== 9;
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
await appendRunLog(9, '1', 'env-9-only secret output');
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET log: caller has stacks:view for the run\'s OWN env -> 200 with the log text', async () => {
|
||||||
|
authState.canByEnv = (envId) => envId === undefined || envId === 9;
|
||||||
|
execStore.set(1, { ...RUN, environmentId: 9 });
|
||||||
|
await appendRunLog(9, '1', 'env-9 output');
|
||||||
|
const res = await logRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toBe('env-9 output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with NO environment (local install, environmentId null) checks against the global merge, not against a specific env', async () => {
|
||||||
|
// null run.environmentId maps to `can(..., undefined)` (the `?? undefined`
|
||||||
|
// in the fix) -- there is no per-environment role to scope against for a
|
||||||
|
// run that was never attributed to one. canByEnv(undefined) is the same
|
||||||
|
// check the coarse gate above already performs, so this is really
|
||||||
|
// confirming the scoped check does not ITSELF reject a local run once the
|
||||||
|
// coarse gate already let it through.
|
||||||
|
authState.canByEnv = (envId) => envId === undefined;
|
||||||
|
execStore.set(1, { ...RUN, environmentId: null });
|
||||||
|
const res = await runRoute.GET(makeEvent());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* Tests for runDeployLogReconcileJob() itself -- the database/filesystem-touching glue
|
||||||
|
* around planReconcile() (see deploy-log-reconcile.test.ts for the pure decision core).
|
||||||
|
*
|
||||||
|
* $lib/server/db transitively loads better-sqlite3 (ERR_DLOPEN_FAILED under Bun), so it
|
||||||
|
* has to be faked via ../helpers/db-fake (registerDbFake), same as
|
||||||
|
* tests/deploy-endpoints.test.ts -- NOT via a separate mock.module('$lib/server/db', ...)
|
||||||
|
* call here, see that helper's doc comment for why a second direct call collides.
|
||||||
|
*
|
||||||
|
* The three deploy-log-store calls are INJECTED (runDeployLogReconcileJob's `deps` param)
|
||||||
|
* rather than mock.module()'d. An earlier version did mock the module, reasoning that no
|
||||||
|
* other test file claimed it -- but the collision that matters is with SOURCE modules, not
|
||||||
|
* test files: mock.module() freezes the export shape process-wide, and deploy-run-record.ts
|
||||||
|
* imports appendRunLog/runLogFileName/SizeBudgetTracker from the same specifier. Whether it
|
||||||
|
* blew up depended on the order bun walks tests/, so it was green locally and red in CI.
|
||||||
|
* Injection has no process-wide effect at all.
|
||||||
|
*
|
||||||
|
* F5 fix: the fake filesystem layer below is keyed PER ENVIRONMENT (envDirName(envId) ->
|
||||||
|
* fileIds[]), mirroring deploy-log-store.ts's real per-environment directories -- this is
|
||||||
|
* what lets the tests below prove the reconcile job never mixes one environment's files
|
||||||
|
* with another's, even when two environments happen to hold a file with the exact same
|
||||||
|
* numeric run id (envDirName/parseEnvDirName are imported for real -- pure functions, no
|
||||||
|
* filesystem -- purely to build the fake's keys the same way production code would).
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import { registerDbFake } from './helpers/db-fake';
|
||||||
|
import { envDirName } from '../src/lib/server/deploy-log-store';
|
||||||
|
|
||||||
|
// -- $lib/server/db: an in-memory schedule_executions fake -----------------
|
||||||
|
|
||||||
|
let nextExecId: number;
|
||||||
|
let executions: Map<number, any>;
|
||||||
|
let stackDeployRecords: Array<{ id: number; status: string; details: any; environmentId: number | null }>;
|
||||||
|
let throwOnUpdate: Set<number>;
|
||||||
|
|
||||||
|
function resetDbState() {
|
||||||
|
nextExecId = 1;
|
||||||
|
executions = new Map();
|
||||||
|
stackDeployRecords = [];
|
||||||
|
throwOnUpdate = new Set();
|
||||||
|
}
|
||||||
|
resetDbState();
|
||||||
|
|
||||||
|
registerDbFake('getDeployLogReconcileEnabled', async () => true);
|
||||||
|
registerDbFake('getScheduleExecutionIdsByType', async (scheduleType: string) =>
|
||||||
|
scheduleType === 'stack_deploy' ? stackDeployRecords : []
|
||||||
|
);
|
||||||
|
registerDbFake('createScheduleExecution', async (data: any) => {
|
||||||
|
const id = nextExecId++;
|
||||||
|
const row = { id, ...data, details: data.details ?? null };
|
||||||
|
executions.set(id, row);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
registerDbFake('updateScheduleExecution', async (id: number, data: any) => {
|
||||||
|
if (throwOnUpdate.has(id)) throw new Error(`update failed for record ${id}`);
|
||||||
|
const row = executions.get(id) ?? { id };
|
||||||
|
const updated = { ...row, ...data };
|
||||||
|
executions.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
registerDbFake('appendScheduleExecutionLog', async () => {});
|
||||||
|
|
||||||
|
// -- $lib/server/deploy-log-store: fully replaced (no other file mocks it) -
|
||||||
|
//
|
||||||
|
// Keyed by envDirName(envId) -> fileIds[], the same per-environment shape
|
||||||
|
// deploy-log-store.ts's real listRunLogIds()/deleteRunLog() work against (F5 fix).
|
||||||
|
|
||||||
|
let filesByEnvKey: Map<string, string[]>;
|
||||||
|
let deletedFiles: Array<{ envId: number | null; id: string }>;
|
||||||
|
let throwOnDeleteKeys: Set<string>; // `${envDirName(envId)}:${fileId}`
|
||||||
|
// mtime (ms since epoch) per delete candidate, keyed the same way as throwOnDeleteKeys.
|
||||||
|
// Defaults (see getRunLogMtimeMs below) to something WELL outside the reconcile job's
|
||||||
|
// grace period, so every existing "orphan gets deleted" assertion in this file keeps
|
||||||
|
// meaning exactly what it always did -- only a test that explicitly seeds a RECENT
|
||||||
|
// mtime here is exercising the TOCTOU guard at all.
|
||||||
|
let mtimeMsByKey: Map<string, number>;
|
||||||
|
|
||||||
|
function resetFsState() {
|
||||||
|
filesByEnvKey = new Map();
|
||||||
|
deletedFiles = [];
|
||||||
|
throwOnDeleteKeys = new Set();
|
||||||
|
mtimeMsByKey = new Map();
|
||||||
|
}
|
||||||
|
resetFsState();
|
||||||
|
|
||||||
|
const storeDeps = {
|
||||||
|
listEnvDirNames: async () => [...filesByEnvKey.keys()],
|
||||||
|
listRunLogIds: async (envId: number | null) => filesByEnvKey.get(envDirName(envId)) ?? [],
|
||||||
|
deleteRunLog: async (envId: number | null, fileId: string) => {
|
||||||
|
const key = `${envDirName(envId)}:${fileId}`;
|
||||||
|
if (throwOnDeleteKeys.has(key)) throw new Error(`delete failed for ${key}`);
|
||||||
|
deletedFiles.push({ envId, id: fileId });
|
||||||
|
},
|
||||||
|
getRunLogMtimeMs: async (envId: number | null, fileId: string) => {
|
||||||
|
const key = `${envDirName(envId)}:${fileId}`;
|
||||||
|
return mtimeMsByKey.get(key) ?? Date.now() - 24 * 60 * 60 * 1000; // default: a day old
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// runDeployLogReconcileJob is imported AFTER both mocks are registered above, so its own
|
||||||
|
// import of '../../db' / '../../deploy-log-store' resolves to the faked module records.
|
||||||
|
const { runDeployLogReconcileJob } = await import('../src/lib/server/scheduler/tasks/deploy-log-reconcile');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetDbState();
|
||||||
|
resetFsState();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The reconcile job's own execution row -- the one created inside runDeployLogReconcileJob. */
|
||||||
|
function ownExecution(): any {
|
||||||
|
return [...executions.values()].find((e) => e.scheduleType === 'deploy_log_reconcile');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runDeployLogReconcileJob', () => {
|
||||||
|
test('one failing delete does not stop later orphan files from being removed', async () => {
|
||||||
|
// All three are orphans: no stack_deploy record exists for any of them, all
|
||||||
|
// in the SAME (null) environment.
|
||||||
|
filesByEnvKey.set(envDirName(null), ['a', 'b', 'c']);
|
||||||
|
throwOnDeleteKeys = new Set([`${envDirName(null)}:b`]);
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
// The one AFTER the failing element must still have been reached.
|
||||||
|
expect(deletedFiles).toEqual([
|
||||||
|
{ envId: null, id: 'a' },
|
||||||
|
{ envId: null, id: 'c' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exec = ownExecution();
|
||||||
|
// Real outcome (2), not the planned one (3) -- the point of Befund 2.
|
||||||
|
expect(exec.details.deletedFiles).toBe(2);
|
||||||
|
expect(exec.details.failed).toBe(1);
|
||||||
|
// A run that hit a per-element failure is not indistinguishable from a clean one.
|
||||||
|
expect(exec.status).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one failing mark does not stop later records from being marked logMissing', async () => {
|
||||||
|
// No files on disk at all -- every record below is missing its file.
|
||||||
|
stackDeployRecords = [
|
||||||
|
{ id: 10, status: 'success', details: null, environmentId: null },
|
||||||
|
{ id: 11, status: 'success', details: null, environmentId: null },
|
||||||
|
{ id: 12, status: 'success', details: null, environmentId: null }
|
||||||
|
];
|
||||||
|
for (const r of stackDeployRecords) executions.set(r.id, { ...r });
|
||||||
|
throwOnUpdate = new Set([11]);
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(executions.get(10).details.logMissing).toBe(true);
|
||||||
|
expect(executions.get(12).details.logMissing).toBe(true);
|
||||||
|
// The failing record's details were never reached, so they stay as seeded.
|
||||||
|
expect(executions.get(11).details).toBe(null);
|
||||||
|
|
||||||
|
const exec = ownExecution();
|
||||||
|
expect(exec.details.markedRecords).toBe(2);
|
||||||
|
expect(exec.details.failed).toBe(1);
|
||||||
|
expect(exec.status).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clean run with nothing to fail is reported as success, not warning', async () => {
|
||||||
|
filesByEnvKey.set(envDirName(null), ['a']);
|
||||||
|
stackDeployRecords = [{ id: 20, status: 'success', details: null, environmentId: null }];
|
||||||
|
executions.set(20, { id: 20, status: 'success', details: null });
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
const exec = ownExecution();
|
||||||
|
expect(exec.status).toBe('success');
|
||||||
|
expect(exec.details.failed).toBe(0);
|
||||||
|
// 'a' has no record and gets deleted; the id-20 record has no file and gets marked.
|
||||||
|
expect(exec.details.deletedFiles).toBe(1);
|
||||||
|
expect(exec.details.markedRecords).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// F5 fix: per-environment reconciliation. THE cross-environment isolation
|
||||||
|
// tests -- every id below is deliberately reused ACROSS environments to
|
||||||
|
// prove the reconcile job never confuses one environment's file/record with
|
||||||
|
// another's just because they share a run id.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("env A's orphan cleanup never deletes env B's file, even when they share a run id and env B's file legitimately belongs to a record", async () => {
|
||||||
|
filesByEnvKey.set(envDirName(1), ['42']); // orphan in env 1: no record anywhere for id 42 in env 1
|
||||||
|
filesByEnvKey.set(envDirName(2), ['42']); // env 2's OWN file for its OWN record with the same numeric id
|
||||||
|
stackDeployRecords = [{ id: 42, status: 'success', details: null, environmentId: 2 }];
|
||||||
|
executions.set(42, { id: 42, status: 'success', details: null });
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
// Env 1's orphan is deleted...
|
||||||
|
expect(deletedFiles).toEqual([{ envId: 1, id: '42' }]);
|
||||||
|
// ...but env 2's file, which legitimately belongs to record 42, is NOT
|
||||||
|
// treated as missing (it has a matching file in ITS OWN environment) and
|
||||||
|
// is never touched by the delete pass either.
|
||||||
|
expect(executions.get(42).details).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an orphan file living under one environment is never confused with a DIFFERENT environment holding the same run id', async () => {
|
||||||
|
// Both environments have a file with the SAME id and NEITHER has any record
|
||||||
|
// at all -- both are genuinely orphans, each in its own right.
|
||||||
|
filesByEnvKey.set(envDirName(1), ['shared-id']);
|
||||||
|
filesByEnvKey.set(envDirName(2), ['shared-id']);
|
||||||
|
stackDeployRecords = [];
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(deletedFiles).toContainEqual({ envId: 1, id: 'shared-id' });
|
||||||
|
expect(deletedFiles).toContainEqual({ envId: 2, id: 'shared-id' });
|
||||||
|
expect(deletedFiles).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a record's own file, missing from its OWN environment, is correctly marked logMissing even when an unrelated environment holds a same-numbered file", async () => {
|
||||||
|
// Record id 501 deliberately avoids id 1 -- this fake's createScheduleExecution
|
||||||
|
// always assigns the reconcile job's OWN execution row id 1 first (nextExecId
|
||||||
|
// starts at 1 every test), which would otherwise clobber a manually seeded
|
||||||
|
// `executions.set(1, ...)` entry for an unrelated stack_deploy record.
|
||||||
|
filesByEnvKey.set(envDirName(2), ['501']); // orphan file living in env 2, wrong environment for record id 501
|
||||||
|
stackDeployRecords = [{ id: 501, status: 'success', details: null, environmentId: 1 }];
|
||||||
|
executions.set(501, { id: 501, status: 'success', details: null });
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
// The record belongs to env 1, which has NO file -- correctly marked
|
||||||
|
// missing, not "found" via env 2's unrelated same-numbered file.
|
||||||
|
expect(executions.get(501).details.logMissing).toBe(true);
|
||||||
|
// Env 2's file has no record in env 2 either -- it is correctly deleted as
|
||||||
|
// its OWN environment's orphan, not because it was mistaken for record 501's.
|
||||||
|
expect(deletedFiles).toEqual([{ envId: 2, id: '501' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a still-running record in one environment is not marked missing, while a DIFFERENT environment\'s terminal record with the same id correctly is', async () => {
|
||||||
|
// No files anywhere -- both records are "missing their file" by definition,
|
||||||
|
// but only the terminal one should actually be marked.
|
||||||
|
stackDeployRecords = [
|
||||||
|
{ id: 5, status: 'running', details: null, environmentId: 1 },
|
||||||
|
{ id: 5, status: 'success', details: null, environmentId: 2 }
|
||||||
|
];
|
||||||
|
executions.set(5, { id: 5, status: 'running', details: null }); // last-write-wins in this fake, matches env 2's terminal status for the assertion below
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
const exec = ownExecution();
|
||||||
|
// One skipped (still running, env 1), one marked (terminal, env 2).
|
||||||
|
expect(exec.details.skippedInProgress).toBe(1);
|
||||||
|
expect(exec.details.markedRecords).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an environment that only has a directory on disk (no records at all, e.g. a deleted environment) still has its orphan files reconciled', async () => {
|
||||||
|
filesByEnvKey.set(envDirName(7), ['old-run']);
|
||||||
|
stackDeployRecords = []; // environment 7 has no records in the database at all
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(deletedFiles).toEqual([{ envId: 7, id: 'old-run' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an environment that only has a record (no directory on disk yet) still gets that record considered for markMissing', async () => {
|
||||||
|
// listEnvDirNames() would never discover environment 3 on its own -- it has
|
||||||
|
// no directory at all -- so the record-side union is what makes this work.
|
||||||
|
stackDeployRecords = [{ id: 99, status: 'success', details: null, environmentId: 3 }];
|
||||||
|
executions.set(99, { id: 99, status: 'success', details: null });
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(executions.get(99).details.logMissing).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// TOCTOU guard: `records` is a single snapshot taken at the start of the job
|
||||||
|
// (getScheduleExecutionIdsByType), while a NEW deploy's file can appear on disk
|
||||||
|
// (listRunLogIds) moments later, for a run whose own record simply postdates
|
||||||
|
// that snapshot -- indistinguishable from a genuine orphan by id alone. See the
|
||||||
|
// module doc comment for the full race.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("a file with no matching record but a RECENT mtime is left alone -- it may belong to a deploy that started after the records snapshot", async () => {
|
||||||
|
filesByEnvKey.set(envDirName(null), ['fresh']);
|
||||||
|
// No record for 'fresh' in `stackDeployRecords` at all -- exactly what an
|
||||||
|
// in-flight deploy whose row postdates the snapshot looks like.
|
||||||
|
mtimeMsByKey.set(`${envDirName(null)}:fresh`, Date.now() - 1000); // 1s old
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(deletedFiles).toEqual([]);
|
||||||
|
const exec = ownExecution();
|
||||||
|
expect(exec.details.skippedRecent).toBe(1);
|
||||||
|
expect(exec.details.deletedFiles).toBe(0);
|
||||||
|
// A recency skip is not a failure -- the run still reports success.
|
||||||
|
expect(exec.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a genuinely old orphan file (no record, no recent write) is still deleted -- the grace period does not protect real orphans forever', async () => {
|
||||||
|
filesByEnvKey.set(envDirName(null), ['stale']);
|
||||||
|
mtimeMsByKey.set(`${envDirName(null)}:stale`, Date.now() - 60 * 60 * 1000); // 1h old
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(deletedFiles).toEqual([{ envId: null, id: 'stale' }]);
|
||||||
|
const exec = ownExecution();
|
||||||
|
expect(exec.details.skippedRecent).toBe(0);
|
||||||
|
expect(exec.details.deletedFiles).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a recency skip in one environment does not stop a genuinely old orphan in ANOTHER environment from being deleted', async () => {
|
||||||
|
filesByEnvKey.set(envDirName(1), ['fresh-in-env-1']);
|
||||||
|
mtimeMsByKey.set(`${envDirName(1)}:fresh-in-env-1`, Date.now());
|
||||||
|
filesByEnvKey.set(envDirName(2), ['stale-in-env-2']);
|
||||||
|
mtimeMsByKey.set(`${envDirName(2)}:stale-in-env-2`, Date.now() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await runDeployLogReconcileJob('manual', storeDeps);
|
||||||
|
|
||||||
|
expect(deletedFiles).toEqual([{ envId: 2, id: 'stale-in-env-2' }]);
|
||||||
|
const exec = ownExecution();
|
||||||
|
expect(exec.details.skippedRecent).toBe(1);
|
||||||
|
expect(exec.details.deletedFiles).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { planReconcile, isEligibleForMissingMark } from '../src/lib/server/deploy-log-reconcile-core';
|
||||||
|
|
||||||
|
describe('planReconcile', () => {
|
||||||
|
test('deletes a file whose run record is gone', () => {
|
||||||
|
const plan = planReconcile({ fileIds: ['a', 'b'], recordIds: ['a'] });
|
||||||
|
expect(plan.deleteFiles).toEqual(['b']);
|
||||||
|
expect(plan.markMissing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks a record whose file is gone but never deletes the record', () => {
|
||||||
|
// The metadata -- when, who, what was built, which digest -- stays valuable
|
||||||
|
// even when the log itself is gone.
|
||||||
|
const plan = planReconcile({ fileIds: [], recordIds: ['a'] });
|
||||||
|
expect(plan.markMissing).toEqual(['a']);
|
||||||
|
expect(plan.deleteFiles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves matching pairs alone', () => {
|
||||||
|
const plan = planReconcile({ fileIds: ['a'], recordIds: ['a'] });
|
||||||
|
expect(plan.deleteFiles).toEqual([]);
|
||||||
|
expect(plan.markMissing).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEligibleForMissingMark', () => {
|
||||||
|
// A still-running deploy may simply not have written its first log line yet --
|
||||||
|
// marking it here would produce a false "log missing" that nothing later clears.
|
||||||
|
test('excludes still in-progress runs', () => {
|
||||||
|
expect(isEligibleForMissingMark('queued')).toBe(false);
|
||||||
|
expect(isEligibleForMissingMark('running')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes every terminal state', () => {
|
||||||
|
for (const status of ['success', 'failed', 'skipped', 'warning', 'error', 'cancelled', 'stale']) {
|
||||||
|
expect(isEligibleForMissingMark(status)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
runLogFileName,
|
||||||
|
envDirName,
|
||||||
|
parseEnvDirName,
|
||||||
|
SizeBudgetTracker,
|
||||||
|
usedBytesExcluding
|
||||||
|
} from '../src/lib/server/deploy-log-store';
|
||||||
|
|
||||||
|
describe('runLogFileName', () => {
|
||||||
|
test('builds a name from a plain run id', () => {
|
||||||
|
expect(runLogFileName('a1b2c3')).toBe('a1b2c3.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects anything that is not a plain id, so a run id can never become a path', () => {
|
||||||
|
// The run id reaches this function from a route parameter. A traversal attempt must
|
||||||
|
// throw rather than resolve to a file outside the log directory.
|
||||||
|
expect(() => runLogFileName('../../etc/passwd')).toThrow();
|
||||||
|
expect(() => runLogFileName('a/b')).toThrow();
|
||||||
|
expect(() => runLogFileName('')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('envDirName / parseEnvDirName -- F5 per-environment directory naming', () => {
|
||||||
|
test('a numeric environment id becomes its own string, an environment-less run becomes "null"', () => {
|
||||||
|
expect(envDirName(1)).toBe('1');
|
||||||
|
expect(envDirName(42)).toBe('42');
|
||||||
|
expect(envDirName(null)).toBe('null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a non-integer environment id rather than silently stringifying it', () => {
|
||||||
|
// environmentId always comes from a database column (number | null) -- this is a
|
||||||
|
// defensive backstop, the same posture runLogFileName's ID regex takes for runId.
|
||||||
|
expect(() => envDirName(1.5)).toThrow();
|
||||||
|
expect(() => envDirName(NaN)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEnvDirName is the exact inverse of envDirName for every value envDirName can produce', () => {
|
||||||
|
expect(parseEnvDirName(envDirName(1))).toBe(1);
|
||||||
|
expect(parseEnvDirName(envDirName(42))).toBe(42);
|
||||||
|
expect(parseEnvDirName(envDirName(null))).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEnvDirName rejects a directory name that is not one of envDirName\'s own outputs', () => {
|
||||||
|
// listEnvDirNames() (deploy-log-store.ts) only returns directories, so this should
|
||||||
|
// not normally see a stray file name -- but a hand-created or unexpected directory
|
||||||
|
// must not be silently misread as some arbitrary environment id.
|
||||||
|
expect(() => parseEnvDirName('not-an-env')).toThrow();
|
||||||
|
expect(() => parseEnvDirName('-1')).toThrow();
|
||||||
|
expect(() => parseEnvDirName('1.5')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SizeBudgetTracker -- see deploy-log-store.ts's doc comment on the class for
|
||||||
|
// the performance problem this replaces (a full directory re-scan on every
|
||||||
|
// appended line) and the concurrency trade-off it makes instead. The F5 fix
|
||||||
|
// (per-environment directory/budget scoping) is covered by its own describe
|
||||||
|
// block further down.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// deploy-log-store.ts reads process.env.DATA_DIR on every call (logDir()), not at
|
||||||
|
// import time -- so it's enough to point it at a fresh scratch dir per test and put
|
||||||
|
// the previous value back afterwards. Bun runs a whole test file in ONE process: a
|
||||||
|
// DATA_DIR left set would leak into unrelated suites (see
|
||||||
|
// test-coverage-pflicht.md's "prozessweite Zustände werden geklammert" -- this is
|
||||||
|
// exactly the trap fs-guard.test.ts fell into once already).
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
let scratchDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
scratchDir = await mkdtemp(join(tmpdir(), 'deploy-log-store-'));
|
||||||
|
process.env.DATA_DIR = scratchDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = previousDataDir;
|
||||||
|
await rm(scratchDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Writes N other-run log files of `sizeEach` bytes each directly into ONE
|
||||||
|
* environment's scratch deploy-logs/<envDirName>/ dir, bypassing appendRunLog so the
|
||||||
|
* byte count is exact and no SizeBudgetTracker is involved in creating the fixture. */
|
||||||
|
async function seedOtherRunFiles(envId: number | null, count: number, sizeEach: number): Promise<void> {
|
||||||
|
const dir = join(scratchDir, 'deploy-logs', envDirName(envId));
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await writeFile(join(dir, `other-${i}.log`), 'x'.repeat(sizeEach));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SizeBudgetTracker', () => {
|
||||||
|
test('does NOT flag a run that stays well under the budget', async () => {
|
||||||
|
await seedOtherRunFiles(null, 5, 1000); // 5 KB of "other" runs already on disk
|
||||||
|
const tracker = new SizeBudgetTracker(null, 'run-under', 1_000_000); // 1 MB budget
|
||||||
|
let exceeded = false;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
exceeded = await tracker.recordAppend(100); // 20 * 100 B = 2 KB of own output
|
||||||
|
}
|
||||||
|
expect(exceeded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags a run once its own output plus the rest of the directory crosses the budget', async () => {
|
||||||
|
await seedOtherRunFiles(null, 3, 300); // 900 B of "other" runs already on disk
|
||||||
|
const tracker = new SizeBudgetTracker(null, 'run-over', 1000); // 1000 B budget
|
||||||
|
let exceeded = false;
|
||||||
|
// 900 B baseline + 10*100 B own = 1900 B, well past the 1000 B budget.
|
||||||
|
for (let i = 0; i < 10 && !exceeded; i++) {
|
||||||
|
exceeded = await tracker.recordAppend(100);
|
||||||
|
}
|
||||||
|
expect(exceeded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gegenversuch (test-coverage-pflicht.md): a tracker that never re-reads the
|
||||||
|
// directory -- cachedOtherBytes stuck at 0 forever, e.g. because refresh() was
|
||||||
|
// never called -- would let a run with a large pre-existing "other" total sail
|
||||||
|
// straight past the budget using only its own (small) byte count. This is the
|
||||||
|
// mutation the previous test guards against; this test isolates the SAME
|
||||||
|
// mechanism from a different angle (pre-existing total alone is already over
|
||||||
|
// budget, before this run writes a single byte).
|
||||||
|
test('flags a run immediately if the directory is already over budget before this run writes anything', async () => {
|
||||||
|
await seedOtherRunFiles(null, 2, 600); // 1200 B of "other" runs, already over a 1000 B budget
|
||||||
|
const tracker = new SizeBudgetTracker(null, 'run-preexisting', 1000);
|
||||||
|
const exceeded = await tracker.recordAppend(1); // first byte this run ever writes
|
||||||
|
expect(exceeded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-scans the directory on the FIRST call, then NOT again until the refresh threshold is crossed', async () => {
|
||||||
|
await seedOtherRunFiles(null, 500, 10); // matches the reviewer's 500-file benchmark shape
|
||||||
|
let scans = 0;
|
||||||
|
const countingMeasure = async (envId: number | null, excludeRunId: string) => {
|
||||||
|
scans++;
|
||||||
|
return usedBytesExcluding(envId, excludeRunId);
|
||||||
|
};
|
||||||
|
// Refresh threshold set to 1000 B of own output for this test, independent of
|
||||||
|
// the 1 MiB production constant -- REFRESH_AFTER_OWN_BYTES is not exported, so
|
||||||
|
// this drives the same code path (loaded flag + threshold check) at a size
|
||||||
|
// this test can actually cross without allocating a real MiB of strings.
|
||||||
|
const tracker = new SizeBudgetTracker(null, 'run-scan-count', 10_000_000, countingMeasure);
|
||||||
|
|
||||||
|
// 200 appends of 10 B each = 2000 B of own output, matching the reviewer's
|
||||||
|
// 200-line benchmark. This is the exact scenario that used to cost one full
|
||||||
|
// directory scan PER call (200 scans of 500 files); a working tracker must
|
||||||
|
// not do that.
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await tracker.recordAppend(10);
|
||||||
|
}
|
||||||
|
expect(scans).toBe(1); // one lazy scan on the first call, none after
|
||||||
|
expect(scans).toBeLessThan(200); // the actual regression this fixes
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-scans again once accumulated own bytes cross the refresh threshold', async () => {
|
||||||
|
await seedOtherRunFiles(null, 5, 10);
|
||||||
|
let scans = 0;
|
||||||
|
const countingMeasure = async (envId: number | null, excludeRunId: string) => {
|
||||||
|
scans++;
|
||||||
|
return usedBytesExcluding(envId, excludeRunId);
|
||||||
|
};
|
||||||
|
// A tiny budget just to construct the tracker; refresh cadence is driven by
|
||||||
|
// the module-private REFRESH_AFTER_OWN_BYTES (1 MiB in production), not by
|
||||||
|
// this budget value.
|
||||||
|
const tracker = new SizeBudgetTracker(null, 'run-refresh', 100_000_000, countingMeasure);
|
||||||
|
|
||||||
|
await tracker.recordAppend(10); // scan #1 (lazy load)
|
||||||
|
expect(scans).toBe(1);
|
||||||
|
|
||||||
|
// Push well past 1 MiB of own output in one shot -- the tracker must re-scan
|
||||||
|
// rather than trust a MiB-stale cache indefinitely.
|
||||||
|
await tracker.recordAppend(2 * 1024 * 1024);
|
||||||
|
expect(scans).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent runs are invisible to each other between refreshes -- documented trade-off, not a bug', async () => {
|
||||||
|
// Two trackers for two DIFFERENT runs sharing the same directory. Tracker A's
|
||||||
|
// baseline is measured before B writes anything; B's own bytes are then
|
||||||
|
// invisible to A until A refreshes again. This is exactly the imprecision
|
||||||
|
// SizeBudgetTracker's doc comment describes -- this test pins the size of
|
||||||
|
// that gap so a future change can't silently widen it without this test
|
||||||
|
// noticing.
|
||||||
|
const trackerA = new SizeBudgetTracker(null, 'run-a', 10_000_000);
|
||||||
|
|
||||||
|
await trackerA.recordAppend(100); // A's baseline measured now: directory is empty
|
||||||
|
|
||||||
|
// B writes 50 KB of real output AFTER A's baseline was taken, into the SAME
|
||||||
|
// environment (null) as A -- this test is about same-environment concurrency,
|
||||||
|
// not cross-environment isolation (see the F5 describe block below for that).
|
||||||
|
const bDir = join(scratchDir, 'deploy-logs', envDirName(null));
|
||||||
|
await mkdir(bDir, { recursive: true });
|
||||||
|
await writeFile(join(bDir, 'run-b.log'), 'y'.repeat(50_000));
|
||||||
|
|
||||||
|
// A has not crossed its own refresh threshold (well under 1 MiB of its own
|
||||||
|
// output), so its cached view of "everything else" still does not include
|
||||||
|
// B's 50 KB -- that is the documented gap, reproduced here.
|
||||||
|
const stillBlind = await trackerA.recordAppend(100);
|
||||||
|
expect(stillBlind).toBe(false);
|
||||||
|
|
||||||
|
// A fresh tracker (or a refreshed one) DOES see it -- the gap is temporary,
|
||||||
|
// not a permanent miss.
|
||||||
|
const freshView = await usedBytesExcluding(null, 'run-a');
|
||||||
|
expect(freshView).toBeGreaterThanOrEqual(50_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F5 fix: per-environment directory/budget scoping. THE key regression test --
|
||||||
|
// see F5-REPORT.md for the counter-test run against this block with the fix
|
||||||
|
// reverted (envDirName() collapsed to a single shared directory).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('SizeBudgetTracker -- per-environment scoping (F5)', () => {
|
||||||
|
test('filling environment A\'s budget does not truncate environment B\'s run (separate directories) -- THE key F5 regression test', async () => {
|
||||||
|
// Environment A already has 950 B of ANOTHER run's output on disk -- right up
|
||||||
|
// against a 1000 B budget, entirely on its own, before environment A's own
|
||||||
|
// run under test has written a single byte. This is deliberately close to the
|
||||||
|
// budget by itself: under the pre-fix shared directory, these 950 B would be
|
||||||
|
// visible to EVERY environment's tracker, not just environment A's own.
|
||||||
|
await seedOtherRunFiles(1, 1, 950);
|
||||||
|
const trackerA = new SizeBudgetTracker(1, 'run-a', 1000);
|
||||||
|
// Environment A's own run pushes ITS OWN environment over budget, as expected.
|
||||||
|
const exceededA = await trackerA.recordAppend(100); // 950 + 100 = 1050 > 1000
|
||||||
|
expect(exceededA).toBe(true);
|
||||||
|
|
||||||
|
// Environment B has NOTHING of its own on disk and the SAME 1000 B budget.
|
||||||
|
// Its run writes a small, unrelated 100 B chunk -- nowhere near 1000 B on its
|
||||||
|
// own. Against the pre-fix SHARED directory/budget, environment A's 950 B
|
||||||
|
// fixture above would land in the exact same directory environment B's
|
||||||
|
// tracker scans, so B's tiny write would ALSO read as over budget (950 + 100
|
||||||
|
// = 1050 > 1000) even though environment B's run never came close to writing
|
||||||
|
// 1000 B itself -- exactly the silent cross-tenant truncation F5 fixes.
|
||||||
|
const trackerB = new SizeBudgetTracker(2, 'run-b', 1000);
|
||||||
|
const exceededB = await trackerB.recordAppend(100);
|
||||||
|
expect(exceededB).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two environments\' own files never appear in each other\'s usedBytesExcluding() total', async () => {
|
||||||
|
await seedOtherRunFiles(1, 3, 1000); // 3 KB in environment 1
|
||||||
|
await seedOtherRunFiles(2, 1, 50); // 50 B in environment 2
|
||||||
|
|
||||||
|
const totalForEnv1 = await usedBytesExcluding(1, 'irrelevant-run-id');
|
||||||
|
const totalForEnv2 = await usedBytesExcluding(2, 'irrelevant-run-id');
|
||||||
|
|
||||||
|
expect(totalForEnv1).toBe(3000);
|
||||||
|
expect(totalForEnv2).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run\'s own log file lives under its own environment\'s directory, and NOT under a different environment\'s directory with the same run id', async () => {
|
||||||
|
const { appendRunLog, runLogPath } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
await appendRunLog(1, 'shared-id', 'env-1 output\n');
|
||||||
|
await appendRunLog(2, 'shared-id', 'env-2 output\n');
|
||||||
|
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
expect(await readFile(runLogPath(1, 'shared-id'), 'utf8')).toBe('env-1 output\n');
|
||||||
|
expect(await readFile(runLogPath(2, 'shared-id'), 'utf8')).toBe('env-2 output\n');
|
||||||
|
expect(runLogPath(1, 'shared-id')).not.toBe(runLogPath(2, 'shared-id'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listEnvDirNames -- F5 environment discovery for the reconcile job', () => {
|
||||||
|
test('lists every environment directory currently on disk, and nothing else', async () => {
|
||||||
|
const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
await seedOtherRunFiles(1, 1, 10);
|
||||||
|
await seedOtherRunFiles(2, 1, 10);
|
||||||
|
await seedOtherRunFiles(null, 1, 10);
|
||||||
|
|
||||||
|
const names = (await listEnvDirNames()).sort();
|
||||||
|
expect(names).toEqual(['1', '2', 'null']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes a stray FILE directly under deploy-logs/ (the pre-F5 flat legacy layout)', async () => {
|
||||||
|
const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const dir = join(scratchDir, 'deploy-logs');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, 'legacy-run.log'), 'old flat-layout content');
|
||||||
|
await seedOtherRunFiles(1, 1, 10);
|
||||||
|
|
||||||
|
const names = await listEnvDirNames();
|
||||||
|
expect(names).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns an empty list when deploy-logs/ does not exist yet', async () => {
|
||||||
|
const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
expect(await listEnvDirNames()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readRunLog -- F5 back-compat fallback to the pre-F5 flat legacy path', () => {
|
||||||
|
test('finds a file at the new per-environment path first', async () => {
|
||||||
|
const { appendRunLog, readRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
await appendRunLog(1, 'r1', 'new layout content');
|
||||||
|
expect(await readRunLog(1, 'r1')).toBe('new layout content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the pre-F5 flat path when no per-environment file exists', async () => {
|
||||||
|
const { readRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const dir = join(scratchDir, 'deploy-logs');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, 'legacy.log'), 'old flat layout content');
|
||||||
|
|
||||||
|
expect(await readRunLog(1, 'legacy')).toBe('old flat layout content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with a file at NEITHER the per-environment nor the legacy path returns null', async () => {
|
||||||
|
const { readRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
expect(await readRunLog(1, 'nowhere')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteRunLog -- F5 clears both the per-environment AND the legacy flat path', () => {
|
||||||
|
test('removes a file at the new per-environment path', async () => {
|
||||||
|
const { appendRunLog, readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
await appendRunLog(1, 'r1', 'content');
|
||||||
|
await deleteRunLog(1, 'r1');
|
||||||
|
expect(await readRunLog(1, 'r1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('also removes a leftover legacy flat-path file for the same run id, so it cannot outlive its own deletion', async () => {
|
||||||
|
const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const dir = join(scratchDir, 'deploy-logs');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, 'legacy.log'), 'stale legacy content');
|
||||||
|
|
||||||
|
await deleteRunLog(1, 'legacy');
|
||||||
|
|
||||||
|
expect(await readRunLog(1, 'legacy')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a nonexistent run\'s log is a no-op, not an error', async () => {
|
||||||
|
const { deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
await expect(deleteRunLog(1, 'never-existed')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// tests/deploy-recorder.test.ts
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { shouldRecord } from '../src/lib/server/deploy-recorder';
|
||||||
|
|
||||||
|
describe('shouldRecord', () => {
|
||||||
|
test('records redacted output lines', () => {
|
||||||
|
expect(shouldRecord('progress', { type: 'line', line: 'Container web Started' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never records the result event, which carries the unredacted output block', () => {
|
||||||
|
// deploy/+server.ts sends { success, output: result.output }, and result.output is
|
||||||
|
// stdout || stderr straight from compose -- it never passes through redactLine.
|
||||||
|
// Recording it would put secrets on disk permanently.
|
||||||
|
expect(shouldRecord('result', { success: true, output: 'DB_PASSWORD=hunter2' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not record the static status labels', () => {
|
||||||
|
expect(shouldRecord('progress', { status: 'Deploying stack...' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TWO conditions carry this guard, and each needs its own case -- otherwise removing
|
||||||
|
// one of them leaves every test green. The case below is reached only by the event
|
||||||
|
// check: a 'result' payload that DOES carry type: 'line' passes the second condition
|
||||||
|
// and is stopped by the first one alone.
|
||||||
|
test('rejects a result event even when it carries a line-shaped payload', () => {
|
||||||
|
expect(shouldRecord('result', { type: 'line', line: 'Container web Started' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* B1 regression: the three single-run deploy routes' @openapi annotations
|
||||||
|
* must not claim a response code that never appears literally in the
|
||||||
|
* handler's own body -- that is exactly Gate 4 of
|
||||||
|
* scripts/generate-openapi.ts's `--check` (statusCodes drift), the CI tor
|
||||||
|
* .github/workflows/openapi.yml:49 runs. This file reproduces that one
|
||||||
|
* gate's logic directly against these three routes' actual source (via the
|
||||||
|
* SAME parseAnnotations/splitMethodBodies/analyzeHandlerBody the generator
|
||||||
|
* itself uses), rather than shelling out to the full generator -- which
|
||||||
|
* scans and regenerates the ENTIRE route tree and would make this a slow,
|
||||||
|
* broad-scoped test for what is a narrow, three-file regression.
|
||||||
|
*
|
||||||
|
* Before the fix: GET and DELETE .../deploys/{runId} documented resp-400 and
|
||||||
|
* resp-404 as `resp-NNN:` lines, and GET .../deploys/{runId}/log documented
|
||||||
|
* resp-400 -- but all three of those specific codes are produced by the
|
||||||
|
* SHARED loadOwnedDeployRun() helper (deploy-run-access.ts), not by a
|
||||||
|
* literal `status: 400` / `error(400)` in the handler's own body text, which
|
||||||
|
* is all the generator's static scanner can see. Fixed by moving that
|
||||||
|
* information into the `description:` field as prose (the same pattern
|
||||||
|
* POST /api/backup/destinations/{id}/task already uses for its own
|
||||||
|
* guard-produced codes), which this test also confirms is still readable.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { parseAnnotations, splitMethodBodies, analyzeHandlerBody } from '../scripts/openapi/lib';
|
||||||
|
|
||||||
|
const ROUTES: { file: string; methods: ('GET' | 'DELETE')[] }[] = [
|
||||||
|
{ file: 'src/routes/api/stacks/[name]/deploys/[runId]/+server.ts', methods: ['GET', 'DELETE'] },
|
||||||
|
{ file: 'src/routes/api/stacks/[name]/deploys/[runId]/log/+server.ts', methods: ['GET'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('B1: deploy-run routes have no status-code drift (Gate 4)', () => {
|
||||||
|
for (const { file, methods } of ROUTES) {
|
||||||
|
const content = readFileSync(join(import.meta.dir, '..', file), 'utf-8');
|
||||||
|
const annotations = parseAnnotations(content);
|
||||||
|
const bodies = splitMethodBodies(content);
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
test(`${method} ${file}: every documented response code appears literally in the handler body`, () => {
|
||||||
|
const ann = annotations[method];
|
||||||
|
const body = bodies[method];
|
||||||
|
expect(ann).toBeDefined();
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
if (!ann || !body) return; // unreachable, satisfies the type checker
|
||||||
|
|
||||||
|
const analysis = analyzeHandlerBody(body);
|
||||||
|
const codeCodes = new Set(analysis.statusCodes.filter((c) => c !== '200'));
|
||||||
|
const docCodes = Object.keys(ann.responses).filter((c) => c !== '200');
|
||||||
|
|
||||||
|
// This IS Gate 4's own drift condition, reproduced directly: a
|
||||||
|
// documented code with no matching status()/error() call in the
|
||||||
|
// SAME handler's body text is exactly what turned CI red.
|
||||||
|
const stale = docCodes.filter((c) => !codeCodes.has(c));
|
||||||
|
expect(stale).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test(`${file}: the 400/404 codes loadOwnedDeployRun() produces are still documented, in prose`, () => {
|
||||||
|
for (const method of methods) {
|
||||||
|
const ann = annotations[method];
|
||||||
|
expect(ann?.description).toBeDefined();
|
||||||
|
expect(ann!.description).toMatch(/loadOwnedDeployRun/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { buildRunDetails } from '../src/lib/server/deploy-run-record-core';
|
||||||
|
|
||||||
|
describe('buildRunDetails', () => {
|
||||||
|
test('carries the options, the summary and the log file name', () => {
|
||||||
|
const d = buildRunDetails({
|
||||||
|
options: { pull: false, build: true, forceRecreate: true },
|
||||||
|
summary: { containersCreated: 2, containersStarted: 2, containersRecreated: 0,
|
||||||
|
imagesBuilt: ['app:1'], imagesPulled: [], buildSteps: 3, buildStepsCached: 1,
|
||||||
|
digest: undefined },
|
||||||
|
exitCode: 0,
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
logFile: '01J8-abc.log'
|
||||||
|
});
|
||||||
|
expect(d.options.build).toBe(true);
|
||||||
|
expect(d.summary?.containersStarted).toBe(2);
|
||||||
|
expect(d.logFile).toBe('01J8-abc.log');
|
||||||
|
expect(d.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The log file name is what ties record and file together. Task 13 deletes any file
|
||||||
|
// whose id has no record -- if the name is missing here, the reconciler eats the log.
|
||||||
|
test('refuses to build details without a log file name', () => {
|
||||||
|
expect(() => buildRunDetails({ options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
summary: undefined, exitCode: 0, composeHash: 'a', envHash: 'b', logFile: '' }))
|
||||||
|
.toThrow(/logFile/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
// tests/deploy-run-record.test.ts
|
||||||
|
/**
|
||||||
|
* Exercises the database-touching half of the stack_deploy run record
|
||||||
|
* (deploy-run-record.ts) against a faked $lib/server/db -- see
|
||||||
|
* tests/helpers/db-fake.ts's doc comment for why a fake is unavoidable under Bun
|
||||||
|
* (db.ts transitively loads better-sqlite3, ERR_DLOPEN_FAILED) and why it must be
|
||||||
|
* registered through registerDbFake() rather than a second mock.module() call.
|
||||||
|
*
|
||||||
|
* Deliberately never calls recorder.line(): end() awaits `this.tail`, which is
|
||||||
|
* `Promise.resolve()` when line() was never invoked, so these tests never touch
|
||||||
|
* deploy-log-store.ts (no real filesystem needed).
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { registerDbFake } from './helpers/db-fake';
|
||||||
|
|
||||||
|
// -- $lib/server/db fake: an in-memory schedule_executions row ------------
|
||||||
|
|
||||||
|
let stored: {
|
||||||
|
created?: Record<string, unknown>;
|
||||||
|
updates: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetDbState() {
|
||||||
|
stored = { created: undefined, updates: [] };
|
||||||
|
}
|
||||||
|
resetDbState();
|
||||||
|
|
||||||
|
registerDbFake('createScheduleExecution', async (data: Record<string, unknown>) => {
|
||||||
|
stored.created = data;
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
...data,
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
duration: null,
|
||||||
|
errorMessage: null,
|
||||||
|
details: data.details ?? null,
|
||||||
|
logs: null,
|
||||||
|
createdAt: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
registerDbFake('updateScheduleExecution', async (id: number, data: Record<string, unknown>) => {
|
||||||
|
stored.updates.push(data);
|
||||||
|
return { id, ...data };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Imported AFTER the fakes are registered above -- same ordering constraint
|
||||||
|
// tests/deploy-endpoints.test.ts documents for $lib/server/db.
|
||||||
|
const { createRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
|
||||||
|
/** The db-fake update() call that closed the row (status !== 'running'), i.e. the
|
||||||
|
* one written by end(), as opposed to createRunRecorder's own startedAt update. */
|
||||||
|
function endUpdate() {
|
||||||
|
return stored.updates.find((u) => u.status !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeployRunRecorder.end() -- secret redaction of the stored error text', () => {
|
||||||
|
// A known, invented value that can't coincidentally appear in a timestamp or id.
|
||||||
|
const SECRET = 'zzz-kanarienvogel-4711';
|
||||||
|
|
||||||
|
test('a failed run whose error text carries a known secret does NOT store that secret', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [SECRET]
|
||||||
|
});
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, `compose up failed: DB_PASSWORD=${SECRET} rejected`);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(SECRET);
|
||||||
|
expect(JSON.stringify(update?.details)).not.toContain(SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('status is "failed" and NOT the redacted-line placeholder itself', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [SECRET]
|
||||||
|
});
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, `boom: ${SECRET}`);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
// Asserting presence of the placeholder alone would stay green even if no
|
||||||
|
// secret had ever been in the line (test-coverage-pflicht.md's warning about
|
||||||
|
// "enthaelt ***" tests) -- so this only checks the redacted text is safe to
|
||||||
|
// store, not that a placeholder specifically appears.
|
||||||
|
expect(String(update?.errorMessage)).toContain('***');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful run stores errorMessage: null regardless of secrets', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [SECRET]
|
||||||
|
});
|
||||||
|
|
||||||
|
await recorder.end(true);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('success');
|
||||||
|
expect(update?.errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// redactLine() withholds the WHOLE line (returns null) when a too-short secret
|
||||||
|
// value appears, rather than risk replacing an unrelated substring. The run must
|
||||||
|
// still close as "failed" -- a run stuck as "running" forever is worse than a
|
||||||
|
// run with a generic message.
|
||||||
|
test('when redaction withholds the whole line, the run still ends "failed" with a safe, non-null message', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const shortSecret = 'ab12cd'; // < MIN_REPLACEABLE_LENGTH (8) in secret-redaction.ts
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [shortSecret]
|
||||||
|
});
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, `token ${shortSecret} invalid`);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(update?.errorMessage).not.toBeNull();
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(shortSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an error with no secrets configured is stored unchanged (no false positives)', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, 'exit code 1');
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.errorMessage).toBe('exit code 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack() resolves a bound secret provider's values (Bitwarden/
|
||||||
|
// 1Password/etc. bulk pulls or inline refs) INTERNALLY, strictly after a caller
|
||||||
|
// (a route, or deployGitStack in git.ts) already built its recorder from the
|
||||||
|
// DB-only vars it had at construction time. addSecrets() is how the caller feeds
|
||||||
|
// those later-known values in before end() redacts the stored error text.
|
||||||
|
describe('DeployRunRecorder.addSecrets() -- provider-resolved secrets added AFTER construction', () => {
|
||||||
|
const LATE_SECRET = 'zzz-spaetzuendung-9000';
|
||||||
|
|
||||||
|
test('a secret added via addSecrets() after construction (with an EMPTY construction-time list) is still redacted from the stored error', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
// Mirrors the route's construction-time list BEFORE deployStack() resolves
|
||||||
|
// provider secrets -- this is the F4 bug's exact starting condition.
|
||||||
|
secrets: []
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.addSecrets([LATE_SECRET]);
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, `compose up failed: PROVIDER_TOKEN=${LATE_SECRET} rejected`);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(LATE_SECRET);
|
||||||
|
expect(String(update?.errorMessage)).toContain('***');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addSecrets() ADDS to, rather than replaces, secrets already known at construction', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const CONSTRUCTION_SECRET = 'yyy-bekannt-von-anfang-an';
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [CONSTRUCTION_SECRET]
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.addSecrets([LATE_SECRET]);
|
||||||
|
|
||||||
|
await recorder.end(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
`compose up failed: A=${CONSTRUCTION_SECRET} B=${LATE_SECRET} rejected`
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(CONSTRUCTION_SECRET);
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(LATE_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calling addSecrets() with an empty array changes nothing (the common case: deployStack() failed before resolving any provider secrets)', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const recorder = await createRunRecorder({
|
||||||
|
stackName: 'demo',
|
||||||
|
envId: null,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: []
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.addSecrets([]);
|
||||||
|
|
||||||
|
await recorder.end(false, undefined, 'exit code 1');
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.errorMessage).toBe('exit code 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeployRunRecorder.line() -- the size-budget path (SizeBudgetTracker,
|
||||||
|
// deploy-log-store.ts). Unlike every test above, THIS block deliberately DOES
|
||||||
|
// call line() and DOES touch the real filesystem (via appendRunLog) -- it is
|
||||||
|
// the exception to this file's opening doc comment, and exists specifically
|
||||||
|
// to prove the budget still triggers truncation after replacing the old
|
||||||
|
// per-line full-directory-scan with SizeBudgetTracker's cached/incremental
|
||||||
|
// check. A tiny injected SizeBudgetTracker (see DeployRunRecorder's
|
||||||
|
// constructor) stands in for BUDGET_BYTES (2 GiB in production) -- nothing
|
||||||
|
// here writes gigabytes of data to prove the same code path.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { SizeBudgetTracker } from '../src/lib/server/deploy-log-store';
|
||||||
|
|
||||||
|
const previousDataDirForLineTests = process.env.DATA_DIR;
|
||||||
|
let lineTestsScratchDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
lineTestsScratchDir = await mkdtemp(join(tmpdir(), 'deploy-run-record-'));
|
||||||
|
process.env.DATA_DIR = lineTestsScratchDir;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (previousDataDirForLineTests === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = previousDataDirForLineTests;
|
||||||
|
await rm(lineTestsScratchDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeployRunRecorder.line() -- size-budget truncation', () => {
|
||||||
|
test('a run whose output crosses a tiny injected budget ends up truncated: true', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
// 50 B budget -- five ~11 B lines ("line-N\n") cross it comfortably without
|
||||||
|
// waiting on anything close to the real 2 GiB BUDGET_BYTES.
|
||||||
|
const tinyBudget = new SizeBudgetTracker(null, '1', 50);
|
||||||
|
const recorder = new DeployRunRecorder(
|
||||||
|
1,
|
||||||
|
Date.now(),
|
||||||
|
{
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: null
|
||||||
|
},
|
||||||
|
tinyBudget
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
recorder.line(`line-${i}`);
|
||||||
|
}
|
||||||
|
await recorder.end(true);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect((update?.details as Record<string, unknown> | undefined)?.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gegenversuch counterpart to the test above, and the "doesn't trigger too
|
||||||
|
// early" half of the requirement: a run whose total output stays under the
|
||||||
|
// budget must NOT be marked truncated.
|
||||||
|
test('a run whose output stays under the injected budget ends up truncated: false', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
const generousBudget = new SizeBudgetTracker(null, '2', 1_000_000);
|
||||||
|
const recorder = new DeployRunRecorder(
|
||||||
|
2,
|
||||||
|
Date.now(),
|
||||||
|
{
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: null
|
||||||
|
},
|
||||||
|
generousBudget
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
recorder.line(`line-${i}`);
|
||||||
|
}
|
||||||
|
await recorder.end(true);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
// buildRunDetails() only puts `truncated` in the object when it's true (see
|
||||||
|
// deploy-run-record-core.ts) -- so "not truncated" is undefined, not false.
|
||||||
|
expect((update?.details as Record<string, unknown> | undefined)?.truncated).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('once truncated, further line() calls stop appending -- the truncation notice is the last thing written', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const { readRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
const tinyBudget = new SizeBudgetTracker(null, '3', 20); // crosses after ~2 lines
|
||||||
|
const recorder = new DeployRunRecorder(
|
||||||
|
3,
|
||||||
|
Date.now(),
|
||||||
|
{
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: null
|
||||||
|
},
|
||||||
|
tinyBudget
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
recorder.line(`this-line-should-not-all-land-${i}`);
|
||||||
|
}
|
||||||
|
await recorder.end(true);
|
||||||
|
|
||||||
|
const log = await readRunLog(null, '3');
|
||||||
|
expect(log).not.toBeNull();
|
||||||
|
expect(log).toContain('[deploy log truncated: size budget exceeded]');
|
||||||
|
// Not every one of the 20 lines can have landed -- otherwise truncation
|
||||||
|
// never actually stopped anything, it just decorated a full log.
|
||||||
|
expect((log ?? '').split('\n').length).toBeLessThan(22);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// F5 fix: DeployRunRecorder threads its envId through to BOTH the log file's
|
||||||
|
// on-disk path AND its SizeBudgetTracker's default construction -- proven
|
||||||
|
// end to end here, without injecting a custom SizeBudgetTracker (so this
|
||||||
|
// exercises DeployRunRecorder's OWN `new SizeBudgetTracker(this.envId, ...)`
|
||||||
|
// default, not a test double standing in for it).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DeployRunRecorder -- envId threading to the log file path (F5)', () => {
|
||||||
|
test('two runs in DIFFERENT environments write to two DIFFERENT log files, never colliding even with the same executionId space', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const { readRunLog, runLogPath } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
|
||||||
|
const recorderA = new DeployRunRecorder(4001, Date.now(), {
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: 1
|
||||||
|
});
|
||||||
|
const recorderB = new DeployRunRecorder(4002, Date.now(), {
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
recorderA.line('environment 1 output');
|
||||||
|
recorderB.line('environment 2 output');
|
||||||
|
await recorderA.end(true);
|
||||||
|
await recorderB.end(true);
|
||||||
|
|
||||||
|
expect(await readRunLog(1, '4001')).toContain('environment 1 output');
|
||||||
|
expect(await readRunLog(2, '4002')).toContain('environment 2 output');
|
||||||
|
// Not merely different content -- genuinely different files on disk.
|
||||||
|
expect(runLogPath(1, '4001')).not.toBe(runLogPath(2, '4002'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// THE end-to-end counterpart to the pure SizeBudgetTracker-level regression
|
||||||
|
// test in deploy-log-store.test.ts: environment A's run is deliberately
|
||||||
|
// pushed PAST its own size budget and truncated. Environment B's run, with
|
||||||
|
// the SAME budget and an unrelated one-line output, must NOT be affected --
|
||||||
|
// proving DeployRunRecorder.line()'s real truncation path (appendRunLog +
|
||||||
|
// SizeBudgetTracker.recordAppend together, not either in isolation) is
|
||||||
|
// scoped per environment, exactly as task-lead described: "filling A's
|
||||||
|
// budget does NOT truncate B's run".
|
||||||
|
test('truncating environment A\'s run does not truncate environment B\'s unrelated run, even with the same tiny budget', async () => {
|
||||||
|
resetDbState();
|
||||||
|
const { readRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record');
|
||||||
|
|
||||||
|
const budgetA = new SizeBudgetTracker(1, '4101', 50);
|
||||||
|
const budgetB = new SizeBudgetTracker(2, '4102', 50);
|
||||||
|
|
||||||
|
const recorderA = new DeployRunRecorder(
|
||||||
|
4101,
|
||||||
|
Date.now(),
|
||||||
|
{
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: 1
|
||||||
|
},
|
||||||
|
budgetA
|
||||||
|
);
|
||||||
|
const recorderB = new DeployRunRecorder(
|
||||||
|
4102,
|
||||||
|
Date.now(),
|
||||||
|
{
|
||||||
|
options: { pull: false, build: false, forceRecreate: false },
|
||||||
|
composeHash: 'aaa',
|
||||||
|
envHash: 'bbb',
|
||||||
|
secrets: [],
|
||||||
|
envId: 2
|
||||||
|
},
|
||||||
|
budgetB
|
||||||
|
);
|
||||||
|
|
||||||
|
// Environment A runs to completion FIRST and fully lands on disk (including
|
||||||
|
// its truncation notice) before environment B writes a single byte -- this
|
||||||
|
// removes any ambiguity about interleaving between the two recorders'
|
||||||
|
// independent async append chains, so a failure below can only mean B's
|
||||||
|
// directory scan actually saw A's bytes, not a timing accident.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
recorderA.line(`line-${i}`);
|
||||||
|
}
|
||||||
|
await recorderA.end(true);
|
||||||
|
|
||||||
|
// Environment B: ONE short line, nowhere near 50 B on its own, written
|
||||||
|
// strictly AFTER environment A has already exceeded its own budget.
|
||||||
|
recorderB.line('untouched');
|
||||||
|
await recorderB.end(true);
|
||||||
|
|
||||||
|
const logA = await readRunLog(1, '4101');
|
||||||
|
const logB = await readRunLog(2, '4102');
|
||||||
|
|
||||||
|
expect(logA).toContain('[deploy log truncated: size budget exceeded]');
|
||||||
|
// Environment B's single line survives completely, with no truncation
|
||||||
|
// notice -- it was never anywhere near ITS OWN 50 B budget, and
|
||||||
|
// environment A's bytes (in a DIFFERENT directory) never counted
|
||||||
|
// against it.
|
||||||
|
expect(logB).toBe('untouched\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* deploy-run-view — pure text/label composition for the Stack modal's Deploys tab.
|
||||||
|
* Import-free by design (see deploy-run-view.ts's doc comment) so it runs under Bun
|
||||||
|
* without pulling in $lib/server (better-sqlite3, ERR_DLOPEN_FAILED).
|
||||||
|
*
|
||||||
|
* Run with: bun test tests/deploy-run-view.test.ts
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import {
|
||||||
|
formatStatusLabel,
|
||||||
|
formatTrigger,
|
||||||
|
formatRunDuration,
|
||||||
|
formatContainerSummary,
|
||||||
|
formatBuildStatus,
|
||||||
|
formatOptionChips,
|
||||||
|
lastErrorLine,
|
||||||
|
buildDeployRunView,
|
||||||
|
buildDeployLogPanelState,
|
||||||
|
type DeployRun,
|
||||||
|
type DeployRunSummary
|
||||||
|
} from '../src/lib/utils/deploy-run-view';
|
||||||
|
|
||||||
|
const summary = (over: Partial<DeployRunSummary> = {}): DeployRunSummary => ({
|
||||||
|
containersCreated: 0,
|
||||||
|
containersRecreated: 0,
|
||||||
|
containersStarted: 0,
|
||||||
|
imagesBuilt: [],
|
||||||
|
imagesPulled: [],
|
||||||
|
buildSteps: 0,
|
||||||
|
buildStepsCached: 0,
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = (over: Partial<DeployRun> = {}): DeployRun => ({
|
||||||
|
id: 1,
|
||||||
|
triggeredBy: 'manual',
|
||||||
|
duration: 1500,
|
||||||
|
status: 'success',
|
||||||
|
errorMessage: null,
|
||||||
|
details: { options: { pull: false, build: false, forceRecreate: false } },
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBuildStatus', () => {
|
||||||
|
test('every build step came from cache', () => {
|
||||||
|
expect(formatBuildStatus(summary({ buildSteps: 3, buildStepsCached: 3 }))).toBe(
|
||||||
|
'Built from cache (3 steps)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single cached step is not pluralized wrong', () => {
|
||||||
|
expect(formatBuildStatus(summary({ buildSteps: 1, buildStepsCached: 1 }))).toBe(
|
||||||
|
'Built from cache'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partially built — some steps from cache, some not', () => {
|
||||||
|
expect(formatBuildStatus(summary({ buildSteps: 4, buildStepsCached: 1 }))).toBe(
|
||||||
|
'Built (4 steps, 1 from cache)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('built with no cache hits at all', () => {
|
||||||
|
expect(formatBuildStatus(summary({ buildSteps: 2, buildStepsCached: 0 }))).toBe(
|
||||||
|
'Built (2 steps)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing built — a run that never touched the build step', () => {
|
||||||
|
expect(formatBuildStatus(summary({ buildSteps: 0, buildStepsCached: 0 }))).toBe(
|
||||||
|
'Nothing built'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no summary at all reads as unknown, not as "nothing built"', () => {
|
||||||
|
expect(formatBuildStatus(undefined)).toBe('Build status unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatContainerSummary', () => {
|
||||||
|
test('no summary recorded says so explicitly — never "0 containers"', () => {
|
||||||
|
const text = formatContainerSummary(undefined);
|
||||||
|
expect(text).toBe('No summary recorded for this run');
|
||||||
|
expect(text).not.toMatch(/0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with zero container changes reads distinctly from "no summary"', () => {
|
||||||
|
expect(formatContainerSummary(summary())).toBe('No container changes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('created and started are joined, recreated omitted when zero', () => {
|
||||||
|
expect(
|
||||||
|
formatContainerSummary(summary({ containersCreated: 3, containersStarted: 1 }))
|
||||||
|
).toBe('3 created, 1 started');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all three counts present appear in created, recreated, started order', () => {
|
||||||
|
expect(
|
||||||
|
formatContainerSummary(
|
||||||
|
summary({ containersCreated: 1, containersRecreated: 2, containersStarted: 3 })
|
||||||
|
)
|
||||||
|
).toBe('1 created, 2 recreated, 3 started');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatOptionChips', () => {
|
||||||
|
test('no options recorded yields no chips', () => {
|
||||||
|
expect(formatOptionChips(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all flags false yields no chips', () => {
|
||||||
|
expect(formatOptionChips({ pull: false, build: false, forceRecreate: false })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every true flag becomes its own chip, in a fixed order', () => {
|
||||||
|
expect(formatOptionChips({ pull: true, build: true, forceRecreate: true })).toEqual([
|
||||||
|
'Pull',
|
||||||
|
'Build',
|
||||||
|
'Force recreate'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single flag yields a single chip', () => {
|
||||||
|
expect(formatOptionChips({ pull: true, build: false, forceRecreate: false })).toEqual(['Pull']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTrigger', () => {
|
||||||
|
test.each([
|
||||||
|
['manual', 'Manual'],
|
||||||
|
['cron', 'Scheduled'],
|
||||||
|
['webhook', 'Webhook'],
|
||||||
|
['startup', 'Startup']
|
||||||
|
])('%s -> %s', (input: string, expected: string) => {
|
||||||
|
expect(formatTrigger(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unrecognized trigger value is not swallowed silently', () => {
|
||||||
|
expect(formatTrigger('something-new')).toBe('something-new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatStatusLabel', () => {
|
||||||
|
test.each([
|
||||||
|
['success', 'Success'],
|
||||||
|
['failed', 'Failed'],
|
||||||
|
['running', 'Running']
|
||||||
|
])('%s -> %s', (input: string, expected: string) => {
|
||||||
|
expect(formatStatusLabel(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unrecognized status value is passed through, not hidden', () => {
|
||||||
|
expect(formatStatusLabel('mystery')).toBe('mystery');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatRunDuration', () => {
|
||||||
|
test('null/undefined duration (a run still in progress or never started)', () => {
|
||||||
|
expect(formatRunDuration(null)).toBe('—');
|
||||||
|
expect(formatRunDuration(undefined)).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub-second durations render in milliseconds', () => {
|
||||||
|
expect(formatRunDuration(340)).toBe('340ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub-minute durations render with one decimal of seconds', () => {
|
||||||
|
expect(formatRunDuration(13515)).toBe('13.5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minute-scale durations render as Nm Ns', () => {
|
||||||
|
expect(formatRunDuration(125000)).toBe('2m 5s');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastErrorLine', () => {
|
||||||
|
test('no error message at all', () => {
|
||||||
|
expect(lastErrorLine(null)).toBeNull();
|
||||||
|
expect(lastErrorLine(undefined)).toBeNull();
|
||||||
|
expect(lastErrorLine('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short single-line message is returned unchanged', () => {
|
||||||
|
expect(lastErrorLine('exit code 1')).toBe('exit code 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a multi-line message keeps only its last non-empty line', () => {
|
||||||
|
const msg = 'Pulling image...\nBuilding...\nError: container failed to start\n';
|
||||||
|
expect(lastErrorLine(msg)).toBe('Error: container failed to start');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long single-line message is cut to maxLen with an ellipsis marker', () => {
|
||||||
|
const long = 'x'.repeat(300);
|
||||||
|
const result = lastErrorLine(long, 50);
|
||||||
|
expect(result?.length).toBe(50);
|
||||||
|
expect(result?.endsWith('…')).toBe(true);
|
||||||
|
expect(result?.startsWith('x'.repeat(49))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDeployRunView', () => {
|
||||||
|
test('a run without a summary (no output at all) never claims to know container/build facts', () => {
|
||||||
|
const view = buildDeployRunView(run({ details: { options: { pull: true, build: false, forceRecreate: false } } }));
|
||||||
|
expect(view.containerSummary).toBe('No summary recorded for this run');
|
||||||
|
expect(view.buildStatus).toBe('Build status unknown');
|
||||||
|
expect(view.imagesBuilt).toEqual([]);
|
||||||
|
expect(view.imagesPulled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncated is surfaced only when the record actually says so', () => {
|
||||||
|
expect(buildDeployRunView(run()).truncated).toBe(false);
|
||||||
|
expect(
|
||||||
|
buildDeployRunView(
|
||||||
|
run({ details: { options: { pull: false, build: false, forceRecreate: false }, truncated: true } })
|
||||||
|
).truncated
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed run with a long error message carries the shortened last line', () => {
|
||||||
|
const longError = `Step 1 ok\nStep 2 ok\n${'boom '.repeat(60)}`;
|
||||||
|
const view = buildDeployRunView(run({ status: 'failed', errorMessage: longError }));
|
||||||
|
expect(view.errorSummary).not.toBeNull();
|
||||||
|
expect(view.errorSummary!.length).toBeLessThanOrEqual(200);
|
||||||
|
expect(view.statusLabel).toBe('Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful run never surfaces an error summary even if errorMessage is set', () => {
|
||||||
|
const view = buildDeployRunView(run({ status: 'success', errorMessage: 'leftover text' }));
|
||||||
|
expect(view.errorSummary).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a running run in progress has no error summary and reads as Running', () => {
|
||||||
|
const view = buildDeployRunView(run({ status: 'running', duration: null, errorMessage: null }));
|
||||||
|
expect(view.errorSummary).toBeNull();
|
||||||
|
expect(view.statusLabel).toBe('Running');
|
||||||
|
expect(view.duration).toBe('—');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('option chips and images pass through from the run summary', () => {
|
||||||
|
const view = buildDeployRunView(
|
||||||
|
run({
|
||||||
|
details: {
|
||||||
|
options: { pull: true, build: true, forceRecreate: false },
|
||||||
|
summary: summary({ imagesBuilt: ['app:1'], imagesPulled: ['redis:7'] })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(view.optionChips).toEqual(['Pull', 'Build']);
|
||||||
|
expect(view.imagesBuilt).toEqual(['app:1']);
|
||||||
|
expect(view.imagesPulled).toEqual(['redis:7']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDeployLogPanelState', () => {
|
||||||
|
test('a normal completed run: log fetchable, not truncated, deletable', () => {
|
||||||
|
const state = buildDeployLogPanelState(run({ status: 'success' }));
|
||||||
|
expect(state).toEqual({ logMissing: false, truncated: false, deletable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run reconciled as logMissing short-circuits to logMissing -- regardless of truncated', () => {
|
||||||
|
const state = buildDeployLogPanelState(
|
||||||
|
run({ status: 'success', details: { logMissing: true, truncated: true } })
|
||||||
|
);
|
||||||
|
expect(state.logMissing).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a truncated run surfaces truncated independently of logMissing', () => {
|
||||||
|
const state = buildDeployLogPanelState(run({ status: 'failed', details: { truncated: true } }));
|
||||||
|
expect(state.logMissing).toBe(false);
|
||||||
|
expect(state.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a running run is never deletable, even though its log is not missing', () => {
|
||||||
|
const state = buildDeployLogPanelState(run({ status: 'running', details: undefined }));
|
||||||
|
expect(state.deletable).toBe(false);
|
||||||
|
expect(state.logMissing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mutating the run to running after it was deletable flips deletable back off', () => {
|
||||||
|
// Regression guard for a hand-rolled `run.status !== "running"` check drifting
|
||||||
|
// out of sync with this function if someone inlines the condition again later.
|
||||||
|
const base = run({ status: 'success' });
|
||||||
|
expect(buildDeployLogPanelState(base).deletable).toBe(true);
|
||||||
|
expect(buildDeployLogPanelState({ ...base, status: 'running' }).deletable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run with no details at all reads as available, not truncated, deletable', () => {
|
||||||
|
const state = buildDeployLogPanelState(run({ status: 'success', details: null }));
|
||||||
|
expect(state).toEqual({ logMissing: false, truncated: false, deletable: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard: every deployStack() call in an API route must pass onLine.
|
||||||
|
*
|
||||||
|
* The compose output window is fed by 'progress' messages of shape
|
||||||
|
* {type:'line', line}. A route that runs deployStack inside createJobResponse but
|
||||||
|
* forgets `onLine: (line) => send('progress', { type: 'line', line })` still works
|
||||||
|
* — it just shows the user an empty output window until the job settles. Nothing
|
||||||
|
* fails, so the omission is invisible: POST /api/stacks (create & start) and
|
||||||
|
* PUT /api/stacks/{name}/compose (save & redeploy) both shipped without it while
|
||||||
|
* the five stack-row actions had it.
|
||||||
|
*
|
||||||
|
* deployStack talks to the Docker socket and the DB, so we can't drive these
|
||||||
|
* handlers end-to-end here. Instead we assert at the source level that every
|
||||||
|
* deployStack call under src/routes/api carries onLine — the same shape of check
|
||||||
|
* as tests/encryption-rotation-coverage.test.ts (a list that silently omitted
|
||||||
|
* one entry).
|
||||||
|
*/
|
||||||
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const apiRoot = join(here, '..', 'src', 'routes', 'api');
|
||||||
|
|
||||||
|
function serverFiles(dir: string): string[] {
|
||||||
|
const found: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) found.push(...serverFiles(full));
|
||||||
|
else if (entry.name === '+server.ts') found.push(full);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the text of each deployStack(...) argument list, paren-balanced so a
|
||||||
|
// nested call or object literal doesn't cut the match short.
|
||||||
|
function deployStackCalls(source: string): string[] {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const marker = 'deployStack(';
|
||||||
|
|
||||||
|
for (let at = source.indexOf(marker); at !== -1; at = source.indexOf(marker, at + 1)) {
|
||||||
|
// Skip identifiers that merely end in "deployStack(" (e.g. runRedeployStack().
|
||||||
|
const before = source[at - 1];
|
||||||
|
if (before && /[A-Za-z0-9_$]/.test(before)) continue;
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = at + marker.length - 1; i < source.length; i++) {
|
||||||
|
if (source[i] === '(') depth++;
|
||||||
|
else if (source[i] === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
calls.push(source.slice(at, i + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsByFile = new Map<string, string[]>();
|
||||||
|
for (const file of serverFiles(apiRoot)) {
|
||||||
|
const calls = deployStackCalls(readFileSync(file, 'utf8'));
|
||||||
|
if (calls.length > 0) callsByFile.set(relative(join(here, '..'), file), calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('API routes stream compose output', () => {
|
||||||
|
// Without this the suite would pass vacuously if the scan ever stopped
|
||||||
|
// finding anything (renamed directory, changed route layout).
|
||||||
|
test('the scan finds the known deployStack routes', () => {
|
||||||
|
expect([...callsByFile.keys()].sort()).toEqual([
|
||||||
|
'src/routes/api/stacks/+server.ts',
|
||||||
|
'src/routes/api/stacks/[name]/compose/+server.ts',
|
||||||
|
'src/routes/api/stacks/[name]/deploy/+server.ts'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every deployStack call passes onLine', () => {
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const [file, calls] of callsByFile) {
|
||||||
|
for (const call of calls) {
|
||||||
|
if (!call.includes('onLine')) missing.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Source guard: deployStack() must record the SAME secret set it redacts against.
|
||||||
|
*
|
||||||
|
* F4 makes `deployStack()` attach the resolved secret VALUES to its result so the
|
||||||
|
* per-route stack_deploy recorder can redact them out of a stored deploy error
|
||||||
|
* (deploy-run-record.ts -> RunRecorder.addSecrets -> redactLine). The single
|
||||||
|
* production line that carries F4 is, in src/lib/server/stacks.ts:
|
||||||
|
*
|
||||||
|
* result.resolvedSecrets = Object.values(secretVars);
|
||||||
|
*
|
||||||
|
* where `secretVars` is the post-`resolveProviderEnvVars` set that
|
||||||
|
* `executeComposeCommand(...)` just redacted the streamed lines against. If that
|
||||||
|
* one line is deleted, resolvedSecrets stays undefined and the recorder redacts
|
||||||
|
* against nothing -- a provider-resolved secret in a thrown/returned deploy error
|
||||||
|
* would be stored in cleartext. A code review demonstrated the gap by mutation:
|
||||||
|
* deleting the line left the entire targeted suite green, because the two tests
|
||||||
|
* that reference `resolvedSecrets` (git-stack-deploy-run-record.test.ts,
|
||||||
|
* stack-compose-redeploy-run-record.test.ts) fake `deployStack` wholesale and
|
||||||
|
* hand-set the field in their fixtures -- they prove the DOWNSTREAM wiring, never
|
||||||
|
* that the real stacks.ts populates it.
|
||||||
|
*
|
||||||
|
* WHY THIS IS A SOURCE GUARD AND NOT A BEHAVIOUR TEST (empirically established,
|
||||||
|
* not assumed):
|
||||||
|
* 1. `tests/helpers/stacks-fake.ts` calls mock.module('$lib/server/stacks', ...)
|
||||||
|
* at import time, which -- per its own doc comment and Bun's single-process
|
||||||
|
* test model -- freezes that specifier's exports for the ENTIRE run. Two
|
||||||
|
* existing suites import it, so in a full `bun test` run `import { deployStack }`
|
||||||
|
* resolves to the fake dispatcher, never the real function. There is no
|
||||||
|
* per-file process isolation in Bun to escape this.
|
||||||
|
* 2. Independently: pulling the real `$lib/server/stacks` into a test's import
|
||||||
|
* graph evaluates a module chain that instantiates `better-sqlite3`, which
|
||||||
|
* Bun does not support ("'better-sqlite3' is not yet supported in Bun") and
|
||||||
|
* crashes at load. Reproduced twice while investigating this fix.
|
||||||
|
* Either blocker alone makes a real-`deployStack` behaviour test impossible here;
|
||||||
|
* that is why the whole suite fakes deployStack and guards its internal invariants
|
||||||
|
* at the source level (see deploy-stack-online.test.ts, stack-create-start-build-
|
||||||
|
* options.test.ts). This guard follows that established pattern.
|
||||||
|
*
|
||||||
|
* WHAT THIS GUARD CANNOT PROVE (honest limits of source matching): it matches
|
||||||
|
* TEXT, so it cannot prove `secretVars` is populated correctly at runtime (covered
|
||||||
|
* by resolveProviderEnvVars' own tests and the redaction tests), and a matching
|
||||||
|
* string inside a comment would satisfy it -- so comments are stripped before
|
||||||
|
* matching. It proves only the wiring: the derivation of `secretVars`, its use as
|
||||||
|
* the executeComposeCommand secret argument, and its assignment to resolvedSecrets.
|
||||||
|
*/
|
||||||
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const stacksPath = join(here, '..', 'src', 'lib', 'server', 'stacks.ts');
|
||||||
|
|
||||||
|
// Strip // line comments and /* */ block comments so a matching string inside a
|
||||||
|
// comment cannot satisfy the guard (the trap called out in test-coverage-pflicht).
|
||||||
|
function stripComments(src: string): string {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // avoid eating "http://" etc. (rare here)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the brace-balanced body of `export async function deployStack(...) { ... }`.
|
||||||
|
function deployStackBody(src: string): string {
|
||||||
|
const sig = src.indexOf('export async function deployStack(');
|
||||||
|
if (sig === -1) return '';
|
||||||
|
const open = src.indexOf('{', sig);
|
||||||
|
if (open === -1) return '';
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = open; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return src.slice(open + 1, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(stacksPath, 'utf8');
|
||||||
|
const body = stripComments(deployStackBody(raw));
|
||||||
|
|
||||||
|
describe('deployStack records the secret set it redacts against (F4 source guard)', () => {
|
||||||
|
// Vacuity guard: if the function can no longer be located, every assertion
|
||||||
|
// below would pass on an empty string. Fail loudly instead.
|
||||||
|
test('the deployStack body is located and non-trivial', () => {
|
||||||
|
expect(body.length).toBeGreaterThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('secretVars is derived from resolveProviderEnvVars (the redaction input)', () => {
|
||||||
|
const ok = /\bsecretVars\b[\s\S]{0,80}=\s*await\s+resolveProviderEnvVars\s*\(/.test(body);
|
||||||
|
expect(ok, `${stacksPath}: deployStack no longer derives \`secretVars\` from ` +
|
||||||
|
`\`await resolveProviderEnvVars(...)\`. F4 records exactly the set that was ` +
|
||||||
|
`redacted; if the derivation changed, update this guard AND confirm the recorder ` +
|
||||||
|
`still receives the redacted set.`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same secretVars is passed to executeComposeCommand as its secret argument', () => {
|
||||||
|
const call = body.match(/executeComposeCommand\(([\s\S]*?)\n\t*\);/);
|
||||||
|
expect(call, `${stacksPath}: could not find the executeComposeCommand(...) call in ` +
|
||||||
|
`deployStack -- the F4 guard cannot verify the redaction/record share \`secretVars\`.`).toBeTruthy();
|
||||||
|
expect(/\bsecretVars\b/.test(call ? call[1] : ''),
|
||||||
|
`${stacksPath}: deployStack's executeComposeCommand(...) call no longer passes ` +
|
||||||
|
`\`secretVars\` as the secret argument. The recorded set (result.resolvedSecrets) ` +
|
||||||
|
`must be the SAME set the compose lines were redacted against.`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('result.resolvedSecrets is assigned Object.values(secretVars) (the F4 line)', () => {
|
||||||
|
const ok = /result\.resolvedSecrets\s*=\s*Object\.values\(\s*secretVars\s*\)/.test(body);
|
||||||
|
expect(ok, `${stacksPath}: the F4 line \`result.resolvedSecrets = Object.values(secretVars)\` ` +
|
||||||
|
`is gone from deployStack. Without it the stack_deploy recorder redacts against nothing ` +
|
||||||
|
`and a provider-resolved secret can be stored in a deploy error in cleartext. If you ` +
|
||||||
|
`intentionally moved this wiring, update this guard to match the new location.`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { summarize } from '../src/lib/server/deploy-summary-core';
|
||||||
|
|
||||||
|
const REAL_RUN = [
|
||||||
|
' Image alpine:3.21 Pulling ',
|
||||||
|
' Image alpine:3.21 Pulled ',
|
||||||
|
' Image progprobe-built:1 Building ',
|
||||||
|
' Image progprobe-built:1 Built ',
|
||||||
|
' Network probea_default Creating ',
|
||||||
|
' Network probea_default Creating ',
|
||||||
|
' Network probea_default Created ',
|
||||||
|
' Network probea_default Created ',
|
||||||
|
' Container probea-plain-1 Creating ',
|
||||||
|
' Container probea-built-1 Creating ',
|
||||||
|
' Container probea-built-1 Created ',
|
||||||
|
' Container probea-plain-1 Created ',
|
||||||
|
' Container probea-built-1 Starting ',
|
||||||
|
' Container probea-plain-1 Starting ',
|
||||||
|
' Container probea-plain-1 Started ',
|
||||||
|
' Container probea-built-1 Started ',
|
||||||
|
'#5 CACHED',
|
||||||
|
'#6 DONE 1.0s',
|
||||||
|
'#7 DONE 2.7s',
|
||||||
|
'#9 exporting manifest list sha256:3e3a42937811aaaabbbbccccddddeeeeffff0000111122223333444455556666'
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('summarize', () => {
|
||||||
|
test('counts each container once in the measured run', () => {
|
||||||
|
const s = summarize(REAL_RUN);
|
||||||
|
expect(s.containersCreated).toBe(2);
|
||||||
|
expect(s.containersStarted).toBe(2);
|
||||||
|
expect(s.containersRecreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// REAL_RUN cannot prove deduplication: in the measured output every container line
|
||||||
|
// appears exactly once (only the *network* line repeats, and CONTAINER does not match
|
||||||
|
// network lines). Replacing the Set with a counter leaves the test above green. This
|
||||||
|
// case supplies the repetition explicitly -- it is the only one that fails when the
|
||||||
|
// Set is removed.
|
||||||
|
test('counts a container once even when compose repeats the same transition', () => {
|
||||||
|
const s = summarize([
|
||||||
|
' Container web-1 Created ',
|
||||||
|
' Container web-1 Created ',
|
||||||
|
' Container web-1 Started ',
|
||||||
|
' Container web-1 Started '
|
||||||
|
]);
|
||||||
|
expect(s.containersCreated).toBe(1);
|
||||||
|
expect(s.containersStarted).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separates images built from images pulled', () => {
|
||||||
|
const s = summarize(REAL_RUN);
|
||||||
|
expect(s.imagesBuilt).toEqual(['progprobe-built:1']);
|
||||||
|
expect(s.imagesPulled).toEqual(['alpine:3.21']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports how much of the build came from cache', () => {
|
||||||
|
const s = summarize(REAL_RUN);
|
||||||
|
expect(s.buildSteps).toBe(3);
|
||||||
|
expect(s.buildStepsCached).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks up the image digest from the buildkit export line', () => {
|
||||||
|
expect(summarize(REAL_RUN).digest)
|
||||||
|
.toBe('sha256:3e3a42937811aaaabbbbccccddddeeeeffff0000111122223333444455556666');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counts recreated containers separately from merely started ones', () => {
|
||||||
|
const s = summarize([
|
||||||
|
' Container a Recreate ',
|
||||||
|
' Container a Recreated ',
|
||||||
|
' Container a Starting ',
|
||||||
|
' Container a Started '
|
||||||
|
]);
|
||||||
|
expect(s.containersRecreated).toBe(1);
|
||||||
|
expect(s.containersCreated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image that was already local produces no pull entry at all', () => {
|
||||||
|
// Measured: compose emits neither Pulling nor Pulled for an image that is present.
|
||||||
|
// Absence is the only signal, so the summary must not invent one.
|
||||||
|
expect(summarize([' Container a Started ']).imagesPulled).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Run with: bun test tests/unit/format-relative-time.test.ts
|
* Run with: bun test tests/unit/format-relative-time.test.ts
|
||||||
*/
|
*/
|
||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { formatRelativeTime } from '../src/lib/utils/format';
|
import { formatRelativeTime } from '../src/lib/utils/format';
|
||||||
|
|
||||||
// Build a timestamp `n` seconds in the past relative to now, so the tests are
|
// Build a timestamp `n` seconds in the past relative to now, so the tests are
|
||||||
@@ -68,3 +68,57 @@ describe('formatRelativeTime — input forms & edges', () => {
|
|||||||
expect(formatRelativeTime(undefined as unknown as string)).toBe('');
|
expect(formatRelativeTime(undefined as unknown as string)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #1183 follow-up (reported live: a deploy from ~2 minutes ago showed "2h ago"):
|
||||||
|
// the server hands back a NAIVE timestamp (PG `timestamp without time zone`,
|
||||||
|
// e.g. "2026-08-30 20:36:11.534" - space separator, no "Z"/offset). Every
|
||||||
|
// caller of formatRelativeTime passes exactly that value, right alongside
|
||||||
|
// formatDateTime() which already reads it as UTC via parseTimestamp() (see
|
||||||
|
// date-format-tz.test.ts). formatRelativeTime used to parse it with a bare
|
||||||
|
// `new Date(...)`, which treats a zone-less string as browser-local time -
|
||||||
|
// correct only when the browser happens to run in UTC, which is exactly why
|
||||||
|
// this stayed invisible in a UTC test browser.
|
||||||
|
//
|
||||||
|
// TZ is forced to a non-UTC zone for this block only, so the bug reproduces
|
||||||
|
// regardless of the host/CI timezone. Restored in afterAll: bun runs every
|
||||||
|
// test file in one process, so a dangling override would leak into whichever
|
||||||
|
// file happens to run next.
|
||||||
|
describe('formatRelativeTime — a naive server timestamp is read as UTC, not local time', () => {
|
||||||
|
const originalTZ = process.env.TZ;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.TZ = 'America/New_York'; // UTC-4 in August - deliberately not UTC
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalTZ === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = originalTZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same instant in two forms: a proper zoned ISO string, and the naive
|
||||||
|
// space-separated form Drizzle hands back for a `timestamp without time
|
||||||
|
// zone` column. Deriving the expectation from the zoned form (rather than
|
||||||
|
// hardcoding a bucket string) keeps the assertion meaningful independent
|
||||||
|
// of whichever zone the suite happens to run under.
|
||||||
|
const naiveFrom = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
|
||||||
|
test('a naive PG-style string reports the same age as its zoned equivalent (5m ago)', () => {
|
||||||
|
const instant = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
expect(formatRelativeTime(naiveFrom(instant))).toBe(formatRelativeTime(instant.toISOString()));
|
||||||
|
expect(formatRelativeTime(naiveFrom(instant))).toBe('5m ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a two-minute-old naive deploy timestamp reads "2m ago" - the reported bug', () => {
|
||||||
|
// Under the bug, "20:36:11" gets read as 20:36:11 America/New_York and
|
||||||
|
// re-anchored 4h later in UTC than the true instant, so
|
||||||
|
// Date.now() - ms goes negative and this would report "in the future"
|
||||||
|
// instead of "2m ago".
|
||||||
|
const instant = new Date(Date.now() - 2 * 60 * 1000);
|
||||||
|
expect(formatRelativeTime(naiveFrom(instant))).toBe('2m ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a naive timestamp from several hours ago still reads the correct bucket', () => {
|
||||||
|
const instant = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||||
|
expect(formatRelativeTime(naiveFrom(instant))).toBe('3h ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import { isProtectedPath } from '../src/lib/server/fs-guard';
|
||||||
|
|
||||||
|
// protectedPaths() reads process.env.DATA_DIR on every call, not at import time, so a plain
|
||||||
|
// import is enough -- but the variable has to be set while these assertions run, and put back
|
||||||
|
// afterwards. Bun runs every test file in ONE process: a stray DATA_DIR leaks into unrelated
|
||||||
|
// suites, and selfhst-icons creates its cache directory under DATA_DIR, which fails when that
|
||||||
|
// points somewhere this machine does not have.
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.DATA_DIR = '/app/data';
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = previousDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isProtectedPath', () => {
|
||||||
|
test('protects the deploy log directory', () => {
|
||||||
|
// Deploy logs can carry secrets. The file browser is open by design, so the
|
||||||
|
// directory has to be named here or the API's permission check is bypassable.
|
||||||
|
expect(isProtectedPath('/app/data/deploy-logs')).toBe(true);
|
||||||
|
expect(isProtectedPath('/app/data/deploy-logs/abc.log')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still protects what it protected before', () => {
|
||||||
|
expect(isProtectedPath('/app/data/db')).toBe(true);
|
||||||
|
expect(isProtectedPath('/proc/1/environ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the rest of the container browsable', () => {
|
||||||
|
expect(isProtectedPath('/app/data/stacks')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-11
@@ -3,20 +3,26 @@ import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'node
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
// Pin DATA_DIR before the import below so the guard computes /app/data paths. This runs
|
// Pin DATA_DIR via beforeAll/afterAll so the guard computes /app/data paths for this
|
||||||
// at module load (Bun runs all test files in one process), so it must be RESTORED after
|
// file's tests, and put it back afterwards so the change doesn't leak into unrelated
|
||||||
// this file or it leaks /app/data into every later file - e.g. selfhst-icons then does
|
// suites. Bun runs every test file in one process: selfhst-icons reads DATA_DIR and
|
||||||
// mkdirSync('/app/data/...') and fails EACCES on CI (#1535).
|
// mkdirSync's a cache directory under it, which fails with EACCES on a machine that has
|
||||||
const __prevDataDir = process.env.DATA_DIR;
|
// no /app.
|
||||||
process.env.DATA_DIR = '/app/data';
|
//
|
||||||
|
// isProtectedPath() only reads process.env.DATA_DIR lazily, inside its own call -- not
|
||||||
|
// at import time -- so beforeAll (which runs before any test body, hence before any
|
||||||
|
// isProtectedPath() call) is sufficient on its own; no separate assignment before the
|
||||||
|
// import below is needed. Measured, not assumed: this file passes standalone, paired
|
||||||
|
// with selfhst-icons.test.ts in both file orders, and as part of the full suite.
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
beforeAll(() => { process.env.DATA_DIR = '/app/data'; });
|
||||||
|
afterAll(() => {
|
||||||
|
if (previousDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = previousDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
const { isProtectedPath } = await import('../src/lib/server/fs-guard');
|
const { isProtectedPath } = await import('../src/lib/server/fs-guard');
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
if (__prevDataDir === undefined) delete process.env.DATA_DIR;
|
|
||||||
else process.env.DATA_DIR = __prevDataDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isProtectedPath (file-browser secret guard, H1)', () => {
|
describe('isProtectedPath (file-browser secret guard, H1)', () => {
|
||||||
test('blocks the database directory and its contents', () => {
|
test('blocks the database directory and its contents', () => {
|
||||||
expect(isProtectedPath('/app/data/db')).toBe(true);
|
expect(isProtectedPath('/app/data/db')).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
/**
|
||||||
|
* Wires the stack_deploy run recorder into `deployGitStack()` (git.ts) itself, not
|
||||||
|
* into any single HTTP route. deployGitStack is the function every git-stack deploy
|
||||||
|
* trigger EXCEPT ONE funnels through -- a config-update-then-redeploy, both webhook
|
||||||
|
* methods, create-and-deploy, and (via runGitStackSync) the cron scheduler and the
|
||||||
|
* schedules page's "run now". Wiring the recorder at a single route (the way
|
||||||
|
* compose/+server.ts does it for non-git stacks) would leave every OTHER caller
|
||||||
|
* unrecorded -- crucially the cron- and webhook-triggered ones, the truly unattended
|
||||||
|
* runs the design doc's Meilenstein 5 is about.
|
||||||
|
*
|
||||||
|
* The one exception is the UI's own "Deploy" button, which streams live progress via
|
||||||
|
* deployGitStackWithProgress() -- a SEPARATE clone/read/deploy pipeline (it does not
|
||||||
|
* call deployGitStack at all) that therefore needed, and was missing, its OWN copy of
|
||||||
|
* this same recorder wiring. Reported by a reviewer and confirmed by reading both
|
||||||
|
* functions side by side: deployGitStack's recorder wiring never touches it. See the
|
||||||
|
* second describe block below for its coverage, and the Gegenprobe block at the
|
||||||
|
* bottom for why the guard there now expects TWO createRunRecorder call sites, not
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* `syncGitStack()` (also in git.ts) is a private collaborator of deployGitStack,
|
||||||
|
* called as a plain in-module function reference, not through an imported
|
||||||
|
* specifier -- so unlike the non-git deploy routes' $lib/server/stacks dependency,
|
||||||
|
* it CANNOT be faked via mock.module(). This suite therefore drives a REAL git
|
||||||
|
* sync against a throwaway local repository served over `git://` by a real `git
|
||||||
|
* daemon` subprocess (loopback only, ephemeral port) -- a bare filesystem path or
|
||||||
|
* `file://` URL is deliberately rejected by git-url-safety.ts (SSRF guard) and
|
||||||
|
* this test does not, and should not, work around that. Only the boundary below
|
||||||
|
* deployGitStack -- $lib/server/stacks' deployStack() -- is faked, exactly the
|
||||||
|
* boundary the compose-PUT/dedicated-deploy-route tests already fake for the
|
||||||
|
* same reason (no real `docker compose` in a unit test).
|
||||||
|
*
|
||||||
|
* $lib/server/db and $lib/server/stacks are faked via the shared helpers -- see
|
||||||
|
* their doc comments for why a second direct mock.module() call for either would
|
||||||
|
* collide with other files that already own those specifiers for this process.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from 'bun:test';
|
||||||
|
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||||
|
import { registerDbFake } from './helpers/db-fake';
|
||||||
|
import { registerStacksFake } from './helpers/stacks-fake';
|
||||||
|
|
||||||
|
// notifyGitSync (git.ts) wraps sendEventNotification in its own try/catch and never
|
||||||
|
// lets it affect the deploy outcome, so a no-op stub is all this suite needs -- and
|
||||||
|
// all it should assert on; which notification fired for which trigger belongs to a
|
||||||
|
// test of notifyGitSync itself, not this one. No other file in the suite mocks this
|
||||||
|
// specifier (as of this writing), so a single direct mock.module() call here is safe
|
||||||
|
// -- see tests/helpers/stacks-fake.ts's doc comment for why db/stacks can't do the same.
|
||||||
|
mock.module('$lib/server/notifications', () => ({
|
||||||
|
sendEventNotification: async () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -- $lib/server/db: an in-memory schedule_executions fake, plus the git-stack -----
|
||||||
|
// -- lookups deployGitStack/syncGitStack need. Same shape as
|
||||||
|
// tests/stack-compose-redeploy-run-record.test.ts's fake for the schedule_executions
|
||||||
|
// half; the git-specific reads/writes below are new to this file.
|
||||||
|
|
||||||
|
let created: Array<Record<string, unknown>>;
|
||||||
|
let updates: Array<Record<string, unknown>>;
|
||||||
|
let nextId: number;
|
||||||
|
let gitStackUpdates: Array<Record<string, unknown>>;
|
||||||
|
let upsertedSources: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
function resetDbState() {
|
||||||
|
created = [];
|
||||||
|
updates = [];
|
||||||
|
nextId = 1;
|
||||||
|
gitStackUpdates = [];
|
||||||
|
upsertedSources = [];
|
||||||
|
}
|
||||||
|
resetDbState();
|
||||||
|
|
||||||
|
registerDbFake('createScheduleExecution', async (data: Record<string, unknown>) => {
|
||||||
|
const row = {
|
||||||
|
id: nextId++,
|
||||||
|
...data,
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
duration: null,
|
||||||
|
errorMessage: null,
|
||||||
|
details: data.details ?? null,
|
||||||
|
logs: null,
|
||||||
|
createdAt: null
|
||||||
|
};
|
||||||
|
created.push(row);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
registerDbFake('updateScheduleExecution', async (id: number, data: Record<string, unknown>) => {
|
||||||
|
updates.push({ id, ...data });
|
||||||
|
return { id, ...data };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The update() call that CLOSED the row (status !== 'running'), i.e. the one written
|
||||||
|
* by DeployRunRecorder.end() -- same helper shape as the compose-redeploy suite's
|
||||||
|
* endUpdate(). Only ever at most one per test: every case here deploys exactly once. */
|
||||||
|
function endUpdate() {
|
||||||
|
return updates.find((u) => u.status !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All stack_deploy rows created for THIS suite's stack, as opposed to the
|
||||||
|
* git_stack_sync bookkeeping row a caller like git-stack-sync.ts creates separately
|
||||||
|
* (a different scheduleType, not exercised by this file -- this suite calls
|
||||||
|
* deployGitStack directly, the layer below that bookkeeping). */
|
||||||
|
function stackDeployRows() {
|
||||||
|
return created.filter((r) => r.scheduleType === 'stack_deploy');
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonSecretVars: Record<string, string>;
|
||||||
|
let secretVars: Record<string, string>;
|
||||||
|
|
||||||
|
registerDbFake('getSecretEnvVarsAsRecord', async () => secretVars);
|
||||||
|
registerDbFake('getNonSecretEnvVarsAsRecord', async () => nonSecretVars);
|
||||||
|
registerDbFake('updateGitStack', async (id: number, data: Record<string, unknown>) => {
|
||||||
|
gitStackUpdates.push({ id, ...data });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
registerDbFake('upsertStackSource', async (data: Record<string, unknown>) => {
|
||||||
|
upsertedSources.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- $lib/server/stacks: only deployStack (and getStackDir, used for the post-
|
||||||
|
// -- success stack_sources bookkeeping) are faked -- everything ABOVE that
|
||||||
|
// -- boundary (deployGitStack, syncGitStack) runs for real.
|
||||||
|
|
||||||
|
let deployStackCalls: Array<{ onLine?: (line: string) => void }>;
|
||||||
|
let deployStackResult: { success: boolean; output?: string; error?: string; resolvedSecrets?: string[] };
|
||||||
|
let deployStackOnLines: string[];
|
||||||
|
|
||||||
|
function resetStacksState() {
|
||||||
|
deployStackCalls = [];
|
||||||
|
deployStackResult = { success: true, output: 'deployed' };
|
||||||
|
deployStackOnLines = ['Container demo-stack-app-1 Started'];
|
||||||
|
}
|
||||||
|
resetStacksState();
|
||||||
|
|
||||||
|
registerStacksFake('deployStack', async (options: { onLine?: (line: string) => void }) => {
|
||||||
|
deployStackCalls.push(options);
|
||||||
|
for (const line of deployStackOnLines) options.onLine?.(line);
|
||||||
|
return deployStackResult;
|
||||||
|
});
|
||||||
|
registerStacksFake('getStackDir', async (stackName: string) => join('/tmp/stub-stacks', stackName));
|
||||||
|
|
||||||
|
// -- A real local git remote, served over git:// by a real `git daemon` -----------
|
||||||
|
//
|
||||||
|
// DATA_DIR is process-global and read at IMPORT time by git.ts (GIT_REPOS_DIR) and
|
||||||
|
// at call time by deploy-log-store.ts -- same precaution tests/deploy-endpoints.test.ts
|
||||||
|
// and the compose-redeploy suite already document for this shared env var.
|
||||||
|
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
let workDir: string;
|
||||||
|
let daemon: ChildProcess;
|
||||||
|
let daemonPort: number;
|
||||||
|
const STACK = 'demo-git-stack';
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], cwd: string): void {
|
||||||
|
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${cmd} ${args.join(' ')} failed: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for the daemon to actually accept connections (spawn is async) by polling
|
||||||
|
* a real `git ls-remote` against it, rather than a fixed sleep. */
|
||||||
|
async function waitForDaemon(port: number, deadlineMs: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: string | undefined;
|
||||||
|
while (Date.now() - start < deadlineMs) {
|
||||||
|
const probe = spawnSync('git', ['ls-remote', `git://127.0.0.1:${port}/remote.git`], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 2000
|
||||||
|
});
|
||||||
|
if (probe.status === 0) return;
|
||||||
|
lastError = probe.stderr;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`git daemon on port ${port} never became ready: ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
workDir = await mkdtemp(join(tmpdir(), 'dv-git-deploy-run-record-'));
|
||||||
|
process.env.DATA_DIR = workDir;
|
||||||
|
|
||||||
|
// Seed the bare "remote" repo with one commit on main, containing a compose file
|
||||||
|
// at the repo-relative path the fixture GitStackWithRepo below points at.
|
||||||
|
const bareDir = join(workDir, 'remote.git');
|
||||||
|
const scratchDir = join(workDir, 'scratch');
|
||||||
|
await mkdir(bareDir, { recursive: true });
|
||||||
|
run('git', ['init', '--bare', '-b', 'main', bareDir], workDir);
|
||||||
|
run('git', ['clone', bareDir, scratchDir], workDir);
|
||||||
|
run('git', ['config', 'user.email', '[email protected]'], scratchDir);
|
||||||
|
run('git', ['config', 'user.name', 'Test'], scratchDir);
|
||||||
|
await writeFile(join(scratchDir, 'compose.yaml'), 'services:\n app:\n image: demo:1\n');
|
||||||
|
run('git', ['add', 'compose.yaml'], scratchDir);
|
||||||
|
run('git', ['commit', '-m', 'initial'], scratchDir);
|
||||||
|
run('git', ['push', 'origin', 'main'], scratchDir);
|
||||||
|
|
||||||
|
daemonPort = 20000 + Math.floor(Math.random() * 20000);
|
||||||
|
daemon = spawn(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'daemon',
|
||||||
|
'--reuseaddr',
|
||||||
|
'--export-all',
|
||||||
|
`--base-path=${workDir}`,
|
||||||
|
`--port=${daemonPort}`,
|
||||||
|
'--listen=127.0.0.1'
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore' }
|
||||||
|
);
|
||||||
|
await waitForDaemon(daemonPort, 5000);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
daemon?.kill();
|
||||||
|
await rm(workDir, { recursive: true, force: true });
|
||||||
|
if (previousDataDir === undefined) {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.DATA_DIR = previousDataDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Route/function under test, imported AFTER DATA_DIR is set and all fakes are ---
|
||||||
|
// -- registered (git.ts reads DATA_DIR at module-load time for GIT_REPOS_DIR).
|
||||||
|
|
||||||
|
const { deployGitStack, deployGitStackWithProgress } = await import('../src/lib/server/git');
|
||||||
|
const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const { hashComposeContent } = await import('../src/lib/server/deploy-run-record-core');
|
||||||
|
|
||||||
|
let gitStackId: number;
|
||||||
|
|
||||||
|
function repoUrl(): string {
|
||||||
|
return `git://127.0.0.1:${daemonPort}/remote.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGitStack(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: gitStackId,
|
||||||
|
stackName: STACK,
|
||||||
|
environmentId: null,
|
||||||
|
repositoryId: 1,
|
||||||
|
composePath: 'compose.yaml',
|
||||||
|
branch: null,
|
||||||
|
envFilePath: null,
|
||||||
|
autoUpdate: false,
|
||||||
|
autoUpdateSchedule: 'daily' as const,
|
||||||
|
autoUpdateCron: '',
|
||||||
|
webhookEnabled: false,
|
||||||
|
webhookSecret: null,
|
||||||
|
contextDir: null,
|
||||||
|
buildOnDeploy: false,
|
||||||
|
noBuildCache: false,
|
||||||
|
repullImages: false,
|
||||||
|
forceRedeploy: false,
|
||||||
|
lastSync: null,
|
||||||
|
lastCommit: null,
|
||||||
|
syncStatus: 'idle',
|
||||||
|
syncError: null,
|
||||||
|
syncedFiles: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
repository: {
|
||||||
|
id: 1,
|
||||||
|
name: 'test-repo',
|
||||||
|
url: repoUrl(),
|
||||||
|
branch: 'main',
|
||||||
|
credentialId: null
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextGitStackId = 1;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetDbState();
|
||||||
|
resetStacksState();
|
||||||
|
// A fresh, never-before-used stack id per test -- getStackRepoPath() derives the
|
||||||
|
// on-disk clone path from it (stack-<id>, since environmentId is null), so this
|
||||||
|
// also gives every test its own clone directory. Without this, a later test could
|
||||||
|
// inherit an earlier test's already-cloned repo at the SAME commit and silently
|
||||||
|
// take the "no changes" skip path instead of the deploy path it meant to test.
|
||||||
|
gitStackId = nextGitStackId++;
|
||||||
|
nonSecretVars = { PUBLIC_VAR: 'v1' };
|
||||||
|
secretVars = { DB_PASSWORD: 's3cr3t-value' };
|
||||||
|
|
||||||
|
registerDbFake('getGitStack', async () => makeGitStack());
|
||||||
|
registerDbFake('getGitRepository', async () => makeGitStack().repository);
|
||||||
|
|
||||||
|
// nextId always restarts at 1 above, so every test's (at most one) row reuses
|
||||||
|
// runId '1' -- clear its log file so tests don't leak content into each other via
|
||||||
|
// the shared DATA_DIR (same precaution the compose-redeploy suite documents).
|
||||||
|
await deleteRunLog(null, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deployGitStack -- stack_deploy run record (git-triggered deploys)', () => {
|
||||||
|
test('a manual-triggered deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => {
|
||||||
|
const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(deployStackCalls).toHaveLength(1); // the sync->deploy path really ran
|
||||||
|
|
||||||
|
const rows = stackDeployRows();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].entityName).toBe(STACK);
|
||||||
|
expect(rows[0].triggeredBy).toBe('manual');
|
||||||
|
expect(rows[0].scheduleId).toBe(0);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('success');
|
||||||
|
expect(update?.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const runId = String(rows[0].id);
|
||||||
|
const log = await readRunLog(null, runId);
|
||||||
|
expect(log).toContain('Container demo-stack-app-1 Started');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('composeHash matches the content actually synced from the git repository', async () => {
|
||||||
|
await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { composeHash: string };
|
||||||
|
expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cron-triggered deploy (the unattended case this wiring exists for) is recorded with triggeredBy=cron', async () => {
|
||||||
|
await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
|
||||||
|
|
||||||
|
const rows = stackDeployRows();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].triggeredBy).toBe('cron');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a webhook-triggered deploy is recorded with triggeredBy=webhook and no userId', async () => {
|
||||||
|
await deployGitStack(gitStackId, { triggeredBy: 'webhook', force: false });
|
||||||
|
|
||||||
|
const rows = stackDeployRows();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].triggeredBy).toBe('webhook');
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { userId?: number };
|
||||||
|
expect(details.userId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secret value in the deploy error text is redacted before it is stored', async () => {
|
||||||
|
deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' };
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain('s3cr3t-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack() resolves a bound secret provider's values INTERNALLY,
|
||||||
|
// after deployGitStack()'s `recorder` above was already built from the DB-only
|
||||||
|
// nonSecretVars/secretVars this suite's fixture returns. A provider-resolved
|
||||||
|
// secret (never in that DB-only set) is simulated via the fake's
|
||||||
|
// resolvedSecrets -- exactly StackOperationResult's real shape -- and must still
|
||||||
|
// be redacted from the stored error, via recorder.addSecrets(). This is the
|
||||||
|
// unattended (cron/webhook) path deployGitStack's own doc comment calls out --
|
||||||
|
// exactly where a bound secret provider is most likely to be in play unattended.
|
||||||
|
test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => {
|
||||||
|
const PROVIDER_SECRET = 'zzz-bulk-provider-9000';
|
||||||
|
deployStackResult = {
|
||||||
|
success: false,
|
||||||
|
error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`,
|
||||||
|
resolvedSecrets: [PROVIDER_SECRET]
|
||||||
|
};
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
const result = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second, unchanged sync (force=false, nothing to deploy) creates NO additional stack_deploy row', async () => {
|
||||||
|
// First call: the clone directory for this test's stack id does not exist yet,
|
||||||
|
// so getPreviousCommit() (git.ts) returns null -> commitChanged is always true
|
||||||
|
// on a first sync -> updated=true -> shouldDeploy=true even with force:false.
|
||||||
|
// This deploys for real and records exactly one row, same as the tests above.
|
||||||
|
const first = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
expect(first.skipped).toBeUndefined();
|
||||||
|
expect(stackDeployRows()).toHaveLength(1);
|
||||||
|
|
||||||
|
// Second call, same stack id, remote repository untouched in between:
|
||||||
|
// syncGitStack reads the ACTUAL previous commit off the clone directory the
|
||||||
|
// first call just left on disk (real git, not a fake), re-clones, and finds
|
||||||
|
// the same commit -> commitChanged=false -> updated=false -> with force:false
|
||||||
|
// and this fixture's forceRedeploy:false, shouldDeploy is false -> skip. The
|
||||||
|
// recorder-creation block in git.ts sits AFTER that `if (!shouldDeploy) return`
|
||||||
|
// branch, so a skip must create no new row at all.
|
||||||
|
const second = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false });
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(second.skipped).toBe(true);
|
||||||
|
expect(deployStackCalls).toHaveLength(1); // still just the first call's deploy
|
||||||
|
expect(stackDeployRows()).toHaveLength(1); // no row added for the skipped second call
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deployGitStackWithProgress -- stack_deploy run record (the UI 'Deploy' button)", () => {
|
||||||
|
// deployGitStackWithProgress runs its OWN clone/read/deploy pipeline (it does not
|
||||||
|
// call deployGitStack), reporting 5 discrete stages via onProgress rather than
|
||||||
|
// deployGitStack's onLine -- see its doc comment in git.ts. It was, until this
|
||||||
|
// fix, the one caller of deployStack() that never went through createRunRecorder
|
||||||
|
// at all: every test below is red without the fix (verified by reverting it and
|
||||||
|
// re-running this file), because no stack_deploy row is created in the first
|
||||||
|
// place.
|
||||||
|
|
||||||
|
test('a manual UI deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => {
|
||||||
|
const progressEvents: Array<{ status: string; message?: string }> = [];
|
||||||
|
const result = await deployGitStackWithProgress(gitStackId, (data) => progressEvents.push(data));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(deployStackCalls).toHaveLength(1); // the clone->deploy path really ran
|
||||||
|
|
||||||
|
const rows = stackDeployRows();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].entityName).toBe(STACK);
|
||||||
|
expect(rows[0].triggeredBy).toBe('manual');
|
||||||
|
expect(rows[0].scheduleId).toBe(0);
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('success');
|
||||||
|
expect(update?.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const runId = String(rows[0].id);
|
||||||
|
const log = await readRunLog(null, runId);
|
||||||
|
expect(log).toContain('Container demo-stack-app-1 Started');
|
||||||
|
|
||||||
|
// onLine mirrors deploy output into the run record's log file WITHOUT being
|
||||||
|
// forwarded to onProgress -- that callback's own contract is the 5 discrete
|
||||||
|
// stage messages this function sends itself, not raw compose output lines.
|
||||||
|
expect(progressEvents.some((e) => e.message?.includes('Container demo-stack-app-1'))).toBe(false);
|
||||||
|
expect(progressEvents.some((e) => e.status === 'complete')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('composeHash matches the content actually read from the cloned repository', async () => {
|
||||||
|
await deployGitStackWithProgress(gitStackId, () => {});
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { composeHash: string };
|
||||||
|
expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secret value in the deploy error text is redacted before it is stored', async () => {
|
||||||
|
deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' };
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
const result = await deployGitStackWithProgress(gitStackId, () => {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain('s3cr3t-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// F4 fix, same rationale as deployGitStack's own regression test above: a
|
||||||
|
// provider-resolved secret is only known AFTER deployStack() returns, so it must
|
||||||
|
// be fed into the recorder via addSecrets() before end() closes the row, or it
|
||||||
|
// bypasses redaction entirely.
|
||||||
|
test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => {
|
||||||
|
const PROVIDER_SECRET = 'zzz-bulk-provider-9000';
|
||||||
|
deployStackResult = {
|
||||||
|
success: false,
|
||||||
|
error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`,
|
||||||
|
resolvedSecrets: [PROVIDER_SECRET]
|
||||||
|
};
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
const result = await deployGitStackWithProgress(gitStackId, () => {});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deployGitStack / deployGitStackWithProgress -- Gegenprobe: removing either recorder wiring turns its own describe block above red', () => {
|
||||||
|
async function readGitTsSource(): Promise<string> {
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
const { dirname } = await import('node:path');
|
||||||
|
const { fileURLToPath } = await import('node:url');
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return readFile(join(here, '..', 'src', 'lib', 'server', 'git.ts'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolates one exported function's own body: from its exact `export async
|
||||||
|
* function NAME(` signature up to (not including) the next top-level `export `.
|
||||||
|
* Good enough for these two specific, non-nested, sequentially-declared
|
||||||
|
* functions in this one file -- NOT a general-purpose brace matcher, and this
|
||||||
|
* guard cannot tell WHICH call site broke if the slicing itself were ever wrong.
|
||||||
|
* The behavioral tests in both describe blocks above are what actually prove
|
||||||
|
* each function records a run; this is a second, cheap line of defense on top
|
||||||
|
* of them, not a replacement for them.
|
||||||
|
*/
|
||||||
|
function functionBody(source: string, name: string): string {
|
||||||
|
const signature = `export async function ${name}(`;
|
||||||
|
const start = source.indexOf(signature);
|
||||||
|
if (start === -1) throw new Error(`function ${name} not found in git.ts`);
|
||||||
|
const nextExport = source.indexOf('\nexport ', start + signature.length);
|
||||||
|
return nextExport === -1 ? source.slice(start) : source.slice(start, nextExport);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deployGitStack calls createRunRecorder', async () => {
|
||||||
|
const body = functionBody(await readGitTsSource(), 'deployGitStack');
|
||||||
|
expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployGitStackWithProgress ALSO calls createRunRecorder -- this is the exact gap Finding 2 closes', async () => {
|
||||||
|
const body = functionBody(await readGitTsSource(), 'deployGitStackWithProgress');
|
||||||
|
expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exactly two createRunRecorder call sites in the whole file, one per function', async () => {
|
||||||
|
const matches = (await readGitTsSource()).match(/createRunRecorder\(/g) ?? [];
|
||||||
|
expect(matches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for dispatchStreamMessage — the PURE 'stream' message routing logic, extracted
|
||||||
|
* from hawser.ts's handleHawserWsMessage into hawser-core.ts. No DB import, so it is
|
||||||
|
* unit-testable directly (importing hawser.ts pulls in db/drizzle -> better-sqlite3, which
|
||||||
|
* bun's test runner can't load — see nav-preferences-core.test.ts for the same pattern).
|
||||||
|
* hawser.ts re-exports dispatchStreamMessage so callers get it from one import site.
|
||||||
|
*
|
||||||
|
* Compose requests are sent with streaming: false and land in connection.pendingRequests
|
||||||
|
* only. The message dispatcher's 'stream' case used to look up pendingStreamRequests
|
||||||
|
* exclusively, so any line message for a non-streaming request vanished silently -- no error,
|
||||||
|
* no warning. dispatchStreamMessage is the fix: it also checks a second, independent registry
|
||||||
|
* (lineHandlers) for a matching callback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { dispatchStreamMessage, type StreamDispatchTarget } from '../src/lib/server/hawser-core.js';
|
||||||
|
|
||||||
|
describe('dispatchStreamMessage', () => {
|
||||||
|
test('decodes a line message and passes it to the callback', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() };
|
||||||
|
conn.lineHandlers!.set('req-1', (l: string) => seen.push(l));
|
||||||
|
|
||||||
|
// This is how it really arrives: Go encodes []byte as base64.
|
||||||
|
dispatchStreamMessage(conn, { requestId: 'req-1', data: Buffer.from('first line').toString('base64') });
|
||||||
|
|
||||||
|
expect(seen).toEqual(['first line']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('silently drops line messages with no registered callback', () => {
|
||||||
|
const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() };
|
||||||
|
expect(() => dispatchStreamMessage(conn, { requestId: 'unknown', data: 'eA==' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers pendingStreamRequests over lineHandlers for the same requestId', () => {
|
||||||
|
const streamSeen: unknown[] = [];
|
||||||
|
const lineSeen: string[] = [];
|
||||||
|
const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() };
|
||||||
|
conn.pendingStreamRequests!.set('req-2', { onData: (d: unknown) => streamSeen.push(d) });
|
||||||
|
conn.lineHandlers!.set('req-2', (l: string) => lineSeen.push(l));
|
||||||
|
|
||||||
|
dispatchStreamMessage(conn, { requestId: 'req-2', data: 'raw-not-decoded' });
|
||||||
|
|
||||||
|
expect(streamSeen).toEqual(['raw-not-decoded']);
|
||||||
|
expect(lineSeen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates a missing lineHandlers map (defensive, matches pendingStreamRequests handling)', () => {
|
||||||
|
const conn: StreamDispatchTarget = { pendingStreamRequests: new Map() };
|
||||||
|
expect(() =>
|
||||||
|
dispatchStreamMessage(conn, { requestId: 'req-3', data: Buffer.from('x').toString('base64') })
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { mock } from 'bun:test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared registration point for faking `$lib/server/authorize`.
|
||||||
|
*
|
||||||
|
* Same collision problem tests/helpers/db-fake.ts documents for $lib/server/db:
|
||||||
|
* mock.module() replaces a module's exports WHOLESALE for the entire test process,
|
||||||
|
* and Bun freezes the exported shape the first time ANY file resolves the specifier
|
||||||
|
* -- so a second, independent `mock.module('$lib/server/authorize', ...)` call
|
||||||
|
* elsewhere in the suite would silently clobber (or be clobbered by) this one,
|
||||||
|
* depending on which file's import happens to run first.
|
||||||
|
*
|
||||||
|
* `authorize` is the module's only export, so a single dispatcher is enough: it
|
||||||
|
* looks up `impl` AT CALL TIME (not at import time), so registerAuthorizeFake()
|
||||||
|
* works no matter which test file calls it, or when, exactly like db-fake.ts's
|
||||||
|
* per-name dispatchers.
|
||||||
|
*
|
||||||
|
* Do NOT add a separate mock.module('$lib/server/authorize', ...) or
|
||||||
|
* mock.module('.../authorize', ...) call anywhere else in the suite -- that
|
||||||
|
* reintroduces the collision this file exists to avoid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFn = (...args: any[]) => any;
|
||||||
|
|
||||||
|
let impl: AnyFn | undefined;
|
||||||
|
|
||||||
|
export function registerAuthorizeFake(fn: AnyFn): void {
|
||||||
|
impl = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module('$lib/server/authorize', () => ({
|
||||||
|
authorize: (...args: unknown[]) => {
|
||||||
|
if (!impl) {
|
||||||
|
throw new Error(
|
||||||
|
'authorize-fake: authorize() was called but no test file has registered an implementation yet ' +
|
||||||
|
'(registerAuthorizeFake(fn) must run before the test that calls it)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return impl(...args);
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { mock } from 'bun:test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared registration point for faking `$lib/server/db`.
|
||||||
|
*
|
||||||
|
* $lib/server/db can't be imported for real under Bun (it transitively loads
|
||||||
|
* better-sqlite3, which throws ERR_DLOPEN_FAILED), so every test file that
|
||||||
|
* exercises a route/module built on top of it has to fake the whole thing.
|
||||||
|
*
|
||||||
|
* Two Bun behaviours make a naive shared registry fail, both confirmed
|
||||||
|
* empirically (undocumented):
|
||||||
|
*
|
||||||
|
* 1. mock.module() replaces a module's exports WHOLESALE for the ENTIRE test
|
||||||
|
* process -- no per-file scoping without `bun test --isolate` (not used
|
||||||
|
* by this suite). Two files independently calling
|
||||||
|
* `mock.module('$lib/server/db', () => ({ onlyWhatIMeed }))` clobber each
|
||||||
|
* other completely -- whichever registers "later" wins for BOTH files.
|
||||||
|
*
|
||||||
|
* 2. Bun does not evaluate every test file's top-level code before running
|
||||||
|
* any test body. It runs files one at a time. The FIRST time ANY code,
|
||||||
|
* from ANY file, resolves the `$lib/server/db` specifier (`import(...)`
|
||||||
|
* or a static import chain reaching it), Bun freezes the resulting module
|
||||||
|
* namespace object for the specifier -- confirmed by trying (a) a Proxy
|
||||||
|
* that reads a shared, still-mutable bag live, and (b) re-calling
|
||||||
|
* mock.module() with a fresh object literal right before each file's own
|
||||||
|
* import: neither picks up a key added by a file that runs AFTER the
|
||||||
|
* first-ever resolution. Once resolved, a specifier's export set is fixed
|
||||||
|
* for the rest of the process, no matter how many more times
|
||||||
|
* mock.module() is called for it afterwards.
|
||||||
|
*
|
||||||
|
* The fix that DOES work: pre-declare every export name any test file will
|
||||||
|
* ever need, ONCE, right here, each as a thin dispatcher function that looks
|
||||||
|
* up its real implementation in `impls` AT CALL TIME (not at import time).
|
||||||
|
* These dispatcher functions themselves are stable references, present in
|
||||||
|
* the very first module snapshot regardless of which file resolves the
|
||||||
|
* specifier first. `registerDbFake(name, fn)` only ever changes what a
|
||||||
|
* dispatcher DELEGATES to -- it never needs to add a new key to the frozen
|
||||||
|
* snapshot, so it works no matter which file calls it, or when.
|
||||||
|
*
|
||||||
|
* Adding a new $lib/server/db export a test needs? Add its name to
|
||||||
|
* KNOWN_EXPORTS below, then call `registerDbFake('yourExportName', fn)` from
|
||||||
|
* your test file. Do NOT add a separate `mock.module('$lib/server/db', ...)`
|
||||||
|
* or `mock.module('.../db', ...)` call anywhere else in the suite -- that
|
||||||
|
* reintroduces the collision this file exists to avoid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFn = (...args: any[]) => any;
|
||||||
|
|
||||||
|
/** Every $lib/server/db export any test file fakes, across the whole suite. */
|
||||||
|
const KNOWN_EXPORTS = [
|
||||||
|
'getBackupConfig',
|
||||||
|
'getScheduleExecution',
|
||||||
|
'deleteScheduleExecution',
|
||||||
|
'getScheduleExecutions',
|
||||||
|
'getDeployLogReconcileEnabled',
|
||||||
|
'getScheduleExecutionIdsByType',
|
||||||
|
'createScheduleExecution',
|
||||||
|
'updateScheduleExecution',
|
||||||
|
'appendScheduleExecutionLog',
|
||||||
|
'getGitStack',
|
||||||
|
'getGitRepository',
|
||||||
|
'getGitCredential',
|
||||||
|
'updateGitRepository',
|
||||||
|
'updateGitStack',
|
||||||
|
'upsertStackSource',
|
||||||
|
'getEnvironment',
|
||||||
|
'getSecretEnvVarsAsRecord',
|
||||||
|
'getNonSecretEnvVarsAsRecord'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const impls: Record<string, AnyFn> = {};
|
||||||
|
|
||||||
|
export function registerDbFake(name: (typeof KNOWN_EXPORTS)[number], fn: AnyFn): void {
|
||||||
|
impls[name] = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatcher(name: string): AnyFn {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
const impl = impls[name];
|
||||||
|
if (!impl) {
|
||||||
|
throw new Error(
|
||||||
|
`db-fake: '${name}' was called but no test file has registered an implementation for it yet ` +
|
||||||
|
`(registerDbFake('${name}', ...) must run before the test that calls it)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return impl(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleShape: Record<string, AnyFn> = {};
|
||||||
|
for (const name of KNOWN_EXPORTS) {
|
||||||
|
moduleShape[name] = dispatcher(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module('$lib/server/db', () => moduleShape);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { mock } from 'bun:test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared registration point for faking `$lib/server/stacks`.
|
||||||
|
*
|
||||||
|
* Mirrors tests/helpers/db-fake.ts exactly, for the same two reasons documented
|
||||||
|
* there: mock.module() replaces a module's exports WHOLESALE for the entire test
|
||||||
|
* process, and once ANY file resolves the '$lib/server/stacks' specifier its export
|
||||||
|
* set is frozen for the rest of the run. A second direct `mock.module('$lib/server/
|
||||||
|
* stacks', ...)` call anywhere in the suite collides with whichever one resolved
|
||||||
|
* first -- exactly the situation this file exists to avoid (see
|
||||||
|
* tests/stack-compose-redeploy-run-record.test.ts's doc comment, which predicted this
|
||||||
|
* exact need before a second file existed).
|
||||||
|
*
|
||||||
|
* Adding a new $lib/server/stacks export a test needs? Add its name to
|
||||||
|
* KNOWN_EXPORTS below, then call `registerStacksFake('yourExportName', fn)` from your
|
||||||
|
* test file. Do NOT add a separate mock.module('$lib/server/stacks', ...) call
|
||||||
|
* anywhere else in the suite.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFn = (...args: any[]) => any;
|
||||||
|
|
||||||
|
/** Every $lib/server/stacks export any test file fakes, across the whole suite. */
|
||||||
|
const KNOWN_EXPORTS = [
|
||||||
|
'getStackComposeFile',
|
||||||
|
'saveStackComposeFile',
|
||||||
|
'requireComposeFile',
|
||||||
|
'deployStack',
|
||||||
|
'getStackDir'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const impls: Record<string, AnyFn> = {};
|
||||||
|
|
||||||
|
export function registerStacksFake(name: (typeof KNOWN_EXPORTS)[number], fn: AnyFn): void {
|
||||||
|
impls[name] = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatcher(name: string): AnyFn {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
const impl = impls[name];
|
||||||
|
if (!impl) {
|
||||||
|
throw new Error(
|
||||||
|
`stacks-fake: '${name}' was called but no test file has registered an implementation for it yet ` +
|
||||||
|
`(registerStacksFake('${name}', ...) must run before the test that calls it)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return impl(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleShape: Record<string, AnyFn> = {};
|
||||||
|
for (const name of KNOWN_EXPORTS) {
|
||||||
|
moduleShape[name] = dispatcher(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module('$lib/server/stacks', () => moduleShape);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { downloadFileName, stripAnsi } from '../src/lib/utils/log-download-name';
|
||||||
|
|
||||||
|
describe('downloadFileName', () => {
|
||||||
|
test('uses the title when one is given', () => {
|
||||||
|
expect(downloadFileName('web-1')).toBe('web-1-logs.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to a neutral name when no title is given', () => {
|
||||||
|
expect(downloadFileName(undefined)).toBe('logs.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips path separators so a title can never escape the download name', () => {
|
||||||
|
expect(downloadFileName('../../etc/passwd')).toBe('etcpasswd-logs.txt');
|
||||||
|
expect(downloadFileName('..\\..\\windows\\system32')).toBe('windowssystem32-logs.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
// One case per transformation -- otherwise removing .trim() leaves every test green.
|
||||||
|
test('trims surrounding whitespace', () => {
|
||||||
|
expect(downloadFileName(' web-1 ')).toBe('web-1-logs.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripAnsi', () => {
|
||||||
|
test('removes ANSI escape sequences', () => {
|
||||||
|
expect(stripAnsi('\u001b[32mok\u001b[0m')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// docker compose hides and shows the cursor on every progress redraw. '?' is a CSI
|
||||||
|
// parameter byte, so a digits-and-semicolons pattern leaves these behind -- in exactly
|
||||||
|
// the output this viewer is built to show.
|
||||||
|
test('removes private-mode sequences, which is what compose emits', () => {
|
||||||
|
expect(stripAnsi('\u001b[?25lbuilding\u001b[?25h')).toBe('building');
|
||||||
|
expect(stripAnsi('\u001b[?2004hx')).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves text without escape sequences untouched', () => {
|
||||||
|
expect(stripAnsi('Container web-1 Started')).toBe('Container web-1 Started');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { saveCloseTiming } from '../src/lib/utils/save-close-policy';
|
||||||
|
|
||||||
|
// Three separate tests, one per case (not one test with three assertions) -- so a
|
||||||
|
// failure in one case doesn't hide the other two behind a single aborted test run.
|
||||||
|
describe('saveCloseTiming', () => {
|
||||||
|
test('a plain save closes', () => {
|
||||||
|
expect(saveCloseTiming(false, true)).toBe('close');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful deploy closes, but only after a delay', () => {
|
||||||
|
expect(saveCloseTiming(true, true)).toBe('close-delayed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed deploy stays open', () => {
|
||||||
|
expect(saveCloseTiming(true, false)).toBe('stay-open');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { isScrolledToBottom, shouldResetScrollPause } from '../src/lib/utils/scroll-position';
|
||||||
|
|
||||||
|
describe('isScrolledToBottom', () => {
|
||||||
|
test('is true when the element is exactly at the end', () => {
|
||||||
|
expect(isScrolledToBottom(400, 500, 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is true within the default 50px threshold', () => {
|
||||||
|
expect(isScrolledToBottom(370, 500, 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One case at the boundary and one just past it -- otherwise an off-by-one in the
|
||||||
|
// comparison (< vs <=) leaves every other test green.
|
||||||
|
test('is true exactly at the threshold and false one pixel past it', () => {
|
||||||
|
expect(isScrolledToBottom(350, 500, 100)).toBe(true);
|
||||||
|
expect(isScrolledToBottom(349, 500, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false once the user has scrolled well up from the end', () => {
|
||||||
|
expect(isScrolledToBottom(100, 500, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a custom threshold', () => {
|
||||||
|
expect(isScrolledToBottom(390, 500, 100, 5)).toBe(false);
|
||||||
|
expect(isScrolledToBottom(395, 500, 100, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldResetScrollPause', () => {
|
||||||
|
test('lifts the pause when a fresh run starts (false -> true)', () => {
|
||||||
|
expect(shouldResetScrollPause(false, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is the case that catches the bug reported live: a viewer scrolls up during one
|
||||||
|
// run, and every later run of the same page session stays paused because nothing ever
|
||||||
|
// clears it. A naive "reset whenever autoScroll is true" implementation returns true
|
||||||
|
// here too -- which reads as similar but re-lifts the pause on every update while a run
|
||||||
|
// is already in progress, defeating the pause entirely. Only the false -> true edge
|
||||||
|
// (a *new* run starting) may lift it.
|
||||||
|
test('does not re-lift the pause on every update while a run is already in progress', () => {
|
||||||
|
expect(shouldResetScrollPause(true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing when a run ends (true -> false)', () => {
|
||||||
|
expect(shouldResetScrollPause(true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does nothing while idle (false -> false)', () => {
|
||||||
|
expect(shouldResetScrollPause(false, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { redactLine } from '../src/lib/server/secret-redaction';
|
||||||
|
|
||||||
|
describe('redactLine', () => {
|
||||||
|
test('replaces a known value exactly', () => {
|
||||||
|
const line = 'Error: connection to postgres://user:sup3rgeheim-passwort-42@db failed';
|
||||||
|
expect(redactLine(line, ['sup3rgeheim-passwort-42'])).toBe(
|
||||||
|
'Error: connection to postgres://user:***@db failed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces every occurrence, not just the first', () => {
|
||||||
|
expect(redactLine('a=tokenwert-lang-genug b=tokenwert-lang-genug', ['tokenwert-lang-genug'])).toBe(
|
||||||
|
'a=*** b=***'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withholds the whole line when a secret is too short to replace safely', () => {
|
||||||
|
// "test" would match inside "latest", "testing", "attest" -- replacing would be nonsense
|
||||||
|
expect(redactLine('Pulling image alpine:latest', ['test'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a line without any secret unchanged', () => {
|
||||||
|
const line = 'Container app-1 Started';
|
||||||
|
expect(redactLine(line, ['sup3rgeheim-passwort-42'])).toBe(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores empty and blank secrets', () => {
|
||||||
|
const line = 'Container app-1 Started';
|
||||||
|
expect(redactLine(line, ['', ' '])).toBe(line);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* readJobResponse's optional line callback — the "live output" hookup used by the UI.
|
||||||
|
* Mocks global fetch to drive watchJob's polling loop through a scripted sequence of
|
||||||
|
* job states, without a real server.
|
||||||
|
*
|
||||||
|
* Run with: bun test tests/sse-fetch-lines.test.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { readJobResponse } from '../src/lib/utils/sse-fetch';
|
||||||
|
|
||||||
|
interface JobLine {
|
||||||
|
event?: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JobState {
|
||||||
|
status: 'running' | 'done' | 'error';
|
||||||
|
lines: JobLine[];
|
||||||
|
result?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not production code — a small test double for the /api/jobs/{jobId} polling
|
||||||
|
// endpoint that watchJob() calls. Returns the given states in order, then keeps
|
||||||
|
// returning the last one if polled again.
|
||||||
|
function mockJobPolling(states: JobState[]): typeof fetch {
|
||||||
|
let call = 0;
|
||||||
|
return (async () => {
|
||||||
|
const state = states[Math.min(call, states.length - 1)];
|
||||||
|
call++;
|
||||||
|
return new Response(JSON.stringify({ id: 'x', ...state }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('readJobResponse with a line callback', () => {
|
||||||
|
test('reports line messages to the callback', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
// Simulate the job poll response: first running with one line, then done with both.
|
||||||
|
globalThis.fetch = mockJobPolling([
|
||||||
|
{ status: 'running', lines: [{ event: 'progress', data: { type: 'line', line: 'first' } }] },
|
||||||
|
{
|
||||||
|
status: 'done',
|
||||||
|
lines: [
|
||||||
|
{ event: 'progress', data: { type: 'line', line: 'first' } },
|
||||||
|
{ event: 'progress', data: { type: 'line', line: 'second' } }
|
||||||
|
],
|
||||||
|
result: { success: true }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await readJobResponse(new Response(JSON.stringify({ jobId: 'x' })), (l) => seen.push(l));
|
||||||
|
expect(seen).toEqual(['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stays unchanged without a callback', async () => {
|
||||||
|
globalThis.fetch = mockJobPolling([
|
||||||
|
{
|
||||||
|
status: 'done',
|
||||||
|
lines: [{ event: 'progress', data: { type: 'line', line: 'ignored' } }],
|
||||||
|
result: { success: true }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const result = await readJobResponse(new Response(JSON.stringify({ jobId: 'x' })));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// tests/sse-run-recorder.test.ts
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { createJobResponse, type RunRecorder } from '../src/lib/server/sse';
|
||||||
|
|
||||||
|
/** A fake RunRecorder that records every line() call and resolves `ended` when end() fires. */
|
||||||
|
function createFakeRecorder() {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let resolveEnded!: (v: { ok: boolean; exitCode?: number; error?: string }) => void;
|
||||||
|
const ended = new Promise<{ ok: boolean; exitCode?: number; error?: string }>((resolve) => {
|
||||||
|
resolveEnded = resolve;
|
||||||
|
});
|
||||||
|
const recorder: RunRecorder = {
|
||||||
|
line(line: string) {
|
||||||
|
lines.push(line);
|
||||||
|
},
|
||||||
|
addSecrets() {
|
||||||
|
// Not exercised by this file's tests -- they cover createJobResponse's own
|
||||||
|
// line()/end() plumbing, not the F4 addSecrets() wiring (see
|
||||||
|
// deploy-run-record.test.ts and the route-level regression tests for that).
|
||||||
|
},
|
||||||
|
async end(ok: boolean, exitCode?: number, error?: string) {
|
||||||
|
resolveEnded({ ok, exitCode, error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { recorder, lines, ended };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a status line (not recordable), two progress lines (recordable), and an
|
||||||
|
* unredacted result (never recordable) -- exercises shouldRecord's real filter. */
|
||||||
|
const runOperation = async (send: (event: string, data: unknown) => void) => {
|
||||||
|
send('progress', { status: 'Deploying stack...' });
|
||||||
|
send('progress', { type: 'line', line: 'Container web Started' });
|
||||||
|
send('progress', { type: 'line', line: 'Container web Healthy' });
|
||||||
|
send('result', { success: true, output: 'DB_PASSWORD=hunter2' });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('createJobResponse RunRecorder hook', () => {
|
||||||
|
test('records qualifying lines on the JSON path (Accept: application/json)', async () => {
|
||||||
|
const { recorder, lines, ended } = createFakeRecorder();
|
||||||
|
const request = new Request('http://x', { headers: { Accept: 'application/json' } });
|
||||||
|
|
||||||
|
const response = createJobResponse(runOperation, request, recorder);
|
||||||
|
await response.json();
|
||||||
|
const end = await ended;
|
||||||
|
|
||||||
|
expect(lines).toEqual(['Container web Started', 'Container web Healthy']);
|
||||||
|
expect(end.ok).toBe(true);
|
||||||
|
expect(end.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records the SAME qualifying lines on the streaming path (no Accept header)', async () => {
|
||||||
|
const { recorder, lines, ended } = createFakeRecorder();
|
||||||
|
const request = new Request('http://x');
|
||||||
|
|
||||||
|
createJobResponse(runOperation, request, recorder);
|
||||||
|
await ended;
|
||||||
|
|
||||||
|
expect(lines).toEqual(['Container web Started', 'Container web Healthy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both send() implementations feed the recorder identical lines', async () => {
|
||||||
|
const jsonSide = createFakeRecorder();
|
||||||
|
const streamSide = createFakeRecorder();
|
||||||
|
|
||||||
|
const jsonResponse = createJobResponse(
|
||||||
|
runOperation,
|
||||||
|
new Request('http://x', { headers: { Accept: 'application/json' } }),
|
||||||
|
jsonSide.recorder
|
||||||
|
);
|
||||||
|
await jsonResponse.json();
|
||||||
|
await jsonSide.ended;
|
||||||
|
|
||||||
|
createJobResponse(runOperation, new Request('http://x'), streamSide.recorder);
|
||||||
|
await streamSide.ended;
|
||||||
|
|
||||||
|
expect(streamSide.lines).toEqual(jsonSide.lines);
|
||||||
|
expect(jsonSide.lines).toEqual(['Container web Started', 'Container web Healthy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming path: end() reports ok=false with the error message when the operation throws', async () => {
|
||||||
|
const { recorder, ended } = createFakeRecorder();
|
||||||
|
|
||||||
|
createJobResponse(
|
||||||
|
async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
new Request('http://x'),
|
||||||
|
recorder
|
||||||
|
);
|
||||||
|
|
||||||
|
const end = await ended;
|
||||||
|
expect(end.ok).toBe(false);
|
||||||
|
expect(end.error).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming path: end() reports ok=false with the error message on a failed result that RESOLVES (no throw) -- this is the path every browser call takes, since neither +page.svelte nor StackModal.svelte set an Accept header', async () => {
|
||||||
|
const { recorder, ended } = createFakeRecorder();
|
||||||
|
const request = new Request('http://x'); // no Accept header -> fire-and-forget path
|
||||||
|
|
||||||
|
createJobResponse(
|
||||||
|
async (send) => {
|
||||||
|
// A failed docker compose up does NOT throw -- it sends a failed result and
|
||||||
|
// returns normally. That lands this in the .then() branch below, never .catch().
|
||||||
|
send('result', { success: false, error: 'compose up failed: exit 1' });
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
recorder
|
||||||
|
);
|
||||||
|
|
||||||
|
const end = await ended;
|
||||||
|
expect(end.ok).toBe(false);
|
||||||
|
expect(end.error).toBe('compose up failed: exit 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Companion to the test above: a resolving (non-throwing) SUCCESS must still end up
|
||||||
|
// ok=true on the streaming path, so the fix doesn't flip the polarity for the common case.
|
||||||
|
test('streaming path: end() reports ok=true on a successful result that resolves (no throw)', async () => {
|
||||||
|
const { recorder, ended } = createFakeRecorder();
|
||||||
|
const request = new Request('http://x');
|
||||||
|
|
||||||
|
createJobResponse(
|
||||||
|
async (send) => {
|
||||||
|
send('result', { success: true, output: 'done' });
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
recorder
|
||||||
|
);
|
||||||
|
|
||||||
|
const end = await ended;
|
||||||
|
expect(end.ok).toBe(true);
|
||||||
|
expect(end.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('JSON path: end() reports ok=false with the error message on a failed result', async () => {
|
||||||
|
const { recorder, ended } = createFakeRecorder();
|
||||||
|
const request = new Request('http://x', { headers: { Accept: 'application/json' } });
|
||||||
|
|
||||||
|
const response = createJobResponse(
|
||||||
|
async (send) => {
|
||||||
|
send('result', { success: false, error: 'compose failed' });
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
recorder
|
||||||
|
);
|
||||||
|
await response.json();
|
||||||
|
|
||||||
|
const end = await ended;
|
||||||
|
expect(end.ok).toBe(false);
|
||||||
|
expect(end.error).toBe('compose failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streaming path: a recorder.end() that itself throws is never called twice', async () => {
|
||||||
|
// Guards RunRecorder's "end() is called exactly once" contract from sse.ts's own
|
||||||
|
// side: if the .then() branch's recorder.end() call throws, that rejection must
|
||||||
|
// not cause .catch() to invoke end() again.
|
||||||
|
let calls = 0;
|
||||||
|
const recorder: RunRecorder = {
|
||||||
|
line() {},
|
||||||
|
addSecrets() {},
|
||||||
|
async end() {
|
||||||
|
calls++;
|
||||||
|
throw new Error('recorder failure');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const request = new Request('http://x'); // no Accept header -> fire-and-forget path
|
||||||
|
|
||||||
|
createJobResponse(
|
||||||
|
async (send) => {
|
||||||
|
send('result', { success: true, output: 'done' });
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
recorder
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let the .then()/.catch() microtasks (including the rejected recorder.end() call
|
||||||
|
// and its handling) settle before asserting.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting the recorder changes nothing (backward compat)', async () => {
|
||||||
|
const request = new Request('http://x', { headers: { Accept: 'application/json' } });
|
||||||
|
const response = createJobResponse(runOperation, request);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({ success: true, output: 'DB_PASSWORD=hunter2' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* Task 21: `PUT /api/stacks/[name]/compose` (Save & redeploy) must produce the same
|
||||||
|
* stack_deploy schedule_executions row + log file the dedicated deploy endpoint
|
||||||
|
* (deploy/+server.ts, Task 10 / deploy-run-record.ts) already produces.
|
||||||
|
*
|
||||||
|
* Before this task, restart:true saved the compose file and redeployed WITHOUT ever
|
||||||
|
* building a run recorder: the deploy ran, but afterwards no row and no log file
|
||||||
|
* existed. Found live against the running test instance (task-21-brief.md), not by a
|
||||||
|
* failing test -- deploy-stack-online.test.ts's own doc comment already notes routes
|
||||||
|
* silently ship without wiring like this, invisibly, because nothing fails when it's
|
||||||
|
* missing.
|
||||||
|
*
|
||||||
|
* This calls the route's PUT handler DIRECTLY (composeRoute.PUT), never through any
|
||||||
|
* router/dispatch layer -- so "this test accidentally exercises the deploy endpoint
|
||||||
|
* instead" is structurally impossible; the object under test IS this file's export.
|
||||||
|
* What every "row created" assertion is instead paired with is a restart:false
|
||||||
|
* counterpart proving the SAME fakes yield ZERO rows when there is no deploy -- so a
|
||||||
|
* spurious pass can't hide behind lenient fakes that always produce a row regardless
|
||||||
|
* of what was actually requested.
|
||||||
|
*
|
||||||
|
* $lib/server/db and $lib/server/authorize are faked via the shared helpers -- see
|
||||||
|
* their doc comments for why a second direct mock.module() call for either would
|
||||||
|
* collide with tests/deploy-endpoints.test.ts, which already owns both specifiers for
|
||||||
|
* this process (mock.module() replaces a module's exports WHOLESALE for the entire
|
||||||
|
* test run, and Bun freezes the exported shape at first resolution).
|
||||||
|
*
|
||||||
|
* $lib/server/stacks is faked via tests/helpers/stacks-fake.ts, the same shared,
|
||||||
|
* collision-safe registration point db-fake.ts/authorize-fake.ts already use for
|
||||||
|
* their specifiers -- like db.ts it pulls in better-sqlite3 (ERR_DLOPEN_FAILED under
|
||||||
|
* Bun), so it can't be imported for real here either. This used to be a direct
|
||||||
|
* mock.module() call in this file; it moved into the shared helper once a second
|
||||||
|
* file (tests/git-stack-deploy-run-record.test.ts) needed to fake $lib/server/stacks
|
||||||
|
* too -- exactly the situation this doc comment originally predicted.
|
||||||
|
*
|
||||||
|
* (A second consumer for this fake was considered -- POST /api/stacks/+server.ts --
|
||||||
|
* but that route also drags in $lib/server/docker -> hawser.ts -> ./db/drizzle.js, a
|
||||||
|
* SEPARATE specifier from $lib/server/db that touches better-sqlite3 directly and
|
||||||
|
* can't be faked the same way. That route's build/pull/forceRecreate coverage is a
|
||||||
|
* source-level check instead, see stack-create-start-build-options.test.ts.)
|
||||||
|
*
|
||||||
|
* The dedicated deploy endpoint (deploy/+server.ts) is deliberately NOT imported here
|
||||||
|
* to double-check its own recording: it additionally pulls in $lib/server/audit ->
|
||||||
|
* $lib/server/license -> $lib/server/notifications/index.ts, none of which this task
|
||||||
|
* touches, and faking that whole chain just to re-prove an unrelated route wasn't
|
||||||
|
* broken would be its own maintenance burden. The "still records exactly once, not
|
||||||
|
* two" check for that route is done at the SOURCE level instead, at the bottom of this
|
||||||
|
* file -- deploy/+server.ts is untouched by this task's diff, so a source-level guard
|
||||||
|
* is sufficient evidence, not a shortcut.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
||||||
|
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { registerDbFake } from './helpers/db-fake';
|
||||||
|
import { registerAuthorizeFake } from './helpers/authorize-fake';
|
||||||
|
import { registerStacksFake } from './helpers/stacks-fake';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// -- $lib/server/authorize: always permitted (auth disabled) ----------------
|
||||||
|
|
||||||
|
registerAuthorizeFake(async () => ({
|
||||||
|
authEnabled: false,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isEnterprise: false,
|
||||||
|
can: async () => true,
|
||||||
|
canAccessEnvironment: async () => true,
|
||||||
|
// upstream/main added this gate (requireEnvAccess) to compose/+server.ts after this
|
||||||
|
// fake was written; authEnabled is false here, so the real implementation would be a
|
||||||
|
// no-op (see src/lib/server/authorize.ts) -- mirror that instead of leaving it undefined.
|
||||||
|
requireEnvAccess: async () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -- $lib/server/db: an in-memory schedule_executions fake -------------------
|
||||||
|
|
||||||
|
let created: Array<Record<string, unknown>>;
|
||||||
|
let updates: Array<Record<string, unknown>>;
|
||||||
|
let nextId: number;
|
||||||
|
|
||||||
|
function resetDbState() {
|
||||||
|
created = [];
|
||||||
|
updates = [];
|
||||||
|
nextId = 1;
|
||||||
|
}
|
||||||
|
resetDbState();
|
||||||
|
|
||||||
|
registerDbFake('createScheduleExecution', async (data: Record<string, unknown>) => {
|
||||||
|
const row = {
|
||||||
|
id: nextId++,
|
||||||
|
...data,
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
duration: null,
|
||||||
|
errorMessage: null,
|
||||||
|
details: data.details ?? null,
|
||||||
|
logs: null,
|
||||||
|
createdAt: null
|
||||||
|
};
|
||||||
|
created.push(row);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
registerDbFake('updateScheduleExecution', async (id: number, data: Record<string, unknown>) => {
|
||||||
|
updates.push({ id, ...data });
|
||||||
|
return { id, ...data };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The update() call that CLOSED the row (status !== 'running'), i.e. the one written
|
||||||
|
* by DeployRunRecorder.end() -- as opposed to createRunRecorder's own startedAt
|
||||||
|
* update. Same helper shape as tests/deploy-run-record.test.ts's endUpdate(). */
|
||||||
|
function endUpdate() {
|
||||||
|
return updates.find((u) => u.status !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Real deploy-log-store against a throwaway DATA_DIR ----------------------
|
||||||
|
//
|
||||||
|
// DATA_DIR is process-global and read at call time by every other test file too
|
||||||
|
// (tests/fs-guard.test.ts's doc comment, referenced by tests/deploy-endpoints.test.ts)
|
||||||
|
// -- save and restore the prior value so this file leaves no trace once it finishes.
|
||||||
|
|
||||||
|
const previousDataDir = process.env.DATA_DIR;
|
||||||
|
let dataDir: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = await mkdtemp(join(tmpdir(), 'dv-t21-deploy-logs-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
if (previousDataDir === undefined) {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.DATA_DIR = previousDataDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store');
|
||||||
|
const { hashComposeContent, hashEnvFingerprint } = await import('../src/lib/server/deploy-run-record-core');
|
||||||
|
|
||||||
|
// -- $lib/server/stacks: mocked WHOLESALE ------------------------------------
|
||||||
|
|
||||||
|
let saveCalls: Array<{ name: string; content: string; envId: number | null | undefined }>;
|
||||||
|
let requireComposeResult: Record<string, unknown>;
|
||||||
|
let deployStackCalls: Array<{ onLine?: (line: string) => void; build?: boolean; pullPolicy?: string; forceRecreate?: boolean }>;
|
||||||
|
let deployStackResult: { success: boolean; output?: string; error?: string; resolvedSecrets?: string[] };
|
||||||
|
let deployStackOnLines: string[];
|
||||||
|
|
||||||
|
function resetStacksState() {
|
||||||
|
saveCalls = [];
|
||||||
|
deployStackCalls = [];
|
||||||
|
deployStackResult = { success: true, output: 'deployed' };
|
||||||
|
deployStackOnLines = ['Container demo-stack-app-1 Started'];
|
||||||
|
requireComposeResult = {
|
||||||
|
success: true,
|
||||||
|
content: 'services:\n app:\n image: demo:1\n',
|
||||||
|
nonSecretVars: { PUBLIC_VAR: 'v1' },
|
||||||
|
secretVars: { DB_PASSWORD: 's3cr3t-value' },
|
||||||
|
stackDir: '/tmp/demo-stack',
|
||||||
|
composePath: '/tmp/demo-stack/compose.yaml',
|
||||||
|
envPath: '/tmp/demo-stack/.env',
|
||||||
|
sourceType: 'internal'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resetStacksState();
|
||||||
|
|
||||||
|
registerStacksFake('getStackComposeFile', async () => {
|
||||||
|
throw new Error('getStackComposeFile: not exercised here -- only the GET handler uses it, this suite only calls PUT');
|
||||||
|
});
|
||||||
|
registerStacksFake('saveStackComposeFile', async (name: string, content: string, _create: boolean, envId?: number | null) => {
|
||||||
|
saveCalls.push({ name, content, envId });
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
registerStacksFake('requireComposeFile', async (_name: string, _envId?: number | null) => requireComposeResult);
|
||||||
|
registerStacksFake('deployStack', async (options: { onLine?: (line: string) => void; build?: boolean; pullPolicy?: string; forceRecreate?: boolean }) => {
|
||||||
|
deployStackCalls.push(options);
|
||||||
|
for (const line of deployStackOnLines) options.onLine?.(line);
|
||||||
|
return deployStackResult;
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Route under test, imported AFTER all the fakes above are registered ----
|
||||||
|
|
||||||
|
const composeRoute = await import('../src/routes/api/stacks/[name]/compose/+server');
|
||||||
|
|
||||||
|
// -- Fixtures ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STACK = 'demo-stack';
|
||||||
|
|
||||||
|
/** `Accept: application/json` forces createJobResponse's synchronous JSON path
|
||||||
|
* (sse-parser.ts prefersJSON()), so the returned Response only resolves once the
|
||||||
|
* whole operation -- deploy AND recorder.end() -- has actually finished. Without it
|
||||||
|
* the "fire and forget" path would return {jobId} before the row closes, and every
|
||||||
|
* assertion below would race the background completion. */
|
||||||
|
function makeComposeEvent(body: Record<string, unknown>) {
|
||||||
|
const request = new Request('http://x/', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
params: { name: STACK },
|
||||||
|
request,
|
||||||
|
url: new URL('http://x/'),
|
||||||
|
cookies: { get: () => undefined } as any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetDbState();
|
||||||
|
resetStacksState();
|
||||||
|
// nextId always restarts at 1 above, so every test's (at most one) row reuses
|
||||||
|
// runId '1' -- clear its log file so tests don't leak content into each other via
|
||||||
|
// the shared DATA_DIR (same precaution tests/deploy-endpoints.test.ts documents).
|
||||||
|
await deleteRunLog(null, '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/stacks/[name]/compose -- restart:true (Save & redeploy)', () => {
|
||||||
|
test('creates exactly one stack_deploy schedule_executions row, closed as success, with a readable log', async () => {
|
||||||
|
const content = 'services:\n app:\n image: demo:2\n';
|
||||||
|
|
||||||
|
const res = await composeRoute.PUT(makeComposeEvent({ content, restart: true }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).success).toBe(true);
|
||||||
|
|
||||||
|
expect(created).toHaveLength(1);
|
||||||
|
expect(created[0].scheduleType).toBe('stack_deploy');
|
||||||
|
expect(created[0].scheduleId).toBe(0);
|
||||||
|
expect(created[0].entityName).toBe(STACK);
|
||||||
|
expect(created[0].triggeredBy).toBe('manual');
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('success');
|
||||||
|
expect(update?.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const runId = String(created[0].id);
|
||||||
|
const log = await readRunLog(null, runId);
|
||||||
|
expect(log).toContain('Container demo-stack-app-1 Started');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('composeHash is derived from the just-SUBMITTED content, not from requireComposeFile()\'s (possibly stale) content', async () => {
|
||||||
|
const submitted = 'services:\n app:\n image: demo:3\n';
|
||||||
|
// Deliberately different from `submitted` -- if the route ever hashed
|
||||||
|
// composeInfo.content instead of the request body's `content`, this test
|
||||||
|
// would catch it: the stored hash would match THIS string, not `submitted`.
|
||||||
|
requireComposeResult = { ...requireComposeResult, content: 'STALE-CONTENT-NEVER-HASHED' };
|
||||||
|
|
||||||
|
// Draining the body (.json()) is required, not cosmetic: createJobResponse's JSON
|
||||||
|
// path only finishes recorder.end() inside the ReadableStream's start(), which
|
||||||
|
// only runs to completion once the stream is actually consumed.
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: submitted, restart: true }))).json();
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { composeHash: string };
|
||||||
|
expect(details.composeHash).toBe(hashComposeContent(submitted));
|
||||||
|
expect(details.composeHash).not.toBe(hashComposeContent('STALE-CONTENT-NEVER-HASHED'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('envHash covers the MERGE of non-secret and secret vars, same shape deploy/+server.ts already uses', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { envHash: string };
|
||||||
|
const expected = hashEnvFingerprint({ PUBLIC_VAR: 'v1', DB_PASSWORD: 's3cr3t-value' });
|
||||||
|
expect(details.envHash).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a secret value in the deploy error text is redacted before it is stored (the envHash-covered vars double as the redaction list)', async () => {
|
||||||
|
deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' };
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain('s3cr3t-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// F4 fix: deployStack() resolves a bound secret provider's values INTERNALLY, after
|
||||||
|
// the route's `recorder` above was already built from requireComposeResult's DB-only
|
||||||
|
// secretVars/nonSecretVars. A provider-resolved secret (never in that DB-only set) is
|
||||||
|
// simulated via the fake's resolvedSecrets -- exactly StackOperationResult's shape --
|
||||||
|
// and must still be redacted from the stored error, via recorder.addSecrets().
|
||||||
|
test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => {
|
||||||
|
const PROVIDER_SECRET = 'zzz-bulk-provider-7000';
|
||||||
|
deployStackResult = {
|
||||||
|
success: false,
|
||||||
|
error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`,
|
||||||
|
resolvedSecrets: [PROVIDER_SECRET]
|
||||||
|
};
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed deploy still closes the row as "failed", never left "running"', async () => {
|
||||||
|
deployStackResult = { success: false, error: 'exit code 1' };
|
||||||
|
deployStackOnLines = [];
|
||||||
|
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
expect(created).toHaveLength(1);
|
||||||
|
const update = endUpdate();
|
||||||
|
expect(update?.status).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before this fix, restart:true ALWAYS deployed with build:false and no pullPolicy,
|
||||||
|
* regardless of what the caller sent -- a stack whose compose declares a `build:`
|
||||||
|
* section silently never rebuilt on "Save & redeploy", the saved compose change just
|
||||||
|
* ran against the stale local image. These assertions are made at the deployStack()
|
||||||
|
* CALL, not the HTTP response's success flag: a response can be {success:true} even
|
||||||
|
* when build/pull were dropped on the floor (that IS the bug this fixes), so
|
||||||
|
* status:200 alone proves nothing here.
|
||||||
|
*/
|
||||||
|
describe('PUT /api/stacks/[name]/compose -- restart:true build/pull/forceRecreate options', () => {
|
||||||
|
test('build:true in the request body reaches deployStack({build:true})', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, build: true }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls).toHaveLength(1);
|
||||||
|
expect(deployStackCalls[0].build).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting build deploys with build:false -- an explicit opt-out, not a broken default', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls[0].build).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pull:true is translated to pullPolicy:"always" -- deployStack has no separate pull flag, only pullPolicy', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: true }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls[0].pullPolicy).toBe('always');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pull:false leaves pullPolicy undefined (not "never" or "missing") -- deployStack treats a missing pullPolicy as "do not pull", which is also why it skips its own post-deploy pending-update badge reconciliation in that case (stacks.ts, untouched by this task)', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: false }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls[0].pullPolicy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forceRecreate:false is honored -- previously this branch hardcoded true unconditionally, so opting out was impossible', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, forceRecreate: false }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls[0].forceRecreate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting forceRecreate entirely still force-recreates -- preserves the prior hardcoded behavior for a caller that has not been updated to send it explicitly (env var changes need --force-recreate to take effect)', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json();
|
||||||
|
|
||||||
|
expect(deployStackCalls[0].forceRecreate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all three options are recorded on the run (details.options), matching what deployStack actually ran with -- not just passed through in memory', async () => {
|
||||||
|
await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: true, build: true, forceRecreate: false }))).json();
|
||||||
|
|
||||||
|
const update = endUpdate();
|
||||||
|
const details = update?.details as { options?: { pull: boolean; build: boolean; forceRecreate: boolean } } | undefined;
|
||||||
|
expect(details?.options).toEqual({ pull: true, build: true, forceRecreate: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/stacks/[name]/compose -- restart:false (save only, no deploy)', () => {
|
||||||
|
test('creates NO schedule_executions row -- a save without a deploy must not become a run', async () => {
|
||||||
|
const res = await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: false }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).success).toBe(true);
|
||||||
|
|
||||||
|
expect(saveCalls).toHaveLength(1); // the save itself DID happen
|
||||||
|
expect(deployStackCalls).toHaveLength(0); // but no deploy
|
||||||
|
expect(created).toHaveLength(0); // and therefore no run record either
|
||||||
|
expect(updates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omitting restart entirely (defaults to false) behaves identically', async () => {
|
||||||
|
const res = await composeRoute.PUT(makeComposeEvent({ content: 'x' }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(created).toHaveLength(0);
|
||||||
|
expect(deployStackCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('src/routes/api/stacks/[name]/deploy/+server.ts -- untouched by this task', () => {
|
||||||
|
test('still calls createRunRecorder exactly once (source-level regression guard against double-recording)', async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
join(here, '..', 'src', 'routes', 'api', 'stacks', '[name]', 'deploy', '+server.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const matches = source.match(/createRunRecorder\(/g) ?? [];
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4 fix source-level guards. deploy/+server.ts and POST /api/stacks/+server.ts are
|
||||||
|
* not exercised as real integration tests in this file (see this file's top doc
|
||||||
|
* comment for why: the former pulls in $lib/server/audit -> ...license ->
|
||||||
|
* ...notifications, the latter pulls in $lib/server/docker -> hawser.ts ->
|
||||||
|
* ./db/drizzle.js -- neither faked here). A source-level guard cannot prove the wiring
|
||||||
|
* REDACTS correctly (that's what the real integration test above proves, for
|
||||||
|
* compose/+server.ts, which shares the exact same pattern) -- it only proves the call
|
||||||
|
* exists exactly once, so a future refactor can't silently drop it back out. It cannot
|
||||||
|
* distinguish a real call from the same text inside a comment or string; the call
|
||||||
|
* itself deliberately never appears verbatim in comments in either file, to keep this
|
||||||
|
* guard meaningful.
|
||||||
|
*/
|
||||||
|
describe('F4 fix -- recorder.addSecrets() wiring exists on the routes not covered by a real integration test here', () => {
|
||||||
|
test('deploy/+server.ts calls recorder.addSecrets(result.resolvedSecrets ?? []) exactly once, before recorder.end()', async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
join(here, '..', 'src', 'routes', 'api', 'stacks', '[name]', 'deploy', '+server.ts'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const matches = source.match(/recorder\.addSecrets\(/g) ?? [];
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(source.indexOf('recorder.addSecrets(')).toBeLessThan(source.indexOf('}, event.request, recorder)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/stacks/+server.ts calls recorder.addSecrets(result.resolvedSecrets ?? []) exactly once, before recorder.end()', async () => {
|
||||||
|
const source = await readFile(join(here, '..', 'src', 'routes', 'api', 'stacks', '+server.ts'), 'utf8');
|
||||||
|
const matches = source.match(/recorder\.addSecrets\(/g) ?? [];
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(source.indexOf('recorder.addSecrets(')).toBeLessThan(source.indexOf('}, request, recorder)'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Before this fix, POST /api/stacks (Create & Start) ALWAYS deployed with
|
||||||
|
* build:false and no pullPolicy, regardless of what the caller sent -- a stack whose
|
||||||
|
* compose declares a `build:` section silently never built on its first start.
|
||||||
|
*
|
||||||
|
* This is a SOURCE-LEVEL check, not a route-level one, for the same reason
|
||||||
|
* tests/stack-compose-redeploy-run-record.test.ts's own doc comment gives for NOT
|
||||||
|
* importing the dedicated deploy endpoint: importing this route for real pulls in
|
||||||
|
* $lib/server/audit -> $lib/server/license -> $lib/server/notifications/index.ts for
|
||||||
|
* the auditStack() call this route makes -- and, worse, $lib/server/docker ->
|
||||||
|
* hawser.ts -> `./db/drizzle.js`, a SEPARATE module specifier from $lib/server/db
|
||||||
|
* that instantiates a real better-sqlite3 connection at import time (confirmed
|
||||||
|
* empirically: faking $lib/server/db alone is not enough, the drizzle specifier
|
||||||
|
* throws ERR_DLOPEN_FAILED under Bun regardless). Faking that whole chain just to
|
||||||
|
* prove three destructured request fields reach one function call would be a much
|
||||||
|
* bigger maintenance burden than the guard itself is worth.
|
||||||
|
*
|
||||||
|
* What IS covered by a real route-level test: the identical pull/build/forceRecreate
|
||||||
|
* plumbing in PUT /api/stacks/[name]/compose (Save & redeploy), which has no such
|
||||||
|
* import chain -- see the "restart:true build/pull/forceRecreate options" describe
|
||||||
|
* block in stack-compose-redeploy-run-record.test.ts. Both routes were changed
|
||||||
|
* together, from the same requestBody shape RedeployPopover.svelte now sends; this
|
||||||
|
* file's job is only to catch this route's copy of that change regressing
|
||||||
|
* independently of the other.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const routePath = join(here, '..', 'src', 'routes', 'api', 'stacks', '+server.ts');
|
||||||
|
|
||||||
|
async function readRoute(): Promise<string> {
|
||||||
|
return readFile(routePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/stacks -- build/pull/forceRecreate options (source-level)', () => {
|
||||||
|
test('the request body destructuring includes pull, build, and forceRecreate', async () => {
|
||||||
|
const source = await readRoute();
|
||||||
|
// Single destructuring statement for the whole POST body -- match it as one
|
||||||
|
// block so a future refactor that drops one of the three fields (while keeping
|
||||||
|
// the others) still fails this test, instead of three independent substring
|
||||||
|
// checks that could each pass against three DIFFERENT destructuring statements.
|
||||||
|
const match = source.match(/const\s*\{([^}]*)\}\s*=\s*body;/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const fields = match![1];
|
||||||
|
expect(fields).toContain('pull');
|
||||||
|
expect(fields).toContain('build');
|
||||||
|
expect(fields).toContain('forceRecreate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployStack() is called with build and pullPolicy derived from the request, not hardcoded', async () => {
|
||||||
|
const source = await readRoute();
|
||||||
|
const deployCall = source.match(/const result = await deployStack\(\{[\s\S]*?\}\);/);
|
||||||
|
expect(deployCall).not.toBeNull();
|
||||||
|
const body = deployCall![0];
|
||||||
|
|
||||||
|
// build: must reference the coerced request value, not a literal false --
|
||||||
|
// `build: false` would match `build:` alone, so anchor on the variable name.
|
||||||
|
expect(body).toMatch(/build:\s*buildOpt/);
|
||||||
|
// pullPolicy must be conditional on the request's pull flag, not omitted or a
|
||||||
|
// hardcoded 'always'/undefined -- the ternary is the actual translation from
|
||||||
|
// deployOptions.pull (boolean) to deployStack's pullPolicy (string | undefined).
|
||||||
|
expect(body).toMatch(/pullPolicy:\s*pullOpt\s*\?\s*'always'\s*:\s*undefined/);
|
||||||
|
expect(body).toMatch(/forceRecreate:\s*forceRecreateOpt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildOpt/pullOpt/forceRecreateOpt are coerced booleans of the destructured request fields, not the raw (possibly undefined/truthy-string) body values', async () => {
|
||||||
|
const source = await readRoute();
|
||||||
|
expect(source).toMatch(/const\s+pullOpt\s*=\s*!!pull;/);
|
||||||
|
expect(source).toMatch(/const\s+buildOpt\s*=\s*!!build;/);
|
||||||
|
expect(source).toMatch(/const\s+forceRecreateOpt\s*=\s*!!forceRecreate;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the run recorder options match what deployStack is actually called with -- the same three variables, not separately hardcoded values that could drift from the deploy call', async () => {
|
||||||
|
const source = await readRoute();
|
||||||
|
const recorderCall = source.match(/options:\s*\{\s*pull:\s*pullOpt,\s*build:\s*buildOpt,\s*forceRecreate:\s*forceRecreateOpt\s*\}/);
|
||||||
|
expect(recorderCall).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user