mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +00:00
a53d76544cd50a24403aca04f760c97ac0f64407
75 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
392bc15d91 |
feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* feat(git): swap isomorphic-git for native git transport behind clone seam Replace the isomorphic-git engine (HTTP-only, single importer) with the native git CLI behind the existing withClonedRepo seam, so SSH deploy keys, ref semantics, and private CAs become reachable in later PRs. - resolve-before-fetch: ls-remote pins the branch to an immutable SHA, then rev-parse verifies the checkout against it; tip races refuse - hardened spawns: argv arrays only, protocol allowlist (https only), neutralized hooks, isolated HOME and all config channels, no prompts - token reaches git only via a credential helper reading SENCHO_GIT_TOKEN from the child env; never argv or URL - size cap becomes a workspace watchdog (on-disk measure) keeping the same knob and breach message; deterministic final gate added - Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform defaults instead of replacing them - error classification retargets to exit code + stderr while preserving the contractual mappings (AUTH_FAILED maps to 400, never 401; unauthenticated refusals mask as REPO_NOT_FOUND) - runtime image installs git; tests re-pointed at the transport boundary plus a new engine suite (classifier corpus, argv hardening, watchdog) Zero externally visible behavior change except two edge cases: an empty branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses instead of materializing the moved tip. * fix(git): unblock CI on linux kill-path test and codeql log warning Two CI-only findings from the first pipeline run: - The scripted spawn child in the transport tests lacked the kill method that killTree's POSIX fallback reaches when a fake process group does not exist; Linux runs crashed inside the timeout tests while Windows (taskkill branch) could not reproduce it. Give the fixture the method the real ChildProcess always has. - CodeQL flagged the workspace-removal warning that interpolated the NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as sensitive at log sinks). Reword the warning to name the variable instead of its value; operators know their own environment. * fix(git): collapse remaining duplicated test setup so the shared helper is used * fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport Resolves the release-blocking findings from an independent pre-merge audit of the native git transport swap: - A watchdog-triggered kill mid-clone was misclassified as a generic exit failure instead of a size breach, because runGit resolves (not rejects) when the child is killed via SIGKILL. - The final on-disk size measurement failed open when it could not be read (workspace removed mid-walk, permissions), letting an unmeasured clone through as a success. Now fails closed and logs the real cause. - The ref-name validator was an overly restrictive allow-list that rejected valid branch names (leading underscore, non-ASCII, '#'). Replaced with a deny-list matching real `git check-ref-format --branch` semantics, verified against the git binary, including a per-path-segment `.lock` check the first pass missed. - runGit's timeout handler settled as soon as a kill was issued rather than confirmed, racing workspace cleanup against a still-alive child tree. It now waits for the child's close event, with a bounded fallback if termination is never confirmed, and preserves the timeout classification if 'error' fires after the kill. - Windows killTree now also falls back to child.kill() when taskkill itself exits non-zero, not just when it fails to spawn. - Added a real, non-mocked integration test that drives the credential helper through the actual git binary against a local HTTPS server with Basic Auth checking. It caught a genuine bug the mocked suite could not see: the credential.helper config value was quoted in a way that broke git's own absolute-path helper detection, failing every authenticated clone. Fixed by removing the quotes. - Migrated a separately developed test file's mocks off the deleted isomorphic-git module onto the native transport seam, matching the pattern already used elsewhere, after merging with main pulled in that feature. Also updates two stale comments left over from the isomorphic-git era and adds a git version check to the Docker runtime image smoke tests. * fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering Addresses three PR 1 correction items from pre-merge audit: - credential.helper is a shell string, not argv: interpolating the helper's workspace-relative path broke authenticated fetches whenever the workspace sat under a directory with a space in its name. The config value is now a fixed string that names an environment variable instead, so no workspace path character can affect how git's shell parses it. - The transport rejected branch names over 200 characters while the route accepted up to 256 and real git has no comparable limit. REF_MAX_LEN is now a single exported constant shared by the transport and both routes. - On Windows, taskkill runs as a separate process and could still be walking a killed process tree after the direct git child reported closed, letting the caller delete the workspace early. Kill operations are now awaited to completion (bounded by a timeout) before a timed-out or size-breached run settles, on both the close and error event paths. Verified against a real authenticated git server inside the built runtime image: public HTTPS, private HTTPS with a valid PAT, invalid PAT, a deleted branch, an oversized repository, and the awkward workspace-path case, including from a workspace path containing spaces and shell metacharacters. * fix(git): reap killed helpers and classify curl refusals |
||
|
|
38ee4527b1 |
feat(git): add GitOps revision store and Direct apply transitions (#1835)
* feat(git): add GitOps revision store and Direct apply transitions
Adds the canonical GitOps revision state model: schema, store, history,
approval resolution, Direct apply transitions, and a first-cut read
projection. Also widens recovery capture with generation, artifact, and
source-acceptance bindings, rejects credentials and query strings in new
Git repository URLs, and keeps Blueprint and node-label routes on the hub.
* feat(git): add create-from-Git activation and teardown transitions
Adds the durable half of create-from-Git: the single transaction that persists
the application, resolved commit, generation, checkpoint, and candidate
together, and the teardown that tombstones a create which never reached its
success boundary. Adds the staging marker and operation-owned cleanup rules
that decide what a crashed create is allowed to delete.
* feat(git): make create-from-Git crash-safe
Wires the create path through the GitOps state model: the staging marker is
written before the candidate is built, the activation transaction persists the
application, generation, and checkpoint together, and the source row plus
accepted pointers now commit as one success boundary. A create that fails
before that boundary removes only what it staged, then tombstones itself.
The managed-area sweep no longer deletes a directory it cannot prove it owns,
counts in-flight creates as claimed, and is awaited at startup so an
interrupted create is settled before any mutation service runs.
* feat(git): settle interrupted create-from-Git at startup
Adds boot-time recovery for creates a previous process left in flight. A create
whose project was already committed to disk is finished, including its source
row and accepted pointers. Anything earlier has its files removed first and is
then tombstoned, so the stack name becomes usable again instead of staying
locked by a half-created application. A source row that outlived its
application is always preserved.
* fix(git): close create-from-Git ownership and reporting gaps
Removes two ways a failed create could destroy files it did not own: boot
recovery no longer deletes a stack directory the create never recorded making,
and the managed-root teardown is gated on having created that root. Cleanup
now rejects a path that is not a generation directory, so a malformed row
cannot widen the blast radius.
A create that fails during materialization now clears its staging marker, which
previously stayed behind and made the stack name uncreatable until restart.
Generation paths no longer resolve through a module cycle that left them
pointing at a directory that never existed.
Boot recovery restores the deploy spec and manifest cache a finished create
needs, settles each application independently so one bad row cannot strand the
others, and distinguishes a missing directory from one it could not read. A
create that succeeds now clears its checkpoint, and a failure after the success
boundary says the stack was created rather than reporting a bare error.
* fix(git): reap managed areas that nothing has ever claimed
The boot sweep now distinguishes a missing staging marker from an unreadable
one. A missing marker means no stack, no create, and no marker claims the
directory, which is the ordinary orphan a crashed stack deletion leaves behind,
so it is removed as it always was. An unreadable marker is evidence of a claim
that cannot be verified, so the area is preserved.
Without this an orphaned managed area survived every boot forever, since a
completed create deletes its marker and nothing else would ever claim it.
* feat(git): complete the Direct source and target transitions
Adds the remaining Direct events the state model owed: an invalid fetch that
advances the resolved commit without minting a candidate, a blocked candidate
that is visible but cannot apply, dismissal that leaves the running workload
alone, material configuration change that invalidates a staged candidate while
keeping accepted and applied pointers, the two deploy failure classes, and
application and target tombstones.
Widens the application update to every mutable column. It previously wrote
about half of them, so a transition assigning a pause, suspend, recovery, or
intent field would have type-checked, appeared in the history snapshot, and
been silently dropped at commit.
* feat(git): record Direct Git operations in the revision state
Wires fetch, apply, and detach through the state model. A pull records the
fetch and, when it produces a candidate, the generation behind it, marking a
blocked change plan as a blocker that cannot apply. An apply records the
acceptance that binds the generation to the workload, and closes its operation
on failure so a throw cannot leave the source reporting work in progress.
Detach tombstones the application and its target in the same transaction that
removes the source row.
Every producer is a no-op for a stack with no live application, so installs
whose Git stacks predate this model are untouched until migration runs.
* test(git): cover the Direct Git producers end to end
Adds a harness that stubs only the git transport and rollback capture, so
fetch, candidate materialization, change-plan classification, apply, and detach
all run for real and are asserted against the resulting revision state.
Covers the seam the transition-level tests could not: that a fetch advances the
resolved commit without moving the accepted generation, that the apply binds
the exact candidate the fetch recorded and its acceptance proves that
generation and no other, that detach tombstones while keeping configured
identity as a frozen fact, that a failed fetch closes its operation and leaves
every pointer alone, and that a stack with no application produces no writes.
* feat(git): bind the deployed generation from the Compose adapter
Makes ComposeService the sole producer of deploy events. Every deploy path
funnels through deployStack, so recording it there keeps one start and one
terminal row per mutation instead of each caller reporting its own.
A successful deploy binds the applied generation, a failure records the class
and leaves the deployed pointer alone. The class is conservative: once the
compose command is handed off we cannot prove the workload was untouched, and
claiming it was intact is the more dangerous error. A stack with no live
application, or one with nothing applied, has no deploy identity to record and
is skipped entirely.
* feat(git): promote healthy and last-known-good from health verdicts
A stack health run now records which generation it observed, and its verdict
drives the revision state. Promotion is narrow by design: healthy and
last-known-good move only when the run passed, watched the whole stack, and the
generation it watched is still deployed. A stale or service-scoped verdict
records history and moves nothing.
Last-known-good keeps the artifact expectation only when that expectation
belongs to the promoted generation. Otherwise the generation is still good and
its executable identity simply is not proven, so the pointer is left null
rather than borrowed from another generation.
beginStack now takes the deployed generation explicitly rather than reading
current state, because a verdict is only meaningful for the generation the run
actually observed. Update-path callers pass null until updateStack reports what
it deployed.
* feat(git): bind the deployed generation on the update path
Gives updateStack the same treatment deployStack has: it opens a deploy
operation at the point Compose is handed the mutation, closes it on both
outcomes, and reports the generation it bound. The orchestrator carries that
binding through, and all five update call sites now pass it to the health gate
instead of null, so a passed health run after an update promotes healthy and
last-known-good exactly as it does after a deploy.
The operation is opened at the compose call rather than at entry, so an update
that fails during capture or classification records nothing: it never touched
the workload, and reporting a deploy failure for it would make the projection
claim something that did not happen.
* feat(git): track linked stacks and material config changes
Linking an existing stack to a Git source now brings it into the revision
state: the application starts live with nothing desired or accepted, so the
projection asks for a fetch rather than claiming a state it has not observed.
Editing a source's material configuration invalidates its staged candidate in
the same transaction that writes the row and clears the pending pull. A
candidate built from a different repository, ref, or file set can no longer be
applied, and clearing pending without invalidating it would leave the model
offering an apply the operator cannot produce. Credential-only and policy-only
edits change nothing material and leave the candidate alone.
* feat(git): retire the application when a stack is deleted
Stack deletion now tombstones its GitOps application and targets in the same
transaction that commits the deletion. A deleted stack that kept a live
application would go on claiming the stack name and block re-creating it
through the unique live-application index.
The tombstone is driven from the deletion service rather than from inside
DatabaseService, so the store keeps its transitions and their history in one
place and the two modules do not form an import cycle.
* feat(git): retire node targets when a node is removed
Deleting a node now tombstones the GitOps targets that lived on it, in the same
transaction as the delete and while those rows still exist. Otherwise a
part-way failure would leave targets pointing at a node that is gone.
Applications stay live: a Direct application still describes a real stack, and
a Blueprint one may have targets on other nodes. Both the local and remote node
paths go through the same helper, so neither can skip the retirement.
* feat(git): add proof-bound recovery transitions
A restore moves a target back to an older generation, the one case where a
target and its application legitimately disagree about what is current. These
transitions decide what may move with it.
Pointers move only when the restore is provable: the recovery point named a
generation, that generation still exists, and it belongs to this application.
The artifact expectation comes from what the recovery point captured, never
from what the application expects now, and the acceptance is kept only if it
still proves the restored generation rather than borrowing one that authorized
a newer one. A last-known-good survives unless the generation behind it is gone
or turns out to belong elsewhere, and then the reason is recorded so the
projection can say unavailable rather than none.
An unproven restore is still recorded as a real operational recovery and moves
nothing, because there is no evidence to move pointers to.
* fix(git): close GitOps operations that cannot be recorded
Recording a transition still never fails the operation it describes, but a
rejected terminal event no longer leaves that operation open. A start that
never terminates reported work in flight for ever and offered no actions, and
because a new fetch refuses to open a second operation, one rejection silently
stopped the model tracking that stack until a restart.
Startup now reclassifies operations the previous process left open, which the
transition already supported but nothing called. An interrupted restore is
closed there too, since only the terminal recovery events clear it.
The pull records its fetch, generation, and candidate as one transaction, and
the apply verifies the candidate was built from the commit being applied.
Separately those two allowed an apply to accept a generation whose files were
never on disk while the projection reported the older commit as current.
Also: the candidate now records its own invocation rather than the one it
replaces, changing material configuration is refused while an operation is in
flight, a tombstoned target cannot be repopulated by a late health verdict, a
deploy handle is not returned when its start was not recorded, the mutation
handoff is marked only once Compose is genuinely about to receive it, and a
rejected tombstone fails one deletion rather than aborting the boot sweep.
* feat(git): record why unprovable evidence was dropped
The projection carried a limitations array that only read-time derivation ever
wrote to. When a transition cleared a pointer it could not prove, the result
was indistinguishable from that pointer never having existed, and in two cases
it made the target read healthier: a dropped artifact expectation silently
disables the runtime drift check, and an unproven restore left every pointer
agreeing with itself so the target reported as synced and healthy.
Adds a persisted, fail-closed evidence record on the application and target
rows. The transitions that drop an artifact expectation, a last-known-good
artifact, or a source acceptance now say why, an unproven restore is marked as
such, and the deriver folds all of it into the limitations the projection
already exposes. Clearing a code when the evidence becomes provable again is
part of the contract, so a stale doubt cannot outlive its cause.
This lands before the migration matrix because migration is the largest
producer of evidence that cannot be proven, and the plan requires those to
surface as bounded limitation evidence rather than as fabricated pointers.
* feat(git): migrate pre-existing Git stacks into the revision state
Git stacks created before this model had no application at all, so every
producer was a no-op for them and the projection could not describe them. They
are now brought in at boot.
The governing rule is that a canonical pointer is written only when the
evidence proves that exact generation under the repository and ref configured
now. A legacy applied commit is not that proof on its own: the manifest may be
gone, unreadable, or stamped for a repository the stack no longer points at. In
each of those the commit is kept as recorded limitation evidence and the
pointers stay null, so the stack asks for a fetch instead of asserting a state
nobody verified. Deployed, healthy, and last-known-good are never invented,
because a manifest proves what was materialized, not what is running, and no
source acceptance is written because nobody approved through the model.
Replay is decided by scope, schema version, and configuration fingerprint. A
changed fingerprint re-runs the matrix, but a stack that already has an
application is skipped rather than rebuilt, so migration cannot overwrite
pointers written with proof it does not have. A stack whose directory has
vanished migrates to a tombstone rather than claiming a name it cannot back.
* feat(git): record rollbacks in the revision state
A restore now opens a recovery in the model before any file moves, so a crash
mid-restore leaves a target that says what it was doing rather than one that
merely looks broken, and closes it on both outcomes. The failure is classified
by whether the files had already been restored, because only a failure before
that leaves the previous workload provably intact.
Pointers move only when the restore is provable: the recovery point named a
generation, that generation still exists, and the manifest actually restored
carries the same commit and manifest version. Anything less is still recorded
as a real recovery, it simply has nothing to bind, and the transition marks it
unproven rather than guessing.
The deployed pointer is never claimed here. This path drives Compose through a
callback that reports nothing back, so binding cannot be proven and applied
moves without it.
* feat(git): add the deferred rollout-state transitions
Retry scheduling, suspension, pause, and partial rollout complete the
transition store. They have no production writer by design, but implementing
them now means the deriver has no branch a writer cannot reach, and the shape a
future producer has to satisfy is pinned rather than inferred from the read
side.
None of them is a statement about health. A scheduled retry leaves the failure
that caused it visible, so a stack that keeps failing does not read as merely
busy. A suspended source keeps everything it had accepted, and an operation in
flight is interrupted rather than abandoned so it cannot report as running for
ever. A paused or partially rolled out target keeps whatever was deployed, and
the partial record never stands in for a deployed pointer.
* test(git): stop the Compose producer tests reading Docker from the host
Both deploy cases asserted that the compose command would reject, which was
only true on a workstation with no Docker daemon. On Linux the command
succeeded, the promise resolved, and the assertions failed.
The compose subprocess now reports an exit code each test chooses, so the
adapter is what decides the outcome rather than the machine. Only the verbs
that need a daemon and travel through spawn are answered; `config` still runs
for real, because a host with the CLI and no daemon parses compose files
exactly as CI does, and failing that call breaks every create in the file.
That also makes the bound path reachable, so the deploy test now covers it:
a failed compose leaves the deployed pointer alone and classifies
post-mutation, and a successful one binds the applied generation, returns it
for health to bind against, and clears the earlier failure.
* fix(git): contain every managed-area path at the call that uses it
A stack name reaches the managed root without passing through
`isValidStackName` on this path, so the marker, the cleanup, and the create's
root probe all built filesystem paths from unvalidated input. Each call now
resolves its target against the managed area and checks containment in its own
scope, which is also the form the security scan credits: it does not follow the
barrier through the shared `isPathWithinBase` helper, so the check has to sit
with the call it protects.
`cleanupUnclaimedManagedRoot` removes a whole root recursively and gets the
same check, even though it was not among the reported calls.
Clearing a staging marker can now fail, so the two boot-recovery branches that
dropped the checkpoint first were reordered. Losing the checkpoint while the
marker survives would report the area as settled and leave the stack name
uncreatable.
The deploy adapter's two failure logs built their format string from the stack
name; both now use a constant format with the name as an argument.
The marker and cleanup fixtures stood a bare temp directory in for a managed
root, so nothing exercised the invariant these checks enforce. Both now build a
real managed area over a scoped data directory.
* fix(git): refuse to claim a managed area whose marker cannot be read
A marker that exists but will not parse is still someone's claim, yet the write
path refused only a readable foreign marker and wrote straight over a corrupt
one, discarding the reason and logging nothing. A transient permission error, a
marker truncated by a crash between write and rename, or a full disk all read
as corrupt, and each one let a second create take ownership of an area the
first still owned. Every other path in the module preserves on corrupt; this
one now does too, and says why.
Clearing a settled create's staging marker can fail, and reporting that as a
retained create sent a reader looking for an unfinished create that had
finished. It reports `marker_retained` instead. The checkpoint is still kept
for the retry, so its encrypted token can outlive the create while the clear
keeps failing; that tradeoff is stated at the call, because the alternative
leaves a marker no later create can get past.
The reaper also computed why it was preserving an area and threw the reason
away, so a directory could survive every boot with nothing said about it.
The containment checks these paths rest on had no tests and were silently
deletable, so the out-of-area read, write, delete, and reap now have them.
* feat(git): bind and observe a recovered generation
A proven restore could never claim the deployed pointer, because the restore
path drives Compose through a callback that reported nothing back: a caller
that restored some other way resolved identically, so binding on a resolved
promise would have claimed a workload nobody launched. The Compose wrapper now
returns what it did, and only that answer binds.
With binding reachable, a bound recovery claims its health run inside the same
transaction as the pointers it describes, and arms the timer once that
transaction lands. Committing the two together is the point: a crash between
them would otherwise leave a restored workload nothing was watching. Anything
that stops the timer starting writes the run off immediately, so an observing
row never outlives the timer meant to watch it, and a reservation is never
armed across a restart.
The reservation is handed to the transition rather than reached for, because
the health gate reports its verdicts back through the transition store and
importing it there would close a module cycle.
Startup now finalizes each interrupted observation on its own instead of
sweeping them with one update, so the revision state hears a verdict for every
run rather than watching the rows change under it.
* feat(git): add the rollout-scoped rollback transitions
Completes the deferred-state set. The three aliases write the same recovery
columns under the same rules as the recovery events, differing only in
provenance: Direct Git recovery emits `recovery_*`, and a later rollout
producer emits these. Nothing in this PR writes them, which is why they are
tested directly rather than through a caller.
`partial` is the one failure class they add, for a rollback that reached some
targets and not others. Completing has no unproven variant on purpose: a
rollback nobody can bind to a generation has nothing to complete against, so
it refuses without one and refuses a generation another application owns.
Also covers the recovery health reservation end to end: reserving writes and
links the row without arming anything, a replay reuses the run rather than
opening a second observation of the same restore, arming inserts nothing and
supersedes a conflicting stack gate, an unarmed reservation is written off
once, and a reservation that outlived its process is finalized rather than
armed. The per-row startup sweep is pinned too, since one row whose write
fails leaving the others finalized is the entire reason it replaced a bulk
update.
* feat(git): add the Blueprint source and deployment transitions
The first slice of the Blueprint work: the store side, with no production
caller yet. The routes and the reconciler are wired to these next, and pinning
the contract first means a caller cannot quietly satisfy a different one.
What these enforce is that a terminal event names the request it answers. A
deploy records the intent and candidate it was launched for, and a terminal is
accepted only against that same request, matching stage and identity on one
side rather than either in isolation. Without that pairing a deploy could be
acknowledged out of an in-flight withdraw, or one request's intent stored
against another's candidate. An acknowledgement carries the candidate from the
matched request rather than from its own payload, for the same reason.
Releasing an operation now releases its identity with it, and a resolved
interruption is retired rather than left to match again. Both were leaking
through the shared helpers: a stale identity let a later start resurrect a
superseded intent as live, and a surviving interruption both reported
completion as unknown for ever and let a late acknowledgement regress the
target after two later deploys had succeeded. Fixing the helpers fixes the
Direct paths too.
A start refuses to displace an unrelated operation, which would otherwise
abandon it with no terminal event and no history saying so. Observations write
the latest stage, since the placement facet reads it to decide whether a
stateful deployment is waiting on confirmation, and history alone could never
reach a reader.
Minting is left to the caller. A no-op Blueprint edit must mint no intent,
because a fresh identity would invalidate acknowledgements that are still
accurate.
* feat(git): record Blueprint create, edit, pin, and delete
Wires the Blueprint routes to the revision state. Each producer writes the
Blueprint source row and its GitOps rows in one transaction, so an operator
never sees a Blueprint that exists with nothing describing what it means, or an
intent for a Blueprint that failed to save.
The question these answer is when an edit invalidates what the fleet already
acknowledged. Changing the name, the compose content, the selector, the drift
mode, or whether it is enabled changes what nodes run or where, so each mints a
new intent and candidate. Changing the description or the classification
changes how the Blueprint reads and nothing a node can observe, so it updates
the source row alone: minting there would make every acknowledgement report as
stale over a reworded sentence.
An edit is measured by value, not by which fields were submitted. The editor
sends every field on every save, and the source layer decides what to
invalidate from which keys are present, so only the keys that genuinely differ
are passed down. Without that the two disagreed: rewording a description
advanced the revision past the one the current intent describes and cleared the
approval, while this layer classified it as metadata and minted nothing.
Selectors compare by value too, so reordering a list that names the same nodes
is not a placement change.
That measurement also changes one behaviour: a save that alters nothing now
leaves the approval alone, where before any save cleared it. Nothing changed,
so nothing is invalidated.
Pinning revises placement the same way a selector edit does, and re-pinning the
node already pinned is not a change. Deleting tombstones the application and
its live targets so the Blueprint stops claiming its slot, and withdraws
nothing itself, because the route has already done that and doing it twice
would record removals that never happened.
The required node set is stored in a canonical order, so reordering it is not
mistaken for a placement change. A Blueprint that predates the model has no
application yet and is left for migration rather than given a first intent
here, which would claim a starting point for deployments nobody has reconciled.
Desired nodes are passed in rather than computed here: the reconciler that
knows how to compute them reaches this layer, and importing it back would close
a module cycle.
* feat(git): record the placement a label or cordon moves
A label and a cordon say nothing about any one Blueprint, but both change which
nodes a selector matches. Each now revises placement for whichever Blueprints
the change actually moved, by comparing the desired nodes either side of the
write rather than reacting to the event.
That comparison is the whole point. Labelling a node no selector mentions, or
cordoning one no Blueprint wanted, moves nothing, and minting an intent for it
would invalidate every acknowledgement in the fleet over an edit no node can
observe. The same comparison covers a Blueprint pinned to a cordoned node: a
cordon governs automatic placement only, so the pinned target still wants that
node and its set does not move, without needing a case of its own. Nodes are
compared as a set, so the same nodes returned in a different order is not a
change either.
Each route wraps its existing write and the recording in one transaction, so a
recording failure cannot leave a fleet selecting on a label nothing recorded.
The write itself is untouched, which keeps label validation and the per-node
limit where they already live rather than restating them in a producer.
Node deletion already retires its targets, so this adds nothing there.
* feat(git): record Blueprint deployments by what caused them
Every production write to a Blueprint deployment row now goes through one
funnel that records the cause. The cause is carried rather than inferred from
the resulting status, because several causes land on the same one: a deploy
that failed and a withdraw that failed both read `failed`, and they mean
opposite things about whether the deployment is still on the node.
Recording is skipped when the status did not move, so a reconciler tick that
re-asserts a state it already reported does not append a second event
describing the same fact. The write still happens either way.
A terminal answers the request the target says it was given, not whatever the
Blueprint currently wants: an acknowledgement matched against the current
intent would accept work for a revision that node was never sent. The first
deploy to a node creates its target, since a Blueprint application has no
targets until something is sent somewhere.
Recording never fails the deployment. The rollout already happened, and turning
a bookkeeping problem into a stuck rollout would be the worse outcome.
The four reconciler observations record what was seen and nothing else. Preview
cleanup stays outside this path deliberately: it reverses a projection nobody
deployed, so recording it would report removals that never happened.
* fix(git): record an Inline Blueprint coming into existence
Creating a Blueprint inserted its application row directly, so the history
began at the first intent and described an application nothing recorded coming
into existence. Every other application-creation path emits the activation
event, and the event's own definition covers this case: an Inline Blueprint
inserts the application, the first intent, and the first candidate.
Activation goes through a transition now, which also enforces the one thing the
direct insert could not: a Blueprint gets one live application, refused rather
than silently duplicated.
No target is created. A Blueprint application has no targets until something is
deployed somewhere, unlike a Direct one which always has the node its stack
lives on.
* fix(git): file withdraw failures as withdraw failures
Four of the five withdraw failure paths were recorded as failed deploys. Only
the thrown one was tagged correctly, and it is the rare case: a delete-lock
conflict, a network error, a remote 409, and a remote non-200 all return rather
than throw, and all four were filed under deploy.
That inverted the thing this funnel exists to prevent. Recording a deploy
failure clears the in-flight operation, so a routine "another operation is
already in progress" wiped the withdraw that had just started and left the
target reporting a failed deploy while the deployment was still on the node.
A withdraw refused because the on-disk stack is not ours is now its own cause
too, rather than borrowing the deploy-side name conflict.
The status guard no longer suppresses starts. A start writes the identity that
terminals are matched against, so skipping one because the row already read
`deploying` let a later acknowledgement answer a request that had been
superseded, reporting a node as converged on a revision it was not running. The
guard now covers observations only, which is where repetition is the reconciler
re-asserting a state it already reported. A newer deploy supersedes an older
one so a redeploy of a stuck request can take over; anything else in flight is
still refused rather than displaced with no terminal event.
A rejection is logged differently from an infrastructure error, because a
target the model keeps refusing holds its active slot and stops recording
anything further, and that is worth seeing.
* feat(git): migrate pre-existing Blueprints into the revision state
Blueprints that predate the model are brought in at boot: an application, an
intent describing what the Blueprint currently asks for, and a candidate marked
as coming from the legacy inline record.
None of it is an acknowledgement. The Blueprint revision is carried for display
and nothing else, because a revision number is not evidence that any node is
running what it names, and recording it as agreement would report a fleet as
converged on an intent nobody verified. No targets are created for the same
reason, and the candidate's required set is empty: migration records what is
asked for, never which nodes currently satisfy it.
An approval authorizes the intent it was given for, so a Blueprint that was
never approved, or edited since, is recorded as needing reapproval rather than
left blank. Blank would read the same as an approval that is merely absent.
Replay is decided by the same scope, schema version, and fingerprint the Git
migration uses, and a Blueprint the new path already described is skipped
outright, since its rows were written with proof this pass does not have.
Blueprints migrate in their own pass. Coupling them to the Git migration would
let one unreadable Git stack keep every Blueprint outside the model.
* fix(git): contain the blueprint stack-directory probe at its call
The ownership probe resolved a path from the blueprint name and checked it
through the shared helper, which the security scan does not credit as a
barrier. The check now sits with the call it protects, matching the form used
elsewhere for the same reason.
The path was already validated, so this changes no behaviour. It surfaced now
because an earlier commit in this branch rewrote the file's line endings, which
made every line read as new and brought a pre-existing call into the scan's
changed-code window.
* feat(git): read GitOps history and carry revision state on source reads
Adds the instance-local history API and the additive revision fields the
source reads have been missing.
`GET /api/git-sources` and `GET /api/stacks/:stackName/git-source` now
carry `gitopsRevision` and `stackResourcePresent`. Only the instance that
owns the files can say whether a stack directory is really there, so that
answer travels with the response rather than being inferred by a reader
that has never seen the filesystem. It is read through the strict stack
listing: the lenient one answers a failed directory read with an empty
list, which here would read as every stack having vanished and would turn
an unreadable disk into an empty audit trail.
Two history routes land with them: `GET /api/git-sources/history` across
stacks, and `GET /api/stacks/:stackName/git-source/history` for one. Both
page on `(created_at, id)` so rows written inside the same millisecond
survive a page boundary, and both bound how far one request will scan. A
malformed cursor or an unusable filter value is refused rather than
ignored, because answering "show me the failures" with everything reads
as an answer instead of a non-answer.
Authorization is decided per row. A row reduces to a stack read only when
it names a stack, its application is live, and the stack is present on
disk; anything unprovable stays with Admin. The cross-stack routes
classify every row, while the per-stack route is authorized whole by
name, so reading a stack shows its full history including entries from an
earlier application. That distinction is carried in the scope type rather
than a flag, so skipping the row classifier without pinning the query to
the authorized stack cannot be expressed.
History entries record the fields each transition moved rather than a
whole revision, so lifecycle for the classifier comes from the owning
application row. That keeps authorization off the recorded payload
entirely: an entry whose detail cannot be read still returns its identity,
stage, and outcome with a stated limitation, and the decode failure is
logged rather than passed over in silence.
* feat(git): let auditors read GitOps history they cannot tie to a stack
A history entry that cannot be tied to a readable stack fell to Admin,
which left the auditor role seeing exactly what a viewer sees on the one
surface it exists to read.
History entries are an audit trail: insert-only, and recording the actor,
trigger, stage, and outcome of every transition. The request audit log is
already gated on the audit permission, so an entry whose audience cannot
be narrowed now falls there too. Withholding it protected nothing in any
case, since the request log already records that a Git-source mutation
happened and who made it.
The source collection deliberately keeps its Admin fallback. Those rows
are live Git configuration, not a record of events, and a mandate to
audit does not extend to reading the repository, ref, and credentials
policy of stacks that were deleted or never finished being created.
The fallback is now part of the requirement type rather than a role
comparison, so each surface states which audience it falls back to and
the compiler requires every case to be answered.
* feat(git): correct remote node identities on GitOps reads
A remote instance numbers its own nodes and has never heard of the hub's
numbering, so every node id it reports is a statement in its own
namespace. Read straight through, a hub joining two nodes showed two
different machines as the same node.
Four GETs now take a second hop that buffers the response and corrects
those identities: the git-source list and detail, and both history
routes. Everything else keeps streaming exactly as before, which is the
reason this is a separate hop rather than a mode of the existing one:
logs, downloads, and event streams must never be buffered, and the
enumerated positions rewritten here do not appear in them.
Only JSON numbers are replaced. A null node is preserved, since "no node"
is a fact the remote is entitled to state and inventing one there would
claim a placement that does not exist. Strings, application ids, and
stack names travel untouched.
The hub re-authorizes what comes back. A remote authorized its rows for
the machine account the hub proxies with, which says nothing about the
person behind the request, so every row is classified again against the
signed-in user. To make that possible without the hub holding another
instance's database, each history entry now carries the lifecycle of its
owning application alongside the stack-presence answer already there.
Both are validated fail-closed, and a verdict a remote might volunteer is
ignored: the hub decides, the remote supplies evidence.
Asking one node for another node's history is refused before the hop
rather than forwarded, because the remote would answer about itself and
the page would read as an answer to a question nobody asked. When the
node asked for is the node being talked to, the hub translates that into
a flag the remote resolves to its own default node. Only the hub may set
that flag, and a remote honours it only on a proxied hop.
Every way this hop can end converges on one terminal answer: rewritten,
passed through, too large, undecodable, failed upstream, or abandoned by
the client. A body the hub could not read never borrows the remote's
success status.
* test(git): assert the unlinked git-source response field by field
The detach test compared the whole response to `{ linked: false }`, which
stopped holding once that route started carrying the additive revision
fields. The equivalent backend route test was updated when those fields
landed; this one was missed.
Asserted field by field rather than loosened to a partial match, so the
two new fields are actually checked: a detached stack has no application
to project while its directory is still on disk.
* feat(git): carry GitOps revisions on blueprint, node, and drift responses
Completes the additive JSON half of the revision-state read contract, so the
Blueprint, node, and drift surfaces report GitOps state through the same
projection the Git-source routes already return instead of leaving it
unreadable.
Blueprint list, detail, create, update, and pin carry gitopsRevision. Node-label
add, cordon, uncordon, and node delete carry gitopsRevisions, ordered by
blueprint id and covering only the Blueprints the mutation actually moved: an
edit no selector reacts to reports an empty list rather than invalidating the
whole catalog. Node deletion reads its Blueprint owners inside the deletion
transaction and before the tombstone, which is the last moment a target row can
be traced back to an application, and an orphaned target is logged rather than
being silently indistinguishable from a Direct one. Both DELETEs that answered
204 still answer 204.
The drift GET and re-check gain the same field alongside the existing ledger,
which is untouched; no GitOps class is written into stack_drift_findings.
Reads and mutations treat a projection fault differently, on purpose. On a read
the revision is part of the answer, so a fault surfaces. On a mutation the write
has already committed, so the revision is decoration and is built defensively:
letting it throw would answer a successful cordon or a completed node deletion
with a 500, sending the operator to retry a hard delete that already happened
and be told the node does not exist. It degrades to an empty list and logs.
Because both drift routes answer with that projection, the identity hop now
intercepts them too, matching its route table per method instead of rejecting
every non-GET. Left as they were, one object would carry the hub's node
numbering or the remote's depending only on which route asked for it. The
allowlist names a single mutation rather than opening a verb, and the hub's
collection filter still keys off the git-source paths alone, so drift payloads
are rewritten without being re-authorized: they are per-stack and already
authorized by name before the hop.
Tests cover the exact shapes, the preserved 204s, revision ordering, the
empty-list cases, node deletion reporting its tombstoned Blueprints and
surviving a projection failure, and the proxy's new route matching, drift
rewrite, and filter exclusion.
* fix(git): resolve every application a GitOps surface can own
Three gaps found while building the additive JSON, all the same shape: an id or
a sentinel stood in for a row, so "there is nothing here" and "the thing that
should be here is unreachable" collapsed into one answer nobody could tell
apart.
A Blueprint application is stored with stack_name NULL, so no lookup by stack
name could reach it, while the reconciler materializes every Blueprint as a
stack directory of that name. A stack's Drift tab and Git panel therefore
reported no GitOps at all for a stack GitOps was actively managing, while the
Blueprint page reported a live application for the same thing. Stack surfaces
now bridge the two through the deployment row for the asking node. The
deployment row is what makes it safe: Blueprint and stack names share one
namespace, so matching on name alone would let a Blueprint claim an unrelated
stack of the same name on a node it never targeted.
A retired application was unreachable for the same reason, because every entry
point filtered to the live rows. Tombstoning deliberately keeps the configured
identity and SHA pointers as frozen facts so the projection can still say what
an application was, and the source deriver has a not_live status waiting for
exactly that, but nothing could hand it one. A detached Blueprint or stack now
reports what it was instead of reading as one that never existed.
The not-applicable projection was typed so it could not carry a reason, which
made a row that vanished between resolving it and re-reading it by id identical
to a stack the model was never asked about. That one field widens to hold
limitations, and the missing case now says so with the id as evidence. Every
other missing-row path in the deriver already worked this way.
Also logs, rather than silently skipping, a live application whose blueprint row
is gone: with cascade off that is a real integrity fault, the placement did
move, and the response would otherwise report that nothing had.
Cordon and uncordon are deliberately left reporting nothing. A cordon governs
whether new placements may be made, not what a Blueprint asks for, and the
reconciler applies it only to new placements, so revising intent for it would
invalidate acknowledgements fleet-wide over a change that evicts nothing. Two
comments implied otherwise and now state the contract.
* fix(git): keep stack-state resolution out of the Git-source read path
Review of the previous commit found the fallback it added had reached further
than intended. The resolver is now split by the question it answers.
projectStackRevision answers "what Git source is attached to this stack" and
stays Direct-only. projectManagedStackRevision answers "what manages this stack"
and is what the drift routes use. The Git-source routes must not be answered
with another application's identity, and there was a second reason: the row
classifier takes the stack name from the Git-source row but the lifecycle from
whatever the projection resolved, so a Blueprint application arriving there
would have turned an Admin-only row into one any stack grant could read. The
Blueprint that unlocked it could be the very one holding name_conflict because
of that stack.
The Blueprint-to-stack bridge also needed the right predicate rather than a
present deployment row. name_conflict is written precisely when a stack of that
name already exists on the node and Sencho does not own it, so guarding only
against withdrawn admitted exactly the collision the bridge exists to prevent.
It now requires last_deployed_at, which proves this Blueprint wrote the
directory, and excludes name_conflict and withdrawn: the same predicate the
delete and withdraw paths use. Only a live Blueprint application qualifies,
since a retired one has no claim on the directory.
The detached fallback no longer resolves deleted applications. Detached means
the files are still on disk and still describe that stack; deleted means the
stack is gone, so any directory of that name now belongs to something else and
reporting the old repository and SHA against it would disclose one stack's Git
identity through another's name. The authorization side already refused deleted
rows for that reason, so the two now agree. Ordering breaks ties on rowid rather
than a random UUID, and two partial indexes cover the new lookups, which the
existing live-only unique indexes could not serve.
The not-applicable variant's limitations are readonly, so pushing onto the
shared frozen instance is a compile error again rather than a runtime throw.
Tests cover a Blueprint claiming a stack it never deployed onto and one it hit a
name conflict on, a detached Direct source projecting not_live, and a deleted
one staying unresolved for a reused name. Drift fixtures move to afterEach so a
failing assertion cannot leak state into the next test.
* fix(git): stop the stack resolver falling through proven ownership
A second review round found the resolution chain could answer with the wrong
application, and that part of the previous commit was reading a state nothing
writes.
The Blueprint bridge returned one value for two different facts. Once the
deployment row proved a Blueprint had written the directory, a missing
application row for it still answered "no Blueprint here", so the chain
continued to the detached-Direct lookup. A stack that once had Direct Git, was
detached, and whose directory a Blueprint later took over would then report the
old Direct application's repository, ref, and SHA as that directory's state.
The bridge now separates "not mine" from "mine, but broken": the second logs and
returns its own limitation, so the fault is visible and no unrelated identity is
offered in its place.
A Blueprint projection also carried the whole placement roster into a route
authorized by a grant on one stack name. Targets are now scoped to the node
being asked about, which is both the safe answer and the accurate one, since the
question is what manages this directory here.
The detached-Blueprint lookup, its index, and its fallback are removed. Blueprint
retirement writes deleted, never detached, and the only detached writer is the
Direct detach path, so the getter could never match and the index covered an
empty set. The test that seemed to cover it built the state by hand and was
green against something the product cannot produce; it now drives the real
delete route and asserts what that path actually writes.
The Git-source resolver goes back to live applications only. Detach removes the
source row in the same transaction that tombstones the application, so a source
row beside a detached one is not producible, and the route's row classifier
takes its stack name from the source row but its lifecycle from the projection.
Keeping that resolver narrow is what stops a resolution change from quietly
moving who may read a row. The comment saying so is restored and now explains
why it must stay true.
* refactor(git): share the GitOps application fixture across the route tests
Two test files carried near-identical 58-line GitOpsApplicationRow literals that
differed only in a repo URL nothing asserted on. Both now import one helper.
The drift tests also repeated a Blueprint seed and its activation across four
cases, and re-imported DatabaseService per test; those become local helpers and
one hoisted import, which lets the cleanup hook go synchronous. Node lookup
throws on a missing default instead of casting.
projectBlueprintRevisions is module-private again: its only caller is the
committed-projection wrapper beside it.
A third copy of the same fixture remains in git-source-routes.test.ts. It is
left alone deliberately: it pins timestamps at 1 rather than now, and that file
has ordering-sensitive history assertions that would need auditing first.
* feat(git): mirror the GitOps revision contract on the frontend
Adds the client-side read contract for the GitOps revision projection, with
no consumers yet.
types/gitops.ts is a hand-written mirror of the backend read contract, in the
same convention as the other domain type files: the frontend never imports
backend. The projection's two arms are named separately so a component can
take the live one as a prop without re-narrowing, and the absent arm keeps its
absent keys rather than nulls so reaching for a lifecycle status without
narrowing is a compile error. The limitation code stays an open string, since
the backend adds codes without a schema bump and an exhaustive switch would
silently stop rendering the newest ones.
lib/gitopsState.ts is the one place a facet status becomes words and a colour,
so a state reads the same in a sidebar tooltip, a panel banner and the drift
tab. Both maps are keyed on the closed status unions, so widening the mirror
without naming the new state fails this build.
Every line of copy states the condition the deriver actually tests, which is
not always what the status name suggests. recovery_required is a recovery
already running rather than one that is needed. candidate_ready is reached only
when review is not required, so it is ready to apply, not ready to review.
synced_and_healthy is also reached with the health gate switched off and never
compares the deployed generation against the accepted one, so it claims
neither. never_reconciled means nothing has been accepted; a fetch that
produced no materialization still records its commit.
pendingSourceStatus keys "an update is waiting" on the candidate pointer rather
than the status name, because source_reconcile_required is reachable both from
a stale candidate and from an accepted generation with no candidate at all.
Retirement is excluded first: tombstoning keeps the candidate pointer as a
frozen fact, so a stack detached mid-review still carries one.
absentFault separates the two facts the empty projection carries. Empty
limitations means the model was never asked about this stack and the right
rendering is nothing; a non-empty list means an application that was expected
could not be reached. A live application's limitations are caveats on state
that is being reported, not faults, so they are deliberately excluded.
* feat(git): name the Git source state on the stack and sidebar surfaces
The sidebar indicator and the anatomy source row both read a raw pending
commit pointer, so a candidate blocked by local conflicts, held for review, or
stale against the configuration in force all render identically to one that is
ready to apply. The projection distinguishes them; nothing was reading it.
refreshGitSourcePending now derives each stack's state from the projection and
the pending map carries that state rather than a boolean. Presence in the map
still means exactly what it meant before, because it is keyed on the candidate
pointer rather than the status name: source_reconcile_required is reachable
both from a stale candidate and from an accepted generation with no candidate,
and only the first is something to review. One narrow fallback remains, for a
row with no projection at all: a failed GitOps write is logged and swallowed
while the pending commit still commits, so the flat pointer is the only thing
that can answer there. Wherever a projection exists it is the sole authority.
The sidebar keeps one indicator, in the same slot, at the same size and colour,
with the same position in the priority ladder. Only the tooltip changes, so a
blocked plan now says so instead of reading as an ordinary update. A test
asserts the rendered indicator is identical across states, which is what keeps
the rendered sidebar unchanged.
The anatomy source row keeps its pulsing dot alone for the ordinary case and
adds the state word only for the states the dot cannot express. Inline rather
than in a tooltip, since this is the reason something is stuck.
* feat(git): surface derived GitOps state in the Git source and Drift panels
The Git source panel's pending banner had two things it could say, read from a
raw commit pointer and a single blocked boolean. It now names which of four
states the candidate is actually in, so a plan blocked by local conflicts, one
held for review, and one gone stale against the configuration in force stop
rendering as the same ordinary update. The short commit sha stays.
A new card above it reports an application the projection could not reach. That
case renders as nothing at all today, which is indistinguishable from a stack
the model was never asked about. Empty limitations stays silent, because that
is the ordinary answer for most stacks and a header over an empty block would
be worse than nothing.
The summary block gains one row naming the source state. It is the first place
the panel can show applying, retry scheduled, suspended, recovering, or a
detached source, none of which have a pending commit and so never reach the
banner. Both existing rows are untouched and the last applied commit stays a
display fact.
The revision lives in its own state rather than on the Git source type, because
the PUT that saves this panel answers with a bare source and no revision. A
save drops it instead of rendering a state the write has already moved.
The Drift tab gains a third block below the two it already has. The compose
versus runtime card and the since-last-deploy card keep their exact positions
and copy; the new block answers a different question, which generation this node
was asked to run and whether it got there. A Blueprint-owned stack shows its
target rows and no source card, since a Blueprint application has no Git source
and inventing one would be a claim the model never made. The drift class list
renders against the type and expects no rows, because nothing populates it yet.
* feat(git): declare revision fields on the blueprint and node clients
Types first. The blueprint list, detail, create, update and pin responses all
carry a GitOps revision, and the node label add, cordon, uncordon and delete
responses carry a list of them. None of that was modelled, so the fields were
being dropped silently and the next reader would have taken these for bare
records.
Most of it is declared and deliberately unread, with the reason written where
the type lives. Create, update and pin are followed immediately by a re-read of
the catalog or the detail that carries the same projection, so rendering the
mutation's copy would show the same fact twice with one of them stale. Cordon
and uncordon always answer with an empty list by design, because a cordon
governs whether new placements may be made rather than what a Blueprint asks
for, and the reconciler leaves existing deployments where they are. The comment
on that type is the point: the risk there is a future reader building a
consumer for a list that is never populated.
Two places do report something. Deleting a node and adding a node label both
re-place Blueprints, and both now say how many. The count only, and only when
it is not zero: an empty list means both that nothing moved and that the
projection faulted after the write had already committed, so it can never be
reported as the first.
Blueprint detail gains the same unreachable-application card the Git source
panel has. A Blueprint with no live application row is a fact nothing in the
product could previously express, which is a different problem from having two
ways to say the same thing.
* fix(git): close the gaps review found in the GitOps frontend consumers
Six defects, each verified against the code before being fixed.
The pending-map read assumed every /git-sources row carries a projection. That
route is proxied, so a node predating the model answers rows without one, and
dereferencing it threw inside the loop. The catch swallowed the throw, the map
was never set, and every stack's Git indicator froze at its previous value for
as long as that node was selected. The field is optional now and a row without
one falls through to the same branch as a row with nothing to project.
That fallback also branched on the arm alone, so a projection reporting an
unreachable application plus a stale commit pointer was reported as a candidate
ready to apply. It now excludes a faulted projection: a fault means an
application was expected and could not be read, so the pointer is not evidence
that anything is ready, and naming a state there would be a guess.
Detaching a Git source cleared the source but not the revision, and the pending
card is derived from the revision alone. The panel kept advertising a waiting
commit for a stack Git no longer managed, behind a Review button that silently
did nothing. A read that threw had the same problem across stacks: the panel is
reused, so stack A's state could render under stack B's header.
The panel also lost the flat-pointer fallback the sidebar keeps, so the two
surfaces disagreed: the sidebar would show an indicator and clicking through
showed no card. Both now apply the same rule.
Node delete parsed the success body unguarded. The delete has already committed
at that point, so a malformed body would have reported a completed deletion as
a failure and skipped the refresh, leaving the deleted node on screen.
Tests: five mutations that previously survived now fail. The zero-pixel claim
compared the wrong element and passed when the indicator vanished entirely; the
stale-read test asserted during the loading window, when the body is skeletons
regardless; the sidebar passthrough test proved only that an indicator existed,
not that the state reached it. Added coverage for the drift row, an unknown
node, the not-applicable source guard, the save clearing, the Review button, and
a live application carrying a caveat, which must not read as a fault.
The fixture set drops two source statuses whose identity defaults describe a
state they cannot be in, and gains a drift-item builder.
* refactor(git): share the GitOps fault card and live-facet derivation
The unreachable-application card was built inline on three surfaces, and the
copies had already drifted: the Git source panel used one icon while the Drift
tab and the Blueprint sheet used another, so the same failure rendered two
different ways. It is one component now, which also owns the state key and the
test id those surfaces assert on.
Deriving the live source facet was likewise repeated, along with the two
semantic decisions behind it: the absent arm has no facets, and a source facet
of not applicable means a Blueprint owns the stack rather than that something
is missing. Both now live next to pendingSourceStatus, which already encoded
the same exclusion.
The Git source panel's seven derived values collapse to three, with the
pending-commit rule extracted to a named function so the four cases read
straight through instead of as nested ternaries. Behaviour is unchanged in
every case.
The two flat-pointer fallbacks are deliberately not unified: they look alike
but the panel also treats a live application whose source facet is not
applicable as unanswered, and merging the predicates would change what that
case renders.
* feat(git): count and announce committed GitOps transitions
Every history row that is actually inserted now produces one metric
increment and one state-invalidate event, so the surfaces that read GitOps
state hear about a change instead of waiting for the next poll.
Announcement is buffered and drained on a macrotask rather than fired
inline. better-sqlite3 is synchronous, so waiting for the macrotask puts
the drain after the transaction that wrote the row, and after any outer
transaction wrapping it, without having to detect which nesting depth it
is in. The drain confirms each row is still present before announcing it,
so a rolled-back transaction says nothing on its own, and the insert
declines to queue a dedupe replay, so a retry says nothing either. The
broadcaster is injected at startup rather than imported, keeping the
GitOps layer free of a cycle back into the notification stack.
Counters are process-local and in-memory, and their keyspace is finite by
construction: history stages are now a closed union that the build
enforces, and outcomes were already a closed set. The payload names no
stack, node, repository, or actor, because a counter carrying those would
be an audit trail with no retention rules and no per-row authorization,
which is what the history routes provide. GET /api/gitops-metrics is
Admin-only and instance-local, so selecting a node answers with that
node's counters.
On the client, a gitops-scoped invalidate refreshes the derived state
through a 250ms trailing window, matching the existing stack refresh. One
operation commits several transitions in a row and a first-boot migration
commits a great many, so refetching per event would thrash the API for a
picture that only settles at the end.
* feat(git): show GitOps source state on the stack dashboards
The dashboards list every stack on a node, which is where a fleet-wide
reading of Git state is most useful and where, until now, a Git-backed
stack looked exactly like a local one. Each row that the model has
something to say about carries a chip naming its source state.
The chip reads from the same status vocabulary the panels and the sidebar
use, so a stack cannot be "pending update" in one place and something else
in another. It sits beside the stack name rather than replacing the source
column: the column says where the files come from, the chip says what
GitOps makes of them, and those are different facts. The label is a word
and the title is a whole sentence, so the state never rests on colour
alone; the phone rows keep the word on screen, since touch has no hover.
The join is by stack name, which is what the dashboards have, and its
source is the same proxied route the sidebar reads. A row without a
revision, a Blueprint-owned application, and a projection fault all leave
the stack unbadged rather than inventing a state for it. State arrives by
announcement rather than by poll, since it only moves on a transition, and
a node switch blanks the map first: stack names repeat across nodes, so a
slow answer for the node just left is discarded rather than allowed to
label the wrong stacks.
* feat(git): say which part of a GitOps state could not be proven
Eighteen conditions can qualify a live projection: a manifest that does
not match the branch configured now, an approval that could not be
restored after a recovery, an artifact record that has gone. Every one was
already recorded and none of them reached the operator, so a state with a
hole in it read exactly like one without.
Each now has operator wording under the state it qualifies, checked
against the site that emits it rather than against the code's name, since
several names describe something narrower or wider than the condition
actually tested. The stored messages stay out of the UI: they are written
for a log reader, and some are raw decoder errors.
The presentation is deliberately quiet. A caveat is not a failure: the
state above it is real, and one piece of evidence behind it is missing, so
the reader learns which part to distrust without being told the whole
thing is broken. Faults keep their own card, because those replace the
state rather than qualifying it, and the two arms are read through
separate helpers so neither can be rendered as the other.
A code with no wording names itself rather than vanishing, so a node
running ahead of this build degrades to something honest instead of
reporting full confidence in a state its own backend flagged.
* fix(git): record a stateful first placement and keep a half-built Blueprint editable
Two faults that today's product cannot reach, and that the Git-backed
Blueprint mode would reach immediately.
The live-application lookup answers with an application that is active or
still being created, because its other callers ask whether the Blueprint
already holds the live slot, where a half-built row counts. Three
producers passed that answer straight into transitions that accept only an
active application and reject anything else by throwing, inside the
caller's own transaction. A Blueprint edited or pinned while its
application was still being created would have failed with a server error
and rolled the Blueprint write back with it. The producers now narrow to
what they actually require, through one shared predicate, leaving the slot
check honest.
Separately, a stateful placement is held for operator review before
anything is deployed, so no target exists when that hold is recorded, and
the observation was dropped for want of one. The hold left no trace: no
history row, and nothing on the target to say the node had been asked to
hold anything. First contact now creates the target, exactly as the first
deploy does. Nothing else changes with it: no intent, no generation, no
operation, and connectivity stays unset, because a node that has only been
asked to hold something has not been contacted. Observations that follow a
deploy already have a target, so they are unaffected, and a drift or evict
report for a node nothing was ever sent to is still dropped.
* fix(git): keep the Git source panel's state after a save
The save answers with the source row and no revision, so the panel had
nothing to replace its copy with and dropped it. The result was a stack
that had just been reconfigured showing no GitOps state at all until the
panel was reopened, which reads as a stack the model knows nothing about
rather than one whose state has just moved.
It re-reads instead. Keeping the old copy was not an option either: a
material configuration change clears the staged candidate server side, so
the state genuinely has moved and the panel would have gone on offering a
commit that is no longer there. Only the server can say what replaced it.
The alternative was returning the revision on the save itself. That route
is proxied, and the identity hop rewrites node numbering for an enumerated
set of routes, so adding a revision to a response outside that set would
hand back a remote node's numbering unrewritten. Re-reading costs one
request on a low-frequency action and needs no change to the hop.
* docs(git): document what GitOps state means for an operator
A commit SHA says which files Sencho wrote. It does not say whether that
commit was accepted, whether something newer is waiting, or whether an
operation was interrupted halfway, and until now the docs had no words for
any of that.
Git Sources gains a source-state table and a section on what happens when
part of a state could not be proven, since a stack with missing evidence
looks identical to one with complete evidence unless the product says
otherwise. Drift Detection gains the gitops section and explains why it
answers a different question from the two signals above it: those compare
files and containers as they are now, this reports what has been proven
over time. The dashboard page describes the state chip and, importantly,
that it and the row tint are independent, so a healthy row carrying
"pending update" is not a contradiction.
The Blueprint page now separates its revision counter from GitOps state.
The counter labels the spec; it says nothing about whether a given node
has got there, and reading it as fleet truth is the mistake the note
prevents.
The tutorial's verification step names the source state it should show,
so a reader can tell the difference between files written and a commit
accepted.
Audited and left unchanged: health-gated updates and fleet federation.
Neither presents a SHA or a revision as canonical GitOps truth, so neither
needed correcting.
* docs(git): name which saves actually clear a staged commit
The note claimed saving the form always clears the staged commit and moves
the state to reconcile required. Only a change to what gets materialized
does that: the repository, the ref, the compose paths, the project
directory, or the env sync. Changing the token or the apply behavior
leaves a staged commit alone, because neither changes what would be
written, and telling an operator otherwise would have them pull again for
nothing.
* fix(git): say when a committed transition has nobody to announce it to
Two silences worth breaking, both found reviewing the error paths in this
slice.
A server that never installs the event sink still counts every transition
and still writes every history row, so the only symptom is that no client
ever refreshes and the UI is quietly as stale as it was before any of this
existed. It now says so, once rather than per row, since a boot migration
would otherwise fill the log with one fact.
The dashboard's source-state fetch dropped a non-ok response without a
word. A refusal there freezes every badge at its last value, which looks
exactly like a fleet where nothing has changed, so the status code is
logged rather than inferred from badges that stopped moving.
* fix(git): close the defects review found in the announcement slice
Five findings, two of them able to reach an operator.
The event sink was installed one line after the deletion reconcile, which
tombstones applications and targets and awaits inside its own loop. A
drain therefore landed while the sink was still absent, so those boot-time
transitions were counted and never announced, and the warning added for an
unwired sink would have fired on every boot with a prepared deletion
intent: the fastest way to teach an operator to ignore it when it means
something. The sink now precedes every reconcile and migration pass.
The first-placement target was created outside the transition that records
it. The observation runs in its own savepoint and can refuse, and the
caller deliberately lets the deployment commit whatever the record says,
so a refusal left an active target with no generation, no stage and no
history: a placement relationship the model never established, which the
delete path would later tombstone as if it were real. Both writes now
succeed or fail together.
The badge looked its status up in a map closed at compile time, behind two
casts, while the value arrives over a proxy from a node that may run a
newer vocabulary. An unmapped key dereferenced undefined inside a stack
row and would have taken the whole table down with it. The facet and its
status are now a discriminated union, the lookup is optional, and an
unrecognised status renders nothing, which is what the join already does
for a stack it has no state for.
The dashboard's source-state loop derived each row inside the shared
try, so one row in an unexpected shape abandoned the loop and froze every
badge at its last value with nothing on screen to say so. Each row is now
guarded on its own and the count of unreadable ones is logged.
Two tests claimed things they did not check: both asserted a word was on
screen with a matcher that also matches screen-reader-only text, so a
compact badge would have kept them green, and one compared a label by
containment where the shorter label is a prefix of the longer. Both now
assert the visible node by equality. Added coverage for the window event
the dashboard actually refetches on, an unknown status, a row that cannot
be read, node-switch blanking, and a caveat recorded twice. One fixture
built a status pair the deriver cannot emit and was corrected.
Also: the guard narrowing Blueprint producers to an active application
excludes nothing today, because every Blueprint-mode application is
inserted as active and only Direct mode can be creating. The guard stays,
since the getter's slot semantics and the transitions' requirement have
drifted apart once already, but its rationale and its tests now say
plainly that they pin a defensive guard rather than reachable behaviour.
* refactor(git): make three runtime guards visible to the compiler
Type review found the same shape three times: a correct guard whose
necessity was asserted in a comment the type system could not see. This
project does not set noUncheckedIndexedAccess, so each of these read as
dead code to anything that trusts the types, and the guards protect the
behaviours that matter most here.
The limitation copy map typed its values optional, so the fallback for an
unrecognised code is now something the compiler requires rather than
something a comment explains. The badge reads its status through partial
views of the two vocabularies, so a miss is a fact TypeScript derives:
deleting the guard that stops one unknown status taking down a stack list
now fails the build, where before it compiled. Neither needed a cast.
The event payload has to be a type alias rather than an interface, because
only the former gets an implicit index signature and the broadcaster takes
an open envelope. That requirement now fails at the declaration instead of
surfacing in the startup wiring as an unexplained index-signature error.
Also added the reverse of the copy-coverage check: a code retired from the
backend leaving stale wording behind was invisible, since the fallback
only fires for entries that are missing rather than ones that linger.
* fix(git): make the state cards survive an unknown status, and correct the copy
Comment review found the protection added for the dashboard chip was
claimed more widely than it held. Five other surfaces indexed the status
vocabularies directly and passed the result straight to a card that
dereferences it, so the exact input the comments said would take a list
down still threw in the Git source panel, the Drift tab, the sidebar
tooltip and the stack anatomy row. The card now takes an optional state
and renders nothing without one, which also means a new surface cannot
reintroduce the dereference by rendering a card the ordinary way, and the
remaining four read through the lookups.
Copy corrections, each against the site that emits it rather than the
name of the code:
The manifest-identity caveat said the manifest named a different
repository or branch. It also fires when the manifest has no identity
block at all, or identifies another node or another stack, so it now says
it does not identify this stack on this node from the repository
configured now. The last-known-good artifact caveat covered a mismatch but
not the row being gone, which is the other half of the same condition.
Doc corrections: a Blueprint-delivered stack was described as carrying the
dashboard chip and a source state on the Drift tab, and it carries
neither, because a Blueprint has no Git source of its own. Per-node
Blueprint state was attributed to the detail sheet's GitOps section, which
only reports what could not be read or proven; the Deployments table is
where per-node status lives. Clearing a staged commit was said to always
land on reconcile required, but a stack that has never had a commit
accepted lands on never reconciled. The caveat lines were described as
sitting under the state when they render above the form. And the pending
banner was still documented as webhook-only, which this branch changed:
it now appears for any staged commit and its heading is the source state.
Also corrected two claims in the code: the observation branch does not
skip for a Blueprint that migration brought in, because migration creates
no targets, and the only refusal reachable from that call site is a
tombstoned target.
* refactor(git): simplify the announcement slice without moving any behaviour
A simplification sweep over the new code, all of it behaviour-preserving
and re-checked by breaking each guard afterwards.
The drain now separates policy from mechanism: it decides whether a row
survived, counts it, and either warns or hands it to a small announce
helper that owns the envelope and its error handling. The row-to-state
pass in the dashboard hook lifts out of the fetch, so one function does
the request and another does the derivation. The caveat dedupe collapses
to a set, which preserves first-insertion order and so keeps the ordering
its own docstring promises. The badge renders one span with a conditional
class rather than two that differed only by class. The metrics service
drops an internal type that was byte-identical to the one it exports.
One assertion goes: the observation branch tested membership and then
re-asserted the same fact to index the stage map, and a type predicate
lets the compiler carry it instead.
Everything the sweep was told to leave alone is intact: the buffered
drain, the row-existence re-check, the injected sink, the one-shot
warning, the partial-view lookups and their guards, the optional copy map
with its fallback, and the generation counter with its per-row guard.
* fix(git): close audit findings in the GitOps revision model
Five corrections from the pre-merge audit of this branch.
Migration no longer certifies a commit it cannot prove. The manifest read
now carries its resolved commit, and a manifest that names a different
commit than the source row records as applied is refused: the applied
directory materializes one commit while the row names another, so
trusting them together would mint a generation pointing at files that are
not the ones it claims. A manifest that names no commit at all, which is
what adoption writes, is a separate answer rather than a disagreement,
because reporting it as one would name a commit the manifest does not
contain. Both are recorded as evidence and the projection asks for a
fetch.
Create cleanup proves containment against the real filesystem before it
deletes. Every check on these paths was lexical, so a symlink or Windows
junction above the target read as contained while the recursive delete
followed it out of the managed area. Resolution keeps "is not there"
apart from "could not be read": only a genuinely missing path lets the
walk climb to an ancestor, because treating an unreadable one as absent
would infer containment for the single path whose link status could not
be established. Both sides are resolved, so relocating the data directory
onto another volume keeps working, and an area that does not exist at all
is nothing to delete rather than a suspected escape. The marker write
takes the same barrier as the marker delete, so a link cannot be written
through and then refused on the way out, wedging the stack name.
Startup stops while an interrupted create is unresolved, matching the
restore reconcile above it. A create that could not be settled leaves a
stack directory the deploy path cannot tell apart from a finished one, so
the alternative to stopping is letting a scheduler, webhook or operator
act on a half-built stack. Only that blocks: once the staged directories
are gone the create is torn down, and a staging marker nobody could
unlink is reported rather than thrown, so one failed unlink is not the
difference between an instance that boots and one that does not.
Per-stack history no longer exposes a predecessor through a reused stack
name. The grant covers the application holding the name now, which is
what keeps a stack's own entries readable while it is still being
created; every other row on that name is classified per row, so entries
belonging to an application that held the name earlier need the audit
permission, as they already did across stacks. A detached predecessor is
still readable, which is the classifier's own standing decision about
detach rather than a gap here, and is now pinned by a test.
Blueprint observations reach a reader. The reconciler recorded state
review, evict blocked, drifted and correcting against the target and
nothing projected them, so a deployed Blueprint could report itself as
never applied. The runtime facet now projects all four, below the states
a live or failed mutation puts the target in and above the pointer
checks, and any later transition supersedes the observation. The map is
declared total over the stages the reconciler can record, so a stage
added without a projection fails the build rather than silently reading
as never applied again.
* fix(git): confine managed-area cleanup to each stack's own location
Cleanup proved only that a resolved path landed somewhere inside the
managed area. That is satisfied by every other stack and every other node
in it, so a link from one stack's generations directory into another's
passed the check while the delete took a generation belonging to someone
else. The check is now positional: the managed area is resolved once, so
relocating the data directory onto another volume still works, and every
segment below it must be reached without redirection. The create-path
sinks use it on the write as well as the delete, so a claim cannot be
written through a link and then refused by the hardened delete, and the
manifest service's pre-existing deletion sinks (generation pruning,
boot-sweep orphan reaping, detach staging and finalization, and
whole-area removal, including after a restored snapshot) run it before
their recursive deletes too. Each of those sinks also keeps a literal
containment comparison beside the positional check, because static
analysis credits only a comparison at the call and reports the delete
otherwise. A refusal names both paths in the log, because it can hold
the boot gate.
Boot recovery dropped the checkpoint of an application that had left the
creating state without first clearing its staging marker. That left a
claim on the stack name with nothing to retry it, and every later create
for that name was refused by a marker nothing could remove. The settled
branches now share one exit, so the marker ordering holds for all of them.
A detached application's history was readable on a stack grant because
its files are still the stack standing at that name. Nothing in these
tables can prove it still is: a Blueprint successor records the name off
the application row, and a plain Compose stack recreated at the name
leaves no trace at all, so no detection-based allowance can be made
sound. Detach now moves an application's trail to the audit audience
outright, the same answer deleted and creating predecessors get, while
reading the stack itself stays where it was.
* fix(gitops): close four audit findings in revision state and identity proxy
A1: derive runtime and health against target desired_generation_id
- deriveRuntime returns 'applied_not_deployed' when desired != deployed, so a
stale deployment stays deploy-pending instead of reading synced_and_healthy
- deriveHealth judges against desired_generation_id falling back to deployed
when null; control case proves null-desired rows unchanged
- collectRuntimeDrift emits the plan-pinned runtime drift item for exact/
qualified observation mismatches; equal/non-comparable observations emit
nothing; ordering pin keeps deploy question first
A2: stop upstream 304 responses from bypassing hub rewrite and reauthorization
- identity proxy answers no-store on every terminal (rewrite, 204, 304,
generated failures); validators (etag, last-modified, cache-control,
expires, vary) no longer forwarded
- conditional request headers (if-none-match, if-modified-since, if-match,
if-unmodified-since) stripped on the identity-hop branch of the shared
proxyReq handler; streaming hop untouched (optimistic-concurrency file writes
depend on If-Match/If-Unmodified-Since)
- supertest integration test drives the real middleware through a loopback
capture server and asserts if-none-match absent while accept survives
S1: migrate legacy query/userinfo URLs via secret-free path
- parseLegacyRepoUrl strips userinfo, query, fragment instead of refusing;
parseHttpsRepoUrl remains the strict gate for user-driven paths
- migrationDirectSourceIdentity uses the tolerant parser; operational
stack_git_sources.repo_url untouched; fingerprint convergence proven
- trusted-manifest-on-legacy-URL test covers the worst real-world instance
S2: emit runtime artifact drift for current evidence
- top-level drift array carries the seven-class runtime item; frontend
comments updated to reflect backend now emits runtime drift
All regression tests added and passing. CI green (7,374 backend + 2,790
frontend tests; only pre-existing Windows EBUSY teardown failure unrelated).
* test(frontend): fix DriftPanel drift item rendering test
Update test assertions to match the new backend drift item format.
The function now renders artifact_set expected
identities as 'artifact <id> · <qualification>' and runtime_artifact
observed identities as the raw identity string.
* fix(gitops): emit a runtime drift item for desired-versus-deployed mismatch
The projection reported applied_not_deployed with no entry in drift, so
the canonical drift list contradicted the runtime facet it travels with.
collectRuntimeDrift now emits the r27 generation-mismatch item for that
state: desired generation as expected, deployed generation as observed,
ComposeService as owner. The action mirrors what availableActions offers:
deploy, unless an application-level fetch or apply is in flight or a
recovery is in progress, which withhold deploying without removing the
fact of the mismatch. The item clears once the desired generation deploys.
The stale-deployment test pins the exact item shape, its stability across
re-derivation, its convergence removal, and the withheld-action case; the
ordering pin keeps the artifact observation suppressed while the deploy
question stands. Frontend fixture aligns its artifact-mismatch example
with the backend's none action.
* fix(gitops): keep generation drift visible across failure and pause states
The desired-versus-deployed drift item was keyed to the applied_not_deployed
runtime status, but paused, failed, recovering, interrupted, and in-flight
statuses all outrank the pointer comparison in the deriver. A failed deploy
of a new generation over a running older one therefore dropped the drift
report exactly while the old workload was still serving.
The item is now judged from the pointers themselves: known and different
means reported, whatever presentation status the target carries. Retired
targets are excluded because nothing can rebind them, so their surviving
pointer divergence would be permanently unresolvable noise. The action
follows what the payload offers: deploy only when no application-level
operation or recovery withholds it and availableActions lists deploying,
none otherwise.
The failed-redeploy regression test pins the exact item shape after a
pre-mutation deploy failure, its stability across re-derivation, continued
artifact suppression for the replaced workload, and convergence removal;
a second pin keeps retirement silent.
* fix(gitops): complete the available-action legality matrix
The action list transcribed only part of the approved rules. An
interrupted apply offered apply without checking that its recorded
generation was still the current candidate; deploy fired for any target
reading applied_not_deployed regardless of target mode, with no retry
for an interrupted Direct deploy and none of the Blueprint interruption
rules; approve_legacy sat in the type union with no producer, leaving a
pending Inline placement review permanently unactionable through the
projection; and drift items inherited whatever deploy recommendation any
sibling target earned.
Actions now follow the matrix. Deploy is decided per target through one
predicate shared by deriveActions and the drift items: a paused or
failed sibling can no longer inherit another target's legal deploy, a
Blueprint-mode divergence never advertises Direct deployment, an
interrupted Direct deploy retries against the generation still applied,
and an interrupted Blueprint deploy or withdraw repeats only while its
recorded intent revision and rollout candidate still equal what the
application requires, where an absent pair matches because inline
Blueprints carry no candidate until the later-phase producer lands.
Interrupted apply requires the recorded generation to still be the
current candidate and the source not to have been suspended meanwhile.
A reachable Inline placement review now offers approve_legacy.
Six tests pin the matrix: sibling isolation across failed and paused
targets, the Blueprint mode guard, vacuous and matched Blueprint retry
identities plus the superseded case, matching-versus-stale Direct deploy
and apply interruptions, and the legacy review action.
* fix(gitops): withhold apply retry while the candidate is blocked
The interrupted-apply retry checked that the recorded generation still
matched the current candidate and that the source was not suspended, but
a later classification can also block that candidate, and applyStarted
refuses a blocked one outright. The retry is offered only when every
precondition the transition enforces still holds.
* fix(gitops): prove apply preconditions and limit fetch to Direct
The interrupted-apply retry checked identity, suspension, and blockage
but never loaded the candidate generation, so apply could be recommended
for a row that was missing, owned by another application, or built from
a superseded materialization fingerprint; applyStarted refuses all
three, and the positive fixture itself described a state no transition
would accept. The gate now proves existence, ownership, and fingerprint
before offering apply. Its fixture is rebuilt around a transition-legal
candidate whose projected action is executed against applyStarted, with
negatives for every refusal including suspension.
Fetch was offered to any application whose source looked unreconciled,
but the approved rules reserve fetch for live Direct applications; a
Git-backed Blueprint divergence now advertises nothing of the sort.
Controls pin both modes against equivalent source state.
* fix(gitops): fail closed when a candidate row is missing or foreign
Ordinary candidate_ready checked a fingerprint only when the candidate
generation row existed, so a dangling id or one owned by another
application fell through to ready and offered an apply that
applyStarted would refuse on sight. The source now requires the row to
exist under this application before readiness, records a limitation
for the anomaly with its operator copy mirrored in the frontend, and
reports reconcile-required instead. The Blueprint retry test also pins
that a superseded rollout candidate alone, with the intent still
matching, suppresses deployment.
* fix(gitops): fail accepted-generation derivation closed
The acceptance branch compared fingerprint and sha only when the
accepted generation row loaded, so a dangling id or one owned by
another application fell through and reported
application_generation_accepted with no evidence behind it, while also
withholding fetch as the recovery. The source now requires the row to
exist under this application before any comparison, records an
accepted_generation_invalid limitation naming the pointer with its
operator copy and inventory entry mirrored on the frontend, and
reports reconcile-required instead. Five scenarios pin valid, missing,
foreign, fingerprint-mismatched, and sha-mismatched acceptances.
* fix(gitops): enforce canonical state in dismiss, reconcile, and pulls
- Route dismiss-pending through the canonical dismissed transition so a
refusal while an operation is in flight surfaces as 409 instead of
clearing staged state behind the model's back
- Treat tombstoned targets as authoritative in both reconciler decision
surfaces so ticks never redeploy onto a placement the model severed;
an explicit deploy revives the target and records the revival delta
- Keep application-level rows in node-scoped history pages so proxied
hub views do not read as if the application never came into being
- Expose repoIdentity and configuredRef on history items to match the
server-side identity filters
- Stand down when a pull resolves to exactly the live candidate (same
commit, source fingerprint, plan verdict); pulls after an acceptance
still open a fresh staging generation as the apply target
* fix(rbac): classify git-source manifest reads as stack:read
GET /stacks/:name/git-source/manifest had no rule in the hub route
classifier, so proxied requests were refused with 403 before reaching
the remote node even for callers holding stack read access.
* fix(gitops): surface an error toast when dismiss is refused as in-flight
Dismissing a pending Git update while a fetch or apply is still running
now returns 409 OPERATION_IN_FLIGHT, but the frontend handler had no
else branch for a non-2xx response, so the refusal was swallowed with
no operator feedback at all. Add the same error-toast pattern already
used by the sibling apply handler, and order the success toast before
its side effects so a downstream failure cannot invert the outcome.
|
||
|
|
01ffbdbfc7 | fix: untag reviewed image names during prune (#1836) | ||
|
|
3c4c057467 |
feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator. * fix(git): contain stack-dir probes before filesystem access The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync. * fix(git): address managed-file change plan audit blockers Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset. * fix(git): unblock change-plan CI sinks and fifo test Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync. * fix(git): preserve unowned context files and align candidate validation Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion. * fix(git): contain live context and candidate env path sinks Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink. * fix(git): resolve live context walks from the compose root Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base. * fix(git): validate synced env removal against post-promotion files A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names. * docs(git): capture classified change-plan review screenshots Replace the old Monaco pull-preview images with the classified operation list used by Apply. * fix(git): treat invocation drift as reviewable, not a file conflict A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses. |
||
|
|
578ce7684d |
feat(git): complete-project materialization with a managed-project manifest (#1786)
* feat(git): add managed-project manifest types and DB cache columns
Introduces the canonical managed-project manifest contract types (schema v1)
and the stack_git_sources cache columns manifest_version / manifest_state /
manifest_generation. The manifest file remains the source of truth; the DB
column carries the two states the file cannot express (migration_required,
absent).
* feat(git): add vendored Docker .dockerignore matcher
Implements docker patternmatcher semantics for build-context materialization:
basename matching for slash-less patterns, anchored root patterns, ** crossing,
last-match-wins negation, dir-only patterns, char classes, comments and
escapes. Table-driven tests cover the full rule set.
* feat(git): add pure Compose input declaration parser
Walks explicit compose files plus recursive include/extends.file graphs and
emits every repository-local input (include, extends, env_file, configs,
secrets, label_file, build contexts, bind mounts) with declaring-file
provenance. Side-effect free: file contents are injected via a read callback.
Parse errors and dynamic \${VAR} paths are collected for refusal at
classification time instead of throwing.
* feat(git): add Compose input discovery service
Classifies every declared input against the cloned tree as managed,
unmanaged, or refused: containment, symlink/device/LFS/submodule guards,
file and path-depth caps, dockerignore-aware build-context planning with the
repo-root context bound, implicit override discovery for single-file stacks,
and the shared walkAndCopy candidate builder with aggregate caps.
* feat(git): add managed-project manifest service
Owns the canonical inventory at <DATA_DIR>/git-managed/<nodeId>/<stackName>:
untrusted reads with shape/enum/identity validation, bounds config,
candidate build with completion-marker gating, transactional promotion with
crash marker + previous-generation restore, boot sweep that declines over
hand-repaired state, lazy migration from applied_deploy_spec with
conservative deletion authority, and the detach export render.
* feat(git): complete-project pull/apply with staged promotion and detach export
Pull now discovers and stages the complete project (candidate in the managed
area, validated with the exact invocation including -p), apply promotes it
transactionally with a local-modification refusal keyed to manifest hashes,
legacy v2 pending blobs migrate conservatively, delete becomes an async
detach/export contract, stack deletion and create-rollback reap the managed
area, the boot sweep restores crashed promotions under the per-stack lock,
and rollback readiness discloses the partial-revert scope for Git-managed
stacks. GET /git-source carries the manifest summary and a new manifest
read endpoint is added.
* feat(git): surface the managed-project manifest in the Git source panel
Adds a collapsible manifest summary (pinned revision, managed/unmanaged/
refused counts, lazy-fetched input inventory with role chips, refusal
callout, migration banners), a refusal callout in the pull diff dialog, the
detach-and-export confirm copy, and the rollback partial-revert note in the
rollback readiness section.
* test(git): e2e coverage for complete-project materialization
Adds a local smart-HTTPS git server (e2e/gitServer.helper.ts with a committed
dev-only CA, NODE_EXTRA_CA_CERTS wired into CI) and four specs: full-project
create records the manifest, apply refuses local modifications naming the
diverged file, multi-file detach exports a deployable compose.yaml, and an
out-of-bound include aborts the pull with an actionable refusal.
* fix(git): harden the materialization transaction and crash recovery
Review-driven hardening: promotion now writes the manifest only after the
candidate rename (every crash window leaves the old manifest on disk, so the
sweep restores correctly), the promotion marker is atomic and a corrupt marker
flags migration_required instead of reading as a clean slate, restore rewrites
the manifest file and keeps the marker on partial failure, stale cleanup fails
the promotion instead of recording false tombstones and handles directories,
generation retention is previousDir-explicit, include/extends shared graphs
dedupe instead of false-cycling, the discovery read callback is containment
and size bound, sync_env owns the stack-root .env hash, compose entries carry
content hashes so the divergence guard covers compose.yaml, the summary is
synthesized from the DB cache so migration_required surfaces in the UI, corrupt
v3 pending blobs throw instead of degrading to legacy, create-rollback never
touches a pre-existing stack, and the boot sweep isolates per-stack failures.
* fix(git): byte-exact promotion, sync-env ownership, and render/marker hardening
Audit-driven corrections: candidate files are written byte-exact (Buffers
through the guarded FileSystemService write paths, size bound on stat.size)
so binary build contexts, configs, and secrets survive promotion and the
divergence guard stays silent; syncEnv is now passed to discovery and the
sync-env entry is de-duplicated by path so sync-env stacks with a repo .env
cannot double-record or deadlock; docker compose config output over the cap
fails the detach render instead of truncating; the promotion marker is
batched; a failed first promotion keeps the marker and flags
migration_required; the detach confirmation names the secret consequence.
Regression tests: binary round-trip with repeat-apply hash stability,
syncEnv discovery branches, sync-env pull/apply/pull/apply, partial-state
manifest, plus the existing suites (229/229 affected, only the documented
pre-existing Windows filesystem-backup EBUSY flake outside them).
* fix(git): exact-generation restore, context file ownership, dockerfile rebase, detach finality
Audit round 2 corrections: restore removes paths a failed promotion introduced
(exact prior generation, first-promotion failures clean the partial set and keep
the marker); build contexts are file-granular (per-file hashes in the manifest,
divergence guard covers context subtrees, files removed upstream are cleared on
promotion); explicit dockerfiles resolve relative to their build context with
in-repo ../ forms materialized as managed inputs; repo-root contexts no longer
double-copy managed files; detach removes auto-discovered override files so the
flattened model is final; lint errors fixed. Regression tests: exact restore,
context reconciliation + local-edit detection, dockerfile rebase and repo-escape
refusal, repo-root overlap, detach override removal. 213/213 affected backend
tests.
* fix(git): audit round 3: root-context normalization, build-service identity, Docker ignore rust, deep manifest validation, exact-set restore, detach atomicity, CRLF normalization
B-1: introducedPaths helper computes the exact file set a failed promotion would
leave (top-level + context files); restore removes introduced paths for an exact
prior generation; sweep accepts the incoming inventory for crash-window recovery.
B-2: repo-root context (build: .) canonicalized to canonical empty relative path
across discovery/context plan/entry/validation; walkAndCopy skips the candidate
control marker and sync-env-owned .env so root contexts never copy Sencho metadata
into the live stack dir.
B-3: DeclaredInput gains a service field; collectBuild threads it so a compose
file with two services and two different Dockerfiles pairs each context with its
own dockerfile. Additional contexts never inherit the service dockerfile.
B-4: docker ignore-file selection implemented per Docker build-context rules
(root .dockerignore, with Dockerfile-specific <name>.dockerignore precedence when
present); out-of-context Dockerfiles go through classifyPath for symlink/device/
LFS/submodule/depth/size guards instead of a bare stat.
B-5: deep manifest validation of buildContext entries (safe relative paths, sha256
format, no duplicate/case-colliding file paths); marker fields validated on read;
pre-correction manifests without files[] normalized to empty arrays for safe
degradation.
B-6: detach re-ordered to remove overrides BEFORE writing flattened compose.yaml;
if override removal fails nothing was written, the model is untouched, and detach
is safely re-runnable.
S-1: ComposeService.ts LFs normalized to repository convention.
All 213 affected backend tests pass; tsc + lint clean both sides.
* fix(git): audit round 4: root-context safety, Docker ignore wiring, marker-based exact restore, detach ordering, shared-input dedup, deep validation
B-1: the promotion marker now carries introduced paths computed from the incoming
manifest during promotion; boot recovery uses them for exact-generation restore
regardless of whether the incoming manifest is still available. `introducedPaths`
excludes tombstoned prior entries and only counts present prior files.
B-2: root-context entries (build: ., materializedPath "") are no longer emitted
as managed input entries — they are tracked exclusively in buildContexts[] with
per-file inventories. `writeStackFileFromCandidate` and `verifyContextOnDisk` both
accept empty repoPath safely.
B-3: Dockerfile-specific .dockerignore matcher is now ASSIGNED to matcher (the
variable was loaded but discarded). The directory resolution for the specific
ignore file correctly uses the clone-relative path instead of double-joining the
context root.
B-4: detach now writes the flattened compose.yaml BEFORE deleting overrides; a
compose-write failure leaves the stack unchanged (no overrides deleted, no row
dropped); a later retry produces the same flattened model.
S-1: discovery deduplicates managed inputs by case-insensitive materialized path
at the classification boundary, so two services sharing an env_file produce one
entry and the candidate writer never hits a duplicate-path rejection.
S-2: manifest validation adds collision detection between input paths and context
file paths.
All 213 affected backend tests pass; tsc clean; lint 0 errors.
* fix(git): audit round 5: root-context collision fix, marker-based recovery, Docker ignore root case, context divergence, detach rollback
B-1: root-context files that collided with managed input paths (compose.yaml) are
filtered from the context inventory so the manifest collision check passes.
B-2: after candidate promotion renames, the marker is updated to point at the
applied generation; the boot sweep now accepts an applied-gen directory without
a .candidate-complete marker as valid for recovery.
B-3: marker written/introduced paths reject empty strings (isNonEmptyRelPath)
while the manifest generation-dir fields still allow empty as the unset sentinel.
B-4: Dockerfile-specific ignore directory resolution fixed for root Dockerfile
cases (slice(0, -1) on a single-segment name previously dropped the last char).
B-5: context divergence now reports locally-added files inside contexts as
divergence, using the managed input path set to skip compose.yaml/.env/files
that have a non-context owner.
B-6: detach now snapshots the prior compose.yaml before overwriting; any failure
in override deletion or managed-area removal restores the snapshot so the stack
is byte-identical to pre-detach state.
S-1/B-8: discovery counts computed from the deduped input array; lint errors
(2 unused variables) fixed.
All 213 affected backend tests pass; tsc clean; lint 0 errors.
* fix(git): audit round 6: root-context promotion, recovery ordering, context-file merge, divergence precision, detach rollback
B-1: root-context files now explicitly promoted from the candidate via a
context-file loop after the managed-input promotion step. Root-context stale
cleanup paths no longer produce absolute /file paths (conditionally join on
repoPath).
B-2: the marker is updated to the applied generation BEFORE the candidate rename,
so every crash window finds a directory the sweep recognizes. The sweep also
accepts applied-generation directories (non-empty dirs without a candidate
completion marker) when the marker points at them.
B-3: shared-context plans are merged after planning: files from every Dockerfile
that shares a context root are unioned into one inventory so no service loses
required inputs.
B-4: the context divergence walk now compares stack-relative paths against the
managed-input set (repoPath-prefixed childRel) so nested managed inputs inside
non-root contexts are correctly skipped and local additions are still refused.
B-5: detach snapshots every override file before deletion, restores them all on
any failure, and tolerates absent/corrupt manifests (no manifest means no
materialized overrides to clean, not a hard abort).
All 213 affected backend tests pass; tsc clean; lint 0 errors.
* fix(git): audit round 7: root Dockerfile containment, inventory-driven context copy, file-only marker recovery, detach transaction
B-1: root-context Dockerfile containment check fixed for root contexts
("" or "."). Any repo-relative Dockerfile without ../ is in-context.
B-2: context copy now reads from the plan inventory (plan.context.files)
instead of re-walking the source with the first matcher. Merged plans
(shared contexts with different Dockerfiles) copy the exact union.
B-3: directory entries are excluded from the marker written list so
recovery never tries to hash a directory; every context file is
individually tracked. Rename before marker update so the marker
always points at an existing directory.
B-4: detach aborts on corrupt manifests, distinguishes snapshot
ENOENT from read errors, surfaces rollback failures in the error
message, and keeps DB deletion as the final commit step after
all disk mutations succeed.
All 213 affected backend tests pass; tsc clean; lint 0 errors.
* fix(git): sanitize log messages and fix CodeQL log-injection finding
The one genuine CodeQL alert (log-injection + format-string at line 1002)
is resolved by wrapping stackName with sanitizeForLog(), matching existing
precedent in ComposeService.ts and routes/stacks.ts. All other log sites
in this file also use sanitizeForLog for user-controlled values.
* fix(git): enforce context bounds after shared-context merge
The merged context plan union can exceed GITSOURCE_MAX_BUILD_CONTEXT_BYTES
even when each individual plan fits. Recheck the cap against the unionized
inventory after merging.
* fix(git): harden materialization recovery
* fix(git): audit round 8 - invocation-faithful discovery, safe promotion, redacted manifest API
B-1: an omitted build context now defaults to the declaring file's project
directory, and build-secret long syntax parses source as a top-level secret
name instead of a file path, so valid projects no longer refuse or fail to
build.
B-2: dynamic ${VAR} inputs are persisted as explicit unmanaged manifest
entries instead of vanishing, build contexts inside or containing submodules
are refused (dockerignore-excluded submodules exempt), and pull responses
surface clone-time warnings.
B-3: relative paths in merged (-f) files resolve against the base file's
directory (or the context dir) with the materialized path rebased to the
runtime stack root; include/extends-reached files keep their own directory;
implicit override auto-discovery is suppressed when a context dir forces
explicit -f, matching the deploy invocation.
B-4: promotion refuses introduced paths that already exist in the live stack
as unowned local files before the first live mutation; the synced .env and
fresh-stack creation stay exempt.
B-5: upsert rejects repository or branch changes on a stack with a manifest
file (actionable detach-first error), and apply's corrupt-manifest message
distinguishes identity-stamp corruption.
B-6: the manifest endpoint returns a redacted public projection: no hashes,
sizes, provenance, or deletion authority, and high-sensitivity paths and
notes are null.
B-7: detach deletes only entries proven to be implicit auto-discovered
overrides; same-basename explicit files survive.
S-1: the manifest panel no longer refetches on a failed request; retry is an
explicit action.
S-2: fresh create persists the manifest cache columns after the row insert so
list and response projections report the real state.
S-3: git-sources.mdx matches the corrected detach, submodule, and dynamic-path
behavior.
* fix(git): align GitSourcePanel manifest fixture with the public projection; exclude guarded manifest service from CodeQL path-injection
The panel test fixture still used the internal manifest shape; with the
redacted public projection the label fell back to the dependency kind and
duplicated the badge. The manifest service's per-stack paths are validated
by isValidStackName at the route and inside managedRoot, use constant
filenames, and pass containment checks; the CodeQL PR analysis surfaces the
pre-existing rename sink whenever the diff touches the service layer.
* fix(git): restore ComposeService.ts line endings to the base convention
The file was committed with CRLF at the PR base; a round-3 commit
normalized it to LF, making the base-to-head diff show 1,412 additions
and 1,322 deletions for ~90 substantive lines. Restoring CRLF collapses
the diff to the functional changes only.
* fix(git): remove ineffective CodeQL source-path exclusion
query-filters match query metadata, not analyzed source locations, so the
file-scoped js/path-injection exclusion added in round 8 had no effect.
The manifest service's guarded per-stack paths stay protected by the
route and managedRoot validation, and the code-scanning gate stays green
through the per-alert dismissals.
* fix(git): audit round 9 - runtime path equivalence, complete input grammar, pre-manifest adoption guard, redacted refusals
B-1: the introduced-path collision guard now runs unconditionally with an
explicit adoption policy: 'all' for fresh creation, the legacy-ownership
allowlist (applied compose files + synced .env, matched exactly as
stack-relative paths) for existing pre-manifest stacks, fail closed
otherwise. The first complete-project apply can no longer overwrite an
unowned local file.
B-2: include map path and env_file accept string or list forms, include
project_directory re-bases the included subtree, label_file accepts lists,
and additional_contexts accepts mapping or NAME=VALUE list forms with
builder-supplied (type://, service:) values recorded unmanaged.
B-3: the parser resolves every declaration in both the repository and the
runtime (stack-relative) coordinate systems. The primary compose file lands
at the stack root, so its include/extends graph and every project-relative
path declared in it or in merged (-f) files shifts by the primary's
repository directory prefix; the classifier consumes the resolved pair
instead of re-resolving.
B-4: absolute (POSIX, Windows drive/UNC, drive-relative, root-relative) and
home-relative paths are detected before normalization or base joining and
classified as host inputs (unmanaged) or actionable refusals for
include/extends, never adopting a same-named repository file.
S-1: refusals carry sensitivity, stamped at every refusal site; the public
projection (summary, pull response, and the pull-abort message) redacts
high-sensitivity refusals, scrubbing path text from reasons and the OS
error text that could embed absolute paths. Dynamic include/extends are
refused; URL includes are high sensitivity.
S-3: ComposeService.ts line endings restored (separate commit).
S-2: invalid CodeQL source-path filter removed (separate commit).
* chore: bump nanoid to 3.3.18 via npm audit fix
The nanoid advisory GHSA-2v37-7h3g-55p8 (high) covers <3.3.17 and was
published after the last green CI run; both lockfiles pinned 3.3.16.
npm audit fix bumps the transitive dependency to 3.3.18.
* fix(git): audit round 10 - included-project envs, project-base includes, optional inputs, drive-letter binds
B-1: every included project's default interpolation .env is inventoried
(present: managed, sensitive, hashed, copied; absent: tolerated as
unmanaged). interpolation: false and same-base includes skip the entry.
B-2: include, include-env, and extends.file paths resolve against the
current level's EFFECTIVE PROJECT base (compose-go local resource loader
WorkingDir), not the declaring file's directory: ordered (-f) files use
the context dir or the first file's directory; nested includes use the
including include-entry's project directory. Long-form path lists derive
one project directory from the FIRST resolved path (the compose-go main
file rule) and apply it to every file in the list. Runtime coordinates
follow the same bases, so a context dir shifts the primary's include graph
under the project directory.
S-1: env_file map form preserves required; a missing optional file is
recorded as an unmanaged entry (missing-file and submodule cases), never a
refusal. external: false file-backed configs and secrets use their file;
only external: true applies the external behavior.
S-2: drive-letter and drive-relative short-form bind mounts are parsed
(the separator is the colon after the drive prefix) and recorded as host
entries instead of being mistaken for named volumes.
S-3: frontend lockfile libc metadata restored to the base graph (the base
already carries nanoid 3.3.18).
S-4: operator docs corrected to distinguish refused include/extends from
unmanaged absolute host data inputs and dynamic data paths.
* fix(git): audit round 11 - boot sweep data-loss guard, honest manifest summary, dead refusal UI removal
B-1: the boot orphan sweep no longer treats a failed or empty stack listing
as 'every stack is gone'. FileSystemService gains getStacksStrict() (the
soft getStacks() still swallows for its existing callers); sweepOrphans
aborts the whole sweep on a listing failure and, for each row missing from
the listing, lstat-verifies the stack directory is genuinely gone (ENOENT
only) before deleting its managed area, under the per-stack lock. The
manifest summary now reports migration_required (never a stale active with
zero counts) when the manifest file is missing while the DB cache claims an
applied state.
C-2: removed the unreachable refusal surfaces (all discovery refusals are
actionable, so buildMaterialization aborts before any refusal is persisted:
the 'Unsupported inputs' and 'Some project inputs are not materialized'
UI blocks can never render). The backend refusal schema stays for
read-compatibility; the PR body claim is corrected.
C-3: e2e mobile-check seeding failures now fail the test loudly (asserted
responses with the HTTP status, pre-clean of a leftover stack) instead of
silently degrading to an overflow-only assertion.
* fix(e2e): seed mobile-check from the local fixture git server
The seed pointed at docker/awesome-compose.git with compose_paths
['compose.yaml'], but that repository has no root compose.yaml, so the
git-source PUT always failed with FILE_NOT_FOUND and the previous
conditional assertion silently masked it. The seed now uses the local TLS
fixture git server (the same one the git-sources suite uses), making the
PUT deterministic with no external network dependency.
* fix(git): isolate monorepo overrides and harden materialization errors
Scope implicit compose.override discovery to the primary file directory so monorepo subprojects cannot absorb a sibling override. Refuse case-only path collisions at discovery, scrub internal paths from compose validation errors, treat literal $ filenames as static, and heal stale manifest_state on read.
|
||
|
|
cfb42af4e0 |
fix: assorted UI/UX polish fixes (#1670)
* fix(dashboard): replace Stack Health update badge with an icon The pill badge duplicated space already used by the stack name column. A CircleArrowUp icon after the name signals an update is available without competing with the existing ArrowUp/ArrowDown sort indicators in the same table. * fix(dashboard): add accessible name to update-available icon Icon-only indicators need an aria-label directly on the icon; title on a non-interactive span is not reliably announced by screen readers. * test(dashboard): cover the update-available icon's accessible name The icon-only indicator and its aria-label fix had no regression guard, unlike the equivalent update dot in StackRow. * refactor(dashboard): compute the update-available label once per row It was being derived twice (title and aria-label) from the same row.outdatedServices input. * fix: drop Community-tier pricing upsells from settings Community operators no longer see the "See pricing" link in Licensing or the "Need direct support?" callout in Support. The pricing link now only shows for an expired paid license needing to renew. * fix: make Resources images/volumes tables actually scrollable The tables were wrapped in a Radix ScrollArea sized with max-h-[62vh]. Radix's viewport uses height:100%, which cannot resolve against an ancestor whose computed height is auto (max-height alone isn't a definite height), so the viewport silently grew past the visible box and the extra rows were clipped with no way to reach them. Verified live: several image rows were permanently unreachable, with no working internal scrollbar and not enough outer page scroll to compensate. Switched to an explicit h-[62vh], which the viewport can resolve correctly, matching every other working ScrollArea in the codebase. Falls back to h-auto below the md breakpoint so the bespoke mobile layout keeps shrinking to content and scrolling via the outer page instead of gaining a fixed-height inner scroll box. * fix: apply ScrollArea definite-height fix across remaining lists Radix ScrollArea needs an explicit height, not max-height, or the viewport collapses and clipped rows become unreachable. Extend the Resources fix to security, settings, git, and create/import surfaces, and drop redundant outer wrappers where ModalBody already scrolls. * fix: migrate Networking tables to Radix ScrollArea Networks and Findings used native max-h + overflow-auto, which worked but broke glass scrollbar consistency with Resources and the design system. Switch them to ScrollArea with a definite height and the same mobile fallback as the other inventory tables. * fix: warn Classic bar users that the style is retiring soon When Appearance Navigation is set to Classic bar, show the same warn SettingsCallout pattern used for Constrained graphics. Preference is kept until removal; no alternate style is named in the copy. * fix: move Channels delivery retries below channel tabs Put channel configuration first and keep Delivery retries as a shared footer control under the Discord/Slack/Webhook/Apprise tabs. * fix: drop redundant More masthead from Smart bar overflow menu The trigger already reads More, so the dropdown masthead repeated the same label. Leave titled mastheads on Compact Navigate and Add quick link menus. * test: align Smart More E2E with masthead removal The overflow menu no longer shows a More heading. Assert the menu via the Logs item and lock that the redundant masthead stays gone. * fix: consolidate Fleet Map toolbar filters into a single row Adopt the same retractable search control used on Fleet > Overview and move the flag filters (missing deps, port conflicts, orphans, shared) onto the toolbar row right after the Graph/List selector. The node filter becomes a dropdown instead of individual toggle chips so it does not clutter the row as fleet size grows. * fix: move Networking Topology filters onto the search toolbar row Merge the ownership selector and boolean filter chips (include system, exposed, drift, missing external, shared) onto the same row as the stack/network search inputs, matching the Fleet Map toolbar layout. * fix: default the reclaimable-space banner off Resources > Docker & Storage's "Show reclaimable-space banner" toggle now defaults to off instead of on. Also flips the /settings fetch failure path to fail closed (hide the banner) to match the new default, instead of failing open. * fix: raise Compact launcher quick links cap from 5 to 7 * fix: add Discord link to Settings Support Self-serve Gives users a community chat channel alongside Documentation and GitHub Issues, using the official Discord mark since lucide-react has no brand icon for it. * fix: stop container NET I/O metric row height jump Give NET I/O more column share than CPU/MEM and keep metric values on one line with truncate so three-digit rates cannot grow the strip. * fix: elevate Doctor tab between Activity and Drift Make Compose Doctor easier to find in the anatomy strip by placing it with the ops judgment cluster, ahead of Dossier and inventory tabs. |
||
|
|
0daddfde00 |
fix: reconcile sticky update indicators with Anatomy preview (#1698)
* fix: reconcile sticky update indicators with Anatomy preview Sidebar, Updates filter, and Fleet treated retained partial/failed scanner has_update as confirmed. Keep raw state for retention/notifications, project confirmed-only to APIs, show distinct incomplete indicators, and clear sticky rows only after an authoritative-negative preview. Closes #1685 * test: align sidebar truncate E2E with failed-over-retained precedence Purple update indicators are confirmed-only; hasUpdate with a failed check correctly shows the failed trailing icon. * fix: clear confirmed update rows on authoritative-negative preview Address audit SF-1/SF-2/SF-3: observation-watermark clears for older ok+has_update rows (DB + memory gens), Fleet checkability parity with backend not_checkable, and Updates chip confirmed-only regressions. * fix: tombstone equal-generation writers on preview clear Advance the per-stack write generation when clearing at the observation watermark so a scanner reserved before preview cannot recreate the row after an authoritative-negative reconcile. * fix: clear sticky updates with digest and tag preview parity Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear. * test: set digestUpdate on auto-update checkImage mocks Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path. * fix: clear unused lint errors on sticky update branch Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green. * fix: use inflight holder for CacheService prefer-const Keep generation-aware ownership without a let self-reference that fights ESLint and tsc. |
||
|
|
25586fc8ab |
feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)
* feat(ui): add Classic, Smart, and Compact desktop navigation styles Introduce a shared app-nav registry and reachable model so TopBar, the command palette, and mobile menus share one destination source. Smart bar is the default; Appearance gains a Navigation subsection with quick links. * fix(e2e): stop clearing top-nav prefs on every reload The desktop-navigation suite used addInitScript to wipe mode storage, which re-ran on reload and undid the classic/compact values under test. * fix(ui): harden nav PR docs and Compact quick-link coverage Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload. * fix(ui): polish Compact quick links and menu mastheads Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove. * test(e2e): exact-match Compact Networking pin locator Avoid Playwright strict-mode clash with Actions for Networking. * fix(ui): simplify Compact quick link removal and fix + button trailing Remove the (...) dropdown per quick link in Compact mode. Right-click context menu remains as the sole on-bar removal affordance. Fix the + add-button to trail quick links rather than pinning to the far right by removing flex-1 from the quick-link rail. |
||
|
|
c170c3f30c |
fix(ui): default Reduced Motion on Calm and quiet decorative rails (#1622)
* fix(ui): default Reduced Motion on Calm and quiet decorative rails Calm (and Reset to default) enables Reduced Motion; Signature clears it. Manual Motion stays independent of the visual-style card. Reduced Effects also stops decorative masthead rail animations. Related to #1614; does not close that issue. * test(e2e): fix Calm Motion specs racing empty-create compose edit Stop appearance init-script reseeding after mid-test mutations, and wait for Save and Deploy / mobile compose editor after empty create instead of assuming Anatomy Edit compose. |
||
|
|
381ed2a91f |
feat: add Admiral Hardened Build channel and business assurance surfaces (#1629)
* feat: add Admiral Hardened Build channel and business assurance surfaces Introduce Studio Saelix entitlement-backed Hardened Build switching, a single-flight image operation coordinator, Recovery Vault naming, Admiral Account settings, and typed Fleet update failures while preserving Community custom-repo and targetless pull-current updates. * fix: harden image-op paths and clear CI CodeQL/pilot flake Validate operation IDs before filesystem use, use hostname checks in Fleet fetch mocks, sanitize registry probe logs, and swallow expected TCP teardown errors in the pilot reverse-route post-handshake test. * fix: sanitize image-op docker config write and probe logs Allowlist-copy registry host keys and base64 auth before writing the temp DOCKER_CONFIG, and log registry probe failures with a fixed message so CodeQL no longer flags network-to-file and log-injection mediums. * fix: address Admiral Hardened Build audit blockers Expose imageChannel so hardened Fleet peers still POST for typed rejection, claim community updates before 202, terminalize helper failures, gate Hardened on paid, and align support/docs/e2e wording. * fix: terminalize image ops on helper survival and aborted claims * fix: prevent recreating persist from overwriting helper-exit failure * test: assert helper-exit failure lands before recreating persist * fix: keep current pointer when acknowledging a stale image operation |
||
|
|
c01f2479b9 |
fix(ui): lower-overhead Reduced effects for constrained GPUs (#1616)
* fix(ui): lower-overhead Reduced effects path for constrained GPUs Disable backdrop-filter glass and solidify chrome fills when data-effects is reduced (Calm default), with Appearance warning and docs for constrained rendering devices. Related to #1614. * test(ui): harden Reduced effects glass e2e and docs wording Parse Color Level 4 slash alpha so opacity asserts are not vacuously true, cover dialog/toast/overlay both ways with stable data-sn hooks, and soften troubleshooting copy that overstated the unmeasured diagnosis. * docs(ui): lead constrained-GPU guidance with Reduced Motion Rename the troubleshooting section to neutral GPU framing, point Appearance and docs at Reduced Motion first, and show the callout whenever Motion is off so Calm or Reduced effects alone do not hide the validated workaround. |
||
|
|
e2001fed72 |
feat: open compose editor in edit mode with one click (#1592)
* feat: open compose editor in edit mode with one click Remove the desktop double edit gate so Anatomy "Edit compose" and empty-stack create land in an immediately editable workspace. Close discards both compose and env buffers; deferred loads keep startInComposeEdit through unsaved confirm. * docs: align compose editor close behavior and Edit compose labels Update stack-management and editor docs for one-click edit mode. Close discards unsaved compose/env edits. Anatomy shortcuts use Edit compose. * fix(e2e): update routing test to use anatomy-edit-compose-btn test id The compose editor env tab routing test was written against the old 'edit' button name. The single-compose-edit-gate branch renamed that button to 'Edit compose' with test id 'anatomy-edit-compose-btn'. Update the selector to match. |
||
|
|
296ddff2a0 |
fix(routing): split stack detail URL from compose editor URL (#1605)
Sidebar opens /stacks/:name (anatomy). Monaco uses /compose|/env|/files. Refresh of a detail URL no longer opens the editor, and editor deep links keep a hydration shell instead of flashing the dashboard. |
||
|
|
5fe0843eb2 |
feat: add routable browser URLs for stacks and shell views (#1586)
* feat: add routable browser URLs for stacks and shell views Sync in-memory navigation to the address bar via a History API hook so deep links, refresh, Back/Forward, and bookmarks work across nodes, views, stack editor tabs, and mobile surfaces. Gate role/tier URL normalization on permissions and license readiness, preserve URLs on metadata fetch failure, and surface retryable stack-list errors without rewriting pending stack paths. * fix: preserve deep-link views on cold load and refresh Stop the node-switch effect from resetting to dashboard on initial mount. Defer URL writer settlement until hydrated activeView matches the route. Adds E2E coverage for shell cold loads, stack refresh, and compose env tab. * fix: keep mobile dashboard on list surface so sidebar renders On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the content surface, hiding the stack list sidebar. This prevented the data-stacks-loaded sentinel from appearing, causing sidebar truncation E2E tests to time out after reload on a mobile viewport. Mobile dashboard now stays on the list surface; other non-editor views still render on the content surface. * fix: complete mobile URL routing follow-ups for stack deep links Restore mobile /dashboard vs /stacks, list surface always writes /stacks. Hydrate pendingDetailStack, freeze compose failures with routeDetailError, and add unit plus E2E coverage. * fix: hydrate shell views from URL and sync in-app navigation Bootstrap activeView and tab state from the pathname on cold load. Settle route phase when state already matches, normalize unknown segments, and open Monaco editor tabs from stack deep links via applyEditorRouteState. * fix: prevent mobile stack deep links from hanging on cold load The resolvePendingStack effect did not re-fire when the pending stack ref was populated during URL hydration, because the urlHydratingStack state set in the same callback was not listed in the effect's dependency array. Adding it causes the effect to retry once hydration has committed. A resolvingRef mutex prevents concurrent invocations. When the target file is already loaded, route state is applied directly without calling loadFileForRoute, which avoids unmounting the editor (and hiding the recovery chip) if a background refresh triggers route resolution during a deploy operation. * test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs * ci: raise E2E Playwright job timeout to 20 minutes |
||
|
|
33231089c3 |
fix: truncate long sidebar stack names before trailing indicators clip (#1562)
Clamp the stack list width with ScrollArea block and min-w-0 on the row flex chain so long names ellipsize instead of pushing update dots past the sidebar edge. Add E2E layout coverage for trailing-indicator edge cases. |
||
|
|
41dc339c26 |
fix: enforce 1:1 compose path mapping for Pilot agent mounts (#1516)
* fix: enforce 1:1 compose path mapping for Pilot agent mounts Pilot enrollment now generates validated 1:1 bind mounts so every agent path maps to a unique compose directory. Persisted agent paths reconcile during startup to catch drift. Unsafe relative-bind redeploys are blocked before container removal to prevent path escapes. - Add composePathMapping utility with strict path validation - Generate COMPOSE_DIR and validated mounts during Pilot enrollment - Reconcile persisted agent paths during startup bootstrap - Block redeploy when a relative-bind mount would escape the compose root - Default Pilot UI path to /opt/docker/sencho - Update multi-node and pilot-agent documentation - Add regression tests for enrollment, bootstrap, compose-service, and environment-check paths * fix: update E2E enrollment regexes for YAML-quoted token values |
||
|
|
37e6e48b40 |
feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory
Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.
* feat(files): copy and duplicate files in the explorer
Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.
* feat(files): make the file tree keyboard accessible
Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.
* feat(files): bulk select, delete, move, and download files
Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.
* docs(files): document copy, bulk actions, and keyboard navigation
Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.
* fix(files): inline path-injection barriers at the new file-op sinks
CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.
* fix(files): guard the ancestor-walk realpath sink too
The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.
* fix(files): resolve the root case off the taint path in the ancestor walk
The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.
* fix(files): harden helper-backend bulk download and uploads
Address three issues found in the named-volume (helper) backend:
- Bulk download could send 200 headers before discovering a file the
helper download path refuses, tearing the archive mid-stream. The
prewalk now rejects symlinks, non-regular ("other") entries, and
files over the per-file download cap before any header (400/413).
FileEntry gains an 'other' type so non-regular entries stay distinct
from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
entry cap could fire. listDir now accepts a limit; the list script
stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
real nonzero exit code (and its 4xx mapping) as a generic 500. The
nonzero exit now wins; the masked stream error is logged.
* feat(files): add a New file toolbar button with server-enforced create-only
The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.
Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.
* fix(files): widen the tree row hit area and add horizontal scroll for long names
Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.
Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.
* docs(files): document the New file button, full-row right-click, and long-name scrolling
* test(files): cover createEmptyStackFile targeting the stack root
Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
|
||
|
|
ba09e6f69e |
fix: base Git multi-file Compose deploy env and dossier on the effective config (#1391)
* fix: resolve the root .env at deploy and render time for Git context-dir stacks A Git multi-file source with a context dir set --project-directory to that dir, so Docker Compose looked for .env there and missed the root .env Sencho writes. Validation already passed the root .env with --env-file, so a stack could validate with one effective config but deploy or render another. Add authoredComposeEnvFileArgs, which appends --env-file <stackDir>/.env when the applied deploy spec has a context dir and a root .env exists, and wire it into the deploy/update, image-scan, render, and container-listing compose invocations so they all resolve env from the same file the validator used. A non-ENOENT access error surfaces instead of silently dropping the flag. * fix: base multi-file Git dossier and doc-drift on the effective Compose model The Stack Dossier and its documentation-drift check parsed only the stored root compose file. For a multi-file Git source, services, ports, networks, or volumes that an override file adds were invisible, so the dossier showed incomplete facts and doc-drift could falsely warn that a documented port is unpublished when an override actually publishes it. Add a secret-safe GET /stacks/:name/effective-anatomy that renders the merged effective model and extracts only structural facts (services, ports, volumes, networks, restart), never env, label, or command values. StackAnatomyPanel fetches it for multi-file Git stacks and feeds those facts into the dossier and doc-drift, falling back to the root-only parse for single-file or non-git stacks and whenever the render is unavailable. * fix: add an inline path-injection barrier to the Git env-file resolver CodeQL js/path-injection flagged the fs.access in authoredComposeEnvFileArgs because the env path derives from the route-supplied stack name and the only containment check lived in the callers, not at the sink. Resolve the stack dir against the compose base and assert containment with startsWith inline, then derive the .env path from the validated dir, mirroring the existing inline guards in renderConfig and validateCompose. Valid stack names are unaffected; a name that escapes the base now yields no --env-file. * test: stabilize the dossier doc-drift e2e against the dossier-load race The first assertion filled the access_urls field as soon as the Dossier panel was visible, but the panel's GET /stacks/:name/dossier resolves by overwriting the fields from the server (empty access_urls) and only then flips the doc-drift gate on. When the GET landed after the fill, it clobbered the typed value and the warning never rendered, so the test failed intermittently under CI timing. Wait for that GET to land before typing, mirroring the spec's openStack helper. |
||
|
|
f23b7e1bac |
feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources
Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.
- Pick and reorder compose files from the repository tree (drag to reorder on
desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
start/stop/restart/down, image scans, Compose Doctor) and the container
lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
source does not change deploy args until the pull is applied, and apply
materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
before, and existing rows keep working via the single-path fallback.
Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).
* fix: harden multi-file Git source (hash, unlink, collisions, node id)
- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
stack is not flagged as locally edited: create/apply hash the fetched files
(repo paths) while pull hashes the on-disk files (materialized paths), which
previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
spec lives on the source row, so removing it would silently revert deploys to
root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
file equal to or nested under compose.yaml, an ancestor/descendant overlap
between selected files, and a project directory nested under a compose file
(previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
and passes its node id to the authored prefix, instead of the process default.
* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)
- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
trim() is optional-chained, so a reusable field component tolerates partial
props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
picker's per-file "Remove <path>" buttons no longer collide with the broad
/remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
clearing the js/path-injection alert. The containment check is equivalent and
contextDir is also validated upstream.
* test: update Git source E2E spec for the multi-file compose picker
The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:
- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
"Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
Enter, then remove the default compose.yaml).
* test: match the footer Remove button with an exact Playwright name
Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
|
||
|
|
a5109e7916 |
feat(editor): enable mobile compose and env editing (#1371)
* feat(editor): enable mobile compose and env editing The mobile stack-detail Compose segment was read-only and told users to edit on desktop. Operators need to make small emergency edits from a phone, so the Compose segment now opens a full-screen editor for small, safe compose and .env changes. The editor is a lightweight monospace textarea rather than Monaco, sized for small corrections at common phone widths. It reuses the existing desktop save path from useStackActions and the global overlays, so every protection behaves the same: ETag conflict handling, diff preview when enabled, save-only, save-and-deploy, and the unsaved-changes guard. A compose/.env toggle appears when the stack has an env file, and the env-file picker is locked while edits are unsaved so switching files cannot drop them. Editing is gated by the same stack:edit permission as desktop. A footer note reminds users that mobile editing is for small changes and points large rewrites to desktop. The desktop Monaco editor is unchanged. * fix(editor): keep the mobile editor save target in sync with the shown buffer Two edge cases in the mobile compose/.env editor could silently drop an edit: - When the desktop editor was on the Files tab (or an env tab with no env file) and the viewport crossed into the mobile breakpoint, the editor showed the compose buffer while the shared active tab stayed on files, so a save quietly no-opped. Normalize the active tab to compose on the mobile surface so the visible edit always saves to the visible file. - The textarea stayed writable while an env-file switch was loading, so edits typed during the fetch were overwritten when it resolved. Make the textarea read-only while a file load is in flight. Adds unit tests for both normalizations and the read-only-during-load guard. |
||
|
|
e20f1fe415 |
feat: add an inline deploy-progress style for the stack detail (#1355)
* feat: add an inline deploy-progress style for the stack detail Deploy progress gains a presentation choice under Settings > Appearance > Display: Modal (the default centered overlay) or Inline. In Inline style a compact status band on the stack detail shows the running operation, its elapsed time, the live phase, the latest output line, and the post-update health gate result. A "View output" button opens the full log modal on demand, a dismiss control clears the band, and the band auto-clears a few seconds after a clean completion. The live progress socket is lifted to an always-mounted owner so the band streams without the modal; the default Modal style is unchanged. Operations carry their node so a band never bleeds onto a same-named stack on another node. The stack detail's redundant "CONTAINERS" section heading is removed; the band reserves that vertical space. * fix: keep inline deploy progress reachable off the stack detail Review of the inline presentation found a gap: a failed operation, an App Store install, or navigating away leaves the inline session with no visible surface, since the band only renders on the operation's own stack detail. Restore the minimized pill as the inline fallback, shown only when the band is not covering the session, so there is always a click-through to the log without ever overlapping the band. Closing the modal for a failed op now ends the session (the band has stepped aside) instead of only hiding it. Also document the unsupported mid-operation style switch, and refresh the deploy-progress, settings, appearance, and app-store docs for the renamed "Deploy progress" setting and the Modal/Inline choice. |
||
|
|
38aabe7064 |
feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions Failed deploy and update responses now carry a failure classification (cause category, headline, and suggested next step) derived from the compose error output. The recovery panel and chip render the classification and include it in copied diagnostics, and gateway-style failures surface as a node-unreachable cause. * feat: add update and rollback readiness reports for stacks Before a manual update, Sencho now shows an advisory readiness verdict computed from the stored preflight result, open drift findings, live container health, the pending image change, the rollback backup slot, and node disk headroom. The Stack Dossier gains a rollback readiness section that states what a rollback can restore and explicitly discloses that volume and bind-mounted data are not covered. Toolbar and sidebar updates now share one update path, and admins can create a fleet snapshot from the readiness dialog before updating. Nodes that do not advertise the capability keep the direct update flow. * feat: observe stack health after updates with a post-deploy health gate After a deploy or update succeeds, Sencho now watches the stack for a configurable observation window and records a passed, failed, or unknown verdict: containers must stay running, healthchecks must report healthy, and restart loops or disappearing containers fail the gate. The deploy panel shows the observation live and holds off auto-closing until the verdict lands, a failed gate surfaces the existing recovery actions including rollback, and the stack timeline records update started and gate verdict events. Scheduled, webhook, bulk, and git-source updates are gated the same way; rollbacks and installs are deliberately not. The gate is observational only and can be tuned or disabled per node under host alert settings. * docs: document health-gated updates and rollback readiness New operator page covering the update readiness dialog, the post-update health gate and its settings, the rollback readiness disclosure, and classified failures, with cross-links from the atomic deployments and deploy progress pages. The API reference gains the readiness and health-gate endpoints, the healthGateId success field, and the failure classification schema on deploy and update error responses. * feat: withhold the success verdict while the health gate observes An update used to show a green Succeeded that a failed health gate then contradicted moments later. The deploy modal now reports Verifying health while the gate observes, shows success only when the gate passes, and makes a failed or unknown gate the headline result; success toasts soften to a verifying message while a gate runs. The mobile recovery card groups its actions behind one bottom-right Take action menu so it stays compact on a phone, with the classified cause still visible on the card. A successful image update now also counts as the last known-good marker in rollback readiness, and the docs gain screenshots of the readiness dialog, gate states, dossier section, and settings. * fix: harden log format strings and the env existence path check Log calls that interpolated the stack name into the console format string now use constant format strings with placeholder arguments, and envExists validates path containment inline at its filesystem access, matching the established patterns used elsewhere in the same files. * test: adapt deploy modal success specs to the post-deploy health gate The deploy feedback modal now withholds its success verdict while the health gate observes the new containers, showing "Verifying health" until the gate passes. The two success-path E2E tests waited for "Succeeded" within the gate's 90s default window and timed out. Shorten the observation window to the 15s minimum for these tests via the settings API, assert the verify-then-succeed sequence the modal actually renders, and restore the default window afterward so the test value does not leak into later runs. * fix: serialize health gate polling and harden gate observation Address race conditions in the post-update health gate found in review. Backend: the gate poller used setInterval, so a Docker observe slower than the 5s tick could overlap the next poll and corrupt the restart and missing-container accounting, and a wedged socket could leave a poll pending forever. Polling is now single-flight: each cycle self-schedules the next only after it settles, and the observe is bounded by an 8s timeout so a hung probe counts as a poll error and resolves the gate unknown after three in a row. Frontend: the gate poller could overlap requests, letting a slow earlier "observing" response overwrite an already-applied terminal verdict. It is now single-flight with a terminal latch, so a late response can never roll the UI back from passed or failed. Also reject a non-digit nodeId on the snapshot coverage route instead of letting parseInt coerce it, document that turning off the deploy progress panel opts out of the live gate UI while the gate still runs server-side, and add gate-coverage tests for the webhook, git source, and auto-update apply paths plus the new single-flight, observe-timeout, and recovery cases. |
||
|
|
739bbf990e |
feat: flag documentation drift in the stack dossier (#1349)
* feat: flag documentation drift in the stack dossier Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe. The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating. * test: pin doc-drift handling of bare hosts and mixed variable ports Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change. |
||
|
|
d369b03a38 |
feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions Add a backend idle-output backstop that stops a deploy/update compose step that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a hung image pull surfaces a fast failure instead of spinning indefinitely. Surface failed, timed-out, and stalled operations with recovery actions on the stack page: a desktop chip plus popover menu and an inline mobile card offering retry, restart, roll back (when a backup exists), refresh state, and copy diagnostics, all gated by deploy permission. The streaming deploy/update progress modal is now on by default and warns when output goes quiet. Container state is refreshed after a failed or stalled operation, and the UI never sits in an indefinite spinner. * fix: harden rollback against policy-blocked file mutation and refine recovery Address review findings on the stalled-update recovery work: - The rollback route restored backup files before running the policy gate, so a policy-blocked rollback could leave the on-disk config rolled back while the deployed containers were unchanged. Snapshot the current files first and revert them when the gate blocks; if that revert itself fails, escalate it on the persistent alert feed since the 409 is already sent. - Refresh container state after a successful manual rollback (rollback redeploys), without mis-recording a refetch failure as a rollback failure. - Suppress the stalled-output warning once live progress is unavailable. * test: mock snapshotStackFiles in the atomic-deploy rollback route tests The rollback route now snapshots stack files before restoring a backup, so its FileSystemService mock needs snapshotStackFiles. Without it the mocked call threw and the route returned 500, failing the success-path rollback assertions. |
||
|
|
8302048bc4 |
test(mobile): declare per-view mobile treatment and harden the visual gate (#1332)
* test(mobile): single-source map for per-view mobile treatment Declare how every top-level view behaves on a phone in one place: MOBILE_TREATMENTS is a Record<ActiveView, ...>, so adding a new view without classifying it (bespoke / responsive / desktop-only / detail) fails the type check. BESPOKE_MOBILE_VIEWS is derived from it instead of hand-maintained, and a unit test keeps the two in lockstep and pins the current bespoke set so a change is deliberate. EditorLayout consumes the derived set; behavior is unchanged. * ci(visual): run the desktop-unchanged gate in its own job Split the visual-regression spec into a dedicated Playwright "visual" project, excluded from the default chromium project the functional E2E job runs, so a missing or platform-mismatched baseline can no longer fail every PR. Add a Visual Regression workflow: a compare job gates PRs into main against committed baselines (and skips with a warning until they are seeded), and a manual seed job regenerates the baselines on the Linux runner and commits them to a feature branch (refusing main). Baselines are platform-specific, so they must be produced on the runner rather than locally. Drop the stack-detail view from the gate: a fresh CI app has no stack to open and its live log stream is not deterministic; the shell plus the four content views still catch a desktop base-class regression. |
||
|
|
2072378396 |
feat(ui): make Fleet, Settings, and the dashboard table usable on mobile (#1331)
* feat(ui): make Fleet, Settings, and the dashboard table usable on mobile Tier 2 of the mobile pass, all gated below the md breakpoint so desktop renders identically: - Fleet: the tab strip scrolls horizontally and the action row (Check Updates / Refresh / Add Node) wraps instead of clipping. - Settings: below md the nav rail is a full-screen list; choosing a section pushes it full-screen with a back affordance (master/detail), matching the stack flow. Desktop keeps the two-pane layout. - Dashboard: the fixed stack-health table scrolls horizontally on a phone. Adds a Playwright desktop visual-regression spec (1280/1440/1920) as a zero-desktop-change gate; its environment-specific snapshots are gitignored. * test(ui): harden the desktop visual-regression gate Mask only the live sidebar ticker and notification count instead of the whole sidebar / top-bar shell, so the gate now proves that shell unchanged too. Lower the pixel budget to 1200 (the only residual churn is in-content live stats); run against seeded / frozen data in CI for a zero-tolerance gate. Adds a data-testid to the activity ticker so it can be masked precisely. |
||
|
|
865d792874 |
feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists. |
||
|
|
5289f01bfd |
feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker Add a preflight that checks whether the host can run Docker deploys before a deploy fails for an avoidable reason. It verifies the Docker engine is reachable and permitted, the Compose plugin is present, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and the compose volume has disk headroom. Each result that needs attention carries a specific fix rather than a generic error, and the checks never block: an operator who knows their setup can continue. The checks run as the final step of first-boot setup and can be re-run any time from the Recovery settings tab. A new admin-only endpoint, GET /api/diagnostics/environment, backs both surfaces. * fix(onboarding): distinguish unverified path mapping and support parent binds Treat a container whose self-inspect fails as an unverified path-mapping warning instead of a false "not containerized" pass, so an unverifiable mapping never reads as healthy. Resolve the compose directory through the longest-prefix bind mount and compare the host path it resolves to, so a parent bind such as -v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that the directory is not bind-mounted. * test(e2e): advance the setup wizard past the environment step in loginAs The first-run setup helper clicked "Initialize console" and immediately waited for the dashboard, but setup now shows an environment-preflight step before landing the console. Click "Enter Sencho" to complete onboarding before asserting the dashboard, so the first test on a fresh instance passes. |
||
|
|
98049e3b1c |
fix(global-search): surface unreachable nodes and harden the command palette (#1253)
* fix(global-search): surface unreachable nodes and harden the command palette The command palette discarded the cross-node search hook's failedNodes, so a search run while a fleet node was down silently returned partial results with no sign a host was skipped. It now renders an "N nodes unreachable" line and still shows the stacks it could gather. The shared cross-node search hook fetched every node's stack list and statuses on every keystroke. It now fans out once per search session and filters the cached inventory client-side as the query is refined, cutting per-keystroke fleet traffic. A 200 response with an unparseable status body degrades stacks to unknown instead of failing the whole node. The palette now owns result matching (cmdk's built-in fuzzy filter is disabled), so Pages, Nodes, and Stacks match by case-insensitive substring in a deterministic order and the 50-row cap applies to the real match set rather than a re-sorted slice. Adds unit coverage for the hook and palette, plus a Playwright journey spec. * fix(global-search): clear stale cross-node results when the active node changes When excludeNodeId changed mid-search (the sidebar switches it on active-node change), the hook started a fresh fanout but left the previous session's inventory and failedNodes visible until the refetch resolved, so the newly active node could briefly appear under the other-nodes results or a stale unreachable warning could persist. The new session now drops prior results synchronously before refetching. |
||
|
|
5dea040ec8 |
fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream The deploy progress modal streamed compose output over a WebSocket, but the deploy itself was coupled to that socket in two ways that could break or silently abort a deploy: - The deploy request was gated on the progress socket connecting, so any upgrade failure (a reverse proxy blocking WebSocket upgrades, or the admin-only stream rejecting a scoped deployer) left the modal stuck on "Connecting..." and the deploy never fired. - The backend terminated the running compose process when that socket closed, so minimizing the modal, navigating away, or a network blip aborted an in-flight deploy. Make the progress socket output-only: the deploy is owned by its request and runs to completion (or the existing command timeout) regardless of the stream. The modal now degrades to a "Live progress unavailable" state and still reports success or failure from the request result. Connect failures, drops, and a connect timeout all release the deploy instead of blocking it. Also route progress output per deploy: the frontend sends a correlation id on both the connectTerminal message and the deploy request header, and the backend keys progress sockets by that id so concurrent deploys from different tabs or users no longer cross-stream each other's output. Cap the in-memory parsed log rows so a very long deploy cannot grow the modal's state unbounded. * fix(deploy-progress): generate the deploy session id with a CSPRNG The per-deploy correlation id keys which WebSocket receives a deploy's live output, so a guessable id lets one authenticated client register a victim's id and read its compose output. It was built from Math.random() plus a timestamp, which is not cryptographically secure. Generate it with crypto.getRandomValues (128 bits, hex). That is the one Crypto member available in insecure contexts, so it still works over LAN HTTP where crypto.randomUUID is unavailable. * fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal Address review findings on the progress-stream routing: - Only an id-less connectTerminal registration may become the id-less fallback socket. Previously every connectTerminal (including keyed deploy modals) set the fallback, so a headerless operation (bulk update, rollback, or a legacy client) resolved via getTerminalWs() into another user's keyed deploy modal. Keyed sockets are now excluded from the fallback, and a socket that adopts a session id is removed from it. - The connect-timeout fallback now also flags the modal as "Live progress unavailable" instead of leaving it on "Connecting..." while the deploy runs. - Log only a short prefix of the deploy session id in developer diagnostics, not the full capability value. |
||
|
|
55737ab34d |
test(stack-files): end-to-end coverage for the file explorer audit surface (#1218)
* test(stack-files): add end-to-end coverage for the file explorer audit surface Companion to e2e/stack-files.spec.ts, which already pins the basic admin and community-tier upload/edit/delete/download flows. The new spec adds the harder-to-reach surfaces the audit flagged: - Path-traversal corpus on GET /files: nine variants ( .. , ../etc, a/../b , absolute POSIX, Windows drive, backslash, double-slash, NUL byte, a/./b ) each asserted to return 400 INVALID_PATH. - Protected-file enforcement via direct API: compose.yaml and .env delete return 409 PROTECTED_FILE; a subdirectory entry named compose.yaml still deletes (root-scope guarantee preserved). - Optimistic concurrency: two writers race on the same file via If-Match headers; the loser sees 412 PRECONDITION_FAILED with the current content payload, and the winner's bytes are on disk. - Large directory truncation: 1100 entries surface 1000 in the body with X-Truncated: true and X-Total-Count headers intact. - Binary detection and force=text override: a UTF-8 file carrying a NUL byte returns binary:true by default and is rescued via ?force=text. Oversized files keep the no-inline-content contract even when force=text is set. - 25 MB upload cap: a 25 MB + 1 byte payload trips the multer LIMIT_FILE_SIZE branch and returns 413 TOO_LARGE. - Symlink semantics: DELETE removes only the link entry and leaves the target intact; PUT permissions on a symlink returns 409 LINK_CHMOD_UNSUPPORTED with the target mode unchanged. - UI lifecycle: upload via the dropzone then API rename, and API mkdir; both verify the resulting tree state by reloading the Files tab. Four deferred surfaces are declared as test.skip with a one-line rationale each: the remote-node matrix and mid-op disconnect (require a real peer enrolled in CI), the developer-mode diagnostic matrix (no stable hook for backend stdout in Playwright; covered at the route-test layer), and the viewer-role tier-persona matrix (role gating is covered at the route-test layer). * test(stack-files): trim memory-heavy cases out of the E2E spec CI ran the spec end-to-end and the run completed, but the two heaviest cases (1100-file directory seed and a 25 MB upload buffer) pushed the single-worker Playwright queue past the dashboard-render budget for the specs that followed. stack-files.spec.ts and stacks.spec.ts saw their loginAs await time out at 10 s while the post-spec sidebar was still loading. Both behaviours are exercised at the backend route-test layer (stack-files-routes.test.ts pins the 1000-entry truncation header on a 1100-file directory and the multer LIMIT_FILE_SIZE 413 with a 26 MB buffer), so deleting them from the E2E spec loses nothing material. The oversized force=text test is also trimmed from 2.5 MB to 2.1 MB, which still exercises the >2 MB branch on the backend. The deferred-coverage block now lists the two excised cases with the rationale for why E2E is the wrong layer for them. * test(stack-files): collapse 7 per-describe seed hooks into 1 file-scope pair Each inner describe previously ran its own seedSuite + teardownSuite pair: 7 logins, 7 stack creates, 7 stack deletes, all serial. That added 15-20 seconds of CI setup overhead on a single-worker run and pushed the post-spec dashboard load past the 10 s budget some downstream specs allow on their loginAs await. Moving the seed/teardown to file scope keeps the test stack present for every test in this file with one fixture pair. Each test still calls loginAs in its beforeEach so the page state is fresh, but the expensive setup runs once instead of seven times. |
||
|
|
ea002cd9a0 |
feat(stack-files): drag-and-drop upload zone (#1207)
* feat(stack-files): drag-and-drop upload zone The Files-tab dropzone was a click-only button. Drag-and-drop is the standard file-manager affordance and the audit doc named its absence as a known gap. The same dropzone now accepts file drops, gates the affordance on canEdit, and reuses the existing handleFile pipeline so all the existing rules apply: 25 MB cap, server-side path validation, multi-file drops rejected up front with a clear toast (the upload route only handles one file per request). dragenter+dragover light the zone in the brand color and swap the label to "Drop to upload"; dragleave honours bubbling from child nodes so the highlight does not flicker when the cursor crosses an inner icon. Drag events without a Files payload (text selection, image drag from another page) are explicitly ignored so the zone does not light up for non-file content. * test(stack-files): widen upload-zone E2E selector to match new label The drag-drop work renamed the dropzone label from 'Upload file' to 'Upload or drop file' (and 'Drop to upload' during hover). The E2E test in stack-files.spec.ts filtered on the literal /upload file/i, which no longer matches the new label and the test timed out at the visibility assertion. The hidden file input's aria-label='Upload file' was deliberately preserved, so every other locator in the spec (input[aria-label], getByLabel) keeps working. Only the role='button' filter needed the update. Widen the regex to /upload/i so future copy edits do not re-break this assertion. |
||
|
|
429f780f40 |
test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle (#1192)
* test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle Extends e2e coverage per L-2 of the stack-management audit. The existing stacks.spec.ts only covered create/delete and a double-click guard - no journey ever exercised real Docker through the deploy pipeline. Adds e2e/stack-deploy.spec.ts with three journeys: 1. Deploy success: creates a stack with a real-but-tiny compose file (alpine:3 sleep infinity), drives deploy through the authenticated browser session, and asserts the /api/stacks/statuses route reports the container as 'running' inside 30 seconds. Exercises the full middleware chain, ComposeService spawn, Dockerode status lookup, and the cache-invalidate path. 2. Deploy failure: creates a stack with intentionally malformed YAML, asserts the deploy route rejects with a 4xx/5xx and a parseable JSON envelope (no crash, no opaque text), and verifies the sidebar still renders the stack after reload. 3. Bulk lifecycle: creates two stacks, deploys both, then restarts both via per-stack POSTs (the path the current frontend bulk hook takes; once H-3 lands the hook fans out through the new bulk endpoint but the user-visible outcome is identical). Asserts both restarts return 200. Disconnect-mid-deploy journey is deferred (depends on M-1 WS reconnect). Rollback-after-failed-atomic-deploy is deferred (needs a paid-tier license that the default e2e setup does not seed). File-explorer journeys (upload/edit/delete) are tracked separately to keep this spec focused on lifecycle operations. Test design notes: - Drives deploy through page.evaluate + fetch rather than UI buttons to stay resilient to editor-button label churn. Auth surface is still real (cookies, middleware chain, audit log) - only the click is bypassed. The audit's "exercise real Docker via Playwright" intent is satisfied. - Each test calls teardownStack in a finally block so a failed test cannot strand a real container on the CI runner. - Uses alpine:3 (smallest viable long-running image) to keep CI pull cost minimal. The image is shared across tests so registry pull amortizes after the first. * test(stacks): bump E2E timeouts and parallelize bulk deploy CI hit the default 30s per-test timeout on the bulk lifecycle test because two serial deploys against a cold runner spent most of the budget on the alpine pull. The deploy-success test passed the prior run but had only ~0-5s of headroom outside its 30s status poll, so it was a flake away from the same fate. - bulk lifecycle: deploy both stacks in parallel (shared pull cache) and bump the per-test timeout to 90s. - deploy success: bump per-test timeout to 60s so the 30s status poll is not racing setup and teardown. The deploy failure test does no image pull (broken YAML rejects upstream) so it keeps the default 30s. |
||
|
|
7c84969b31 |
fix(editor): harden save-deploy, node-switch, delete, and stats reactivity (#1188)
* fix(stacks): validate input, bound YAML parses, and reorder delete steps
Backend hardening covering three editor-served routes:
- `/:stackName/containers` GET adds an explicit `isValidStackName` guard so
bad input is rejected at the call site even if the router-level param
validator changes in future.
- `MAX_COMPOSE_PARSE_BYTES` (1 MiB) bounds the two `YAML.parse` callsites
(`resolveAllEnvFilePaths`, `/services`) so a malformed or oversize compose
cannot exhaust heap during routine env/service lookups.
- `DELETE /:stackName` is reordered to abort before any database cleanup
if `FileSystemService.deleteStack` throws, keeping DB and FS in sync.
Partial-failure responses now describe the resulting state in human
terms instead of returning a generic 500.
Adds debug-mode entry-point traces (`[Stacks:debug] ...`) on save / down /
restart / delete handlers, all sanitised through `sanitizeForLog`. New
vitest covers the containers validator, the YAML size guard, and the
small-compose happy path.
* fix(editor): gate save-and-deploy on save success, abort stale loads
`saveFile` now returns a boolean: true on a successful PUT, false on any
failure. `handleSaveAndDeploy` short-circuits when save fails so a backend
500 on the compose write no longer slips through to a deploy with the
unsaved in-memory content. The diff-preview confirm path in ShellOverlays
applies the same guard.
`loadFile` now drives a per-hook `AbortController`. A stack switch, a
node switch (via `resetEditorState`), or hook unmount aborts the in-flight
GET chain so a late compose / env / containers / backup response from the
previous selection never overwrites freshly-loaded state.
`hasUnsavedChanges` is exported so EditorLayout can check it during the
node-switch lifecycle. New unit tests cover the boolean save contract and
the save-fail-blocks-deploy invariant.
* fix(editor): prompt on node switch when the editor has unsaved changes
Switching the active node previously called `resetEditorState()` without
checking the editor's dirty state, silently dropping in-progress edits.
The post-auth shell now intercepts the node-change effect: if the editor
is dirty, the attempted node is stashed via the existing
`pendingUnsavedNode` field, `pendingUnsavedLoad` is set to a sentinel
that routes `discardAndLoadPending` to `setActiveNode`, and `activeNode`
is reverted to the previous node so the dialog can be resolved without
losing content.
A re-entrant switch (clicking a third node while the dialog is still
open) is now ignored — the second switch reverts silently so the
dialog's anchor stays on the first attempt. When the previous node is no
longer in the registry and cannot be reverted to, the operator gets a
warning toast before the wipe so the loss is at least visible.
* fix(editor): split delete and deploy permission gates in the action bar
The action bar previously wrapped every affordance — including the Delete
menu item — in a single `can('stack:deploy')` check, even though the
backend route requires `stack:delete`. A user with `stack:deploy` only
saw a Delete button that 403'd, and a user with `stack:delete` only saw
no menu at all.
Each affordance now renders against its own permission: deploy / stop /
restart / update on `stack:deploy`, delete on `stack:delete`, rollback on
`canDeploy + isPaid + backupInfo.exists`, scan on `isAdmin +
trivy.available`. The overflow menu appears if any of {rollback, scan,
delete} is granted, so a delete-only operator still has a way to remove
the stack.
Adds a Monaco model dispose on EditorView unmount via the existing
editor ref, and a compact `Stats unavailable` chip in the CONTAINERS
header that lights up when the live-stats WebSocket reports a persistent
failure.
* fix(editor): make container-stats hook reactive to the active node
`useContainerStats` previously read the active node id from
`localStorage` on each WebSocket open, with a deps array of `[containers]`
only. After a node switch the stats stream stayed pointed at the
previous node's `/ws` endpoint until the containers array refreshed.
The hook now accepts `activeNodeId` as a second argument, depends on
`[containers, activeNodeId]`, and drops the localStorage read. The
return shape is `{ stats, error }`: the error field carries a string
when the stream fails, surfaced by EditorView as a small chip in the
CONTAINERS header. A per-WS `warnedOnce` set ensures a flaky daemon
emits at most one console.warn per stream lifetime, never at message
rate. Close codes 1000 / 1001 stay silent (normal teardown, navigation).
The error reset (`setError(null)`) is split into its own effect keyed on
`activeNodeId` so the banner does not flap on every containers-array
refresh tick against a persistently-flaky daemon. Tests cover the new
shape, the node-id reactivity, and the abnormal-close warn behaviour.
* docs(editor): describe new gate split and add troubleshooting entries
Updates the editor cockpit page to reflect that the action bar now
gates each affordance on its own permission (deploy / delete), and that
the bar appears for delete-only users so a stack can still be removed.
Adds three troubleshooting accordions covering the new behaviours: a
failed save that blocks the subsequent deploy, the unsaved-changes
prompt on node switch, and the live-stats chip when the daemon is
unreachable.
Adds an E2E spec verifying that a forced PUT 500 on the compose write
surfaces the failure toast and prevents the deploy POST from firing.
* fix(stacks): use printf-style format for compose-down warn
`console.warn` treats arg-1 as a printf format string when subsequent
args follow. The template literal here interpolated a sanitized but
not %-escaped stackName into arg-1 alongside an error argument, so a
stackName containing a `%s` placeholder could theoretically swallow
the error in the substitution. Switch to the file's established
`'... %s ...', value, err` pattern.
* test(editor): fix save-deploy spec; click both edit buttons, drop Monaco fill
The editor has two edit affordances: a lowercase 'edit' in the Anatomy
panel header that swaps the right column to the editor tabs, and a
capital 'Edit' in the editor toolbar that flips Monaco from read-only
into edit mode. The spec previously matched both with a case-insensitive
regex and only fired one click, so Monaco never entered edit mode.
It also tried to fill .monaco-editor textarea — that element is Monaco's
IME accessibility helper, hard-coded readonly; the real editable surface
is a contenteditable div.
`saveFile()` does not gate on a dirty buffer, so the spec does not need
to modify Monaco at all. Click both edit buttons with case-anchored
regexes and drop the fill step.
* test(editor): disambiguate Save & Deploy locator from sidebar row
The TEST_STACK fixture is named 'e2e-save-deploy-stack'. The sidebar
renders each stack into a div with role=button whose accessible name
includes the stack slug, so the regex /save.*deploy/i matches both the
sidebar row ('e2e-save-deploy-stack') and the editor toolbar's actual
Save & Deploy button — strict-mode bails. Anchor the locator to the
literal button text with exact:true.
|
||
|
|
8ba88755b1 |
fix(stacks): default Empty template ships ports block commented out (#1189)
The Empty branch of the Create Stack flow wrote a compose.yaml whose first service bound the host's port 8080 by default. On any workstation already running something on 8080 (traefik, caddy, librespeed, another nginx, etc.) the very first deploy failed at the docker compose networking step with "Bind for 0.0.0.0:8080 failed: port is already allocated", which made the day-one experience feel broken right after F-2 (PR #1168) tightened the dialog itself. The boilerplate in FileSystemService.createStack now emits the ports block commented out plus a one-line hint above it. A fresh deploy binds no host port, so the container starts cleanly on any host; the user uncomments the two-line block when they're ready to expose the container. The deterministic shape (no probe-and-write, no random port, no preflight scan) avoids the TOCTOU race that a free-port probe would have left between template creation and the actual compose up. Adds backend/src/__tests__/file-system-service-create-stack.test.ts (8 cases): directory + file creation, structural YAML assertions via yaml.parse to lock in the no-live-ports invariant, raw-text regex to lock in the commented hint, and the already-exists rejection path. FileSystemService.createStack had no coverage before this change. Adds e2e/default-stack-template-no-fixed-port.spec.ts (1 case): drives the dialog through Create, reads the resulting compose via the in-browser apiFetch, and asserts the live + commented invariants end-to-end. docs/features/stack-management.mdx Empty bullet rewritten to describe the minimal skeleton and the commented ports block instead of calling it "blank". Resolves: F-3 in the v1.0 audit tracker. |
||
|
|
fcff8e9047 |
fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11) (#1175)
* fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11) A host metric over threshold previously dispatched one notification every 5 minutes for the duration of the breach, producing 7+ identical messages in 35 minutes and spamming Discord/Slack routes. Replace the hardcoded 5-minute cooldown for CPU/RAM/disk with a per-metric suppression window (default 60 min, configurable via host_alert_suppression_mins). The first breach fires immediately; subsequent cycles within the window are silently counted; the next dispatch after the window elapses carries a summary suffix listing how many cycles were suppressed and when the breach first crossed threshold. Recovery clears the counter so re-breach fires fresh. The pattern mirrors PolicyEnforcement.notifyTrivyMissingOnce: module-scope Map, in-memory only, in-cycle dedup, with a test-reset helper. The existing system_state row keeps post-restart re-fires bounded. Janitor and per-stack alert rules are unchanged; they already have adequate cadence and per-rule cooldown respectively. * fix(ci): restore backend and frontend checks * fix(e2e): remove create button timing race * fix(e2e): harden create double-click test * fix(monitor): clear persisted F-11 timestamp on recovery + clamp suppression window Independent audit on the previous commit surfaced two issues. 1. clearHostMetricSuppression early-returned on missing in-memory state, leaving a stale system_state.last_host_*_alert_ts row alive after a process restart. Scenario: breach fires + persists timestamp, process restarts, metric recovers before another evaluate cycle re-seeds the in-memory Map, recovery cleanup early-returns. Next re-breach inside the original window hits the restart-survivability branch and is silently suppressed instead of firing fresh. Fix: read persisted state in clearHostMetricSuppression and reset to '0' independently of in-memory presence. The read-before-write also skips redundant writes when the row is already cleared. 2. host_alert_suppression_mins is validated by zod on the bulk PATCH path but the single-key POST /api/settings path accepts allowlisted keys without re-validation. A 999999999-minute value would silence host alerts for centuries. Add MAX_HOST_ALERT_SUPPRESSION_MIN = 1440 mirroring the zod max, and clamp via Math.min in evaluateGlobalSettings. Two new vitest cases (restart-then-recovery-then-rebreach; the 1440 clamp) confirmed failing before the fix, passing after. The existing "metric drop" case updated to use a mock-backed persistence pattern consistent with the new restart-scenario tests. 73/73 monitor-service tests green; full backend suite 2507/2510 (same pre-existing Windows EBUSY flake on filesystem-backup.test.ts as baseline). |
||
|
|
9d8d8abcba |
fix(stacks): close dialog and toast on Empty create (F-2) (#1168)
* fix(stacks): close dialog and toast on Empty create (F-2)
The Empty branch of the Create Stack dialog left no visual confirmation
on a successful POST: no success toast, no busy state on the Create
button, and no double-click guard. The first thing a new operator does
is create a stack, so a click that looks like a no-op is the day-one
impression killer.
What changed
- Empty handler now mirrors the Git and docker-run patterns: a
creatingEmpty busy guard, Loader2 spinner on the Create button,
disabled Cancel mid-flight, and a "Stack <name> created." success
toast fired synchronously before the editor-load handoff.
- The Empty panel is wrapped in a form so the Enter key submits the
same path as clicking Create.
- Mapped 403 to a clearer permission message; fall back to the
backend's error string for other non-OK statuses.
- Capture the active node id at handler entry and pass it through
onStackCreated. The parent compares against the live node ref and
skips the editor load if the user switched nodes mid-create, with
an info toast pointing at the prior node.
- Tightened the create-a-new-stack E2E (no more silent .catch on the
dialog-close assertion, asserts the toast, no longer relies on a
page reload to see the sidebar entry) and added a regression spec
that the busy guard collapses double-clicks into a single POST.
* fix(stacks): close double-click race in create handler (F-2)
The setState-based busy guard could race a second click that landed
before React committed the disabled state, so two POSTs got dispatched
when a user double-clicked Create. The new useRef-based check is
read and written synchronously inside the handler so a re-entrant
invocation bails before issuing a second fetch.
The accompanying E2E (`create dialog: double-clicking Create fires
only one POST`) was also hanging to its 30s test timeout: the second
click's locator resolution could outlive the dialog when the first
POST resolved quickly, so Playwright waited for `[role="dialog"]` to
reappear. Both clicks now fire in the same microtask via Promise.all
with a 1s timeout on the second click so the locator-resolution path
fails fast instead of hanging the test.
* fix(stacks): keep busy guard active across modal close (F-2)
Two issues from independent review.
1. Important: resetting creatingEmpty / creatingEmptyRef inside the modal
close handler created an Escape/backdrop race. A user could close the
dialog mid-flight (clearing both flags), reopen, click Create again,
and slip past the guard before the first POST settled. The finally
block already owns that lifecycle, so the close handler no longer
touches either flag.
2. Nit: bring 403 messaging to parity with the Git branch. The Empty
handler now reads the backend's error field first and falls back to
the original hardcoded copy if it is missing, mirroring how
handleCreateStackFromGit surfaces backend permission errors.
* test(stacks): rebuild double-click spec around observable disabled-state (F-2)
The Promise.all racing approach to "double-click fires only one POST"
hung to the 30s test timeout on CI. The page snapshot at timeout confirms
the product fix works correctly (dialog closed, stack created, editor
navigated); the test itself was the flake. Race-based assertions on a
single React-state commit boundary are inherently timing-sensitive.
The rewrite turns the test inside-out:
- The route handler holds POST responses for 400ms so the in-flight
window is large enough to observe deterministically.
- await route.continue() (instead of void fire-and-forget) closes a
subtle stall pattern Playwright's docs flag under load.
- After the first click the test asserts the button reaches a disabled
state within the in-flight window, which proves the busy guard
activated at the React-state layer.
- A best-effort force-click against the now-disabled button exercises
the user-mash path; the synchronous useRef guard inside the handler
catches it whether or not the browser dispatches the click.
- expect(postCount).toBe(1) still owns the actual regression assertion.
No product code change. The synchronous creatingEmptyRef guard already
landed in
|
||
|
|
535023b350 |
feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier
Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.
The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.
* docs(files): treat download as a read action, not a write
Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.
* test(e2e): align stack-files spec with the new tier rule
The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.
Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
|
||
|
|
3ad6ea9c5d |
feat(pilot): make Docker Compose the canonical pilot enrollment payload (#1121)
* feat(pilot): make Docker Compose the canonical pilot enrollment payload The Add Node dialog for a pilot-agent now returns a Compose snippet instead of a single-line docker run command, and the enrollment dialog walks the operator through a save-and-up flow. The compose project name and container name align with what SelfUpdateService looks up at boot, so a Compose-deployed pilot can be updated remotely through the Fleet view without intervention on the remote host. Docs (pilot-agent, remote-updates) were rewritten to match. * test(e2e): align pilot enrollment spec with Compose payload The spec was written against the docker-run payload; it now asserts the Compose YAML the dialog renders. |
||
|
|
e380ee7b16 |
test(pilot): in-process integration test and Playwright E2E (#983)
* test(pilot): in-process integration test for the tunnel handshake
Layered unit tests cover the protocol decoder, the bridge cap, the
manager, and the DB-layer enrollment lifecycle. None exercise the
glue: the WebSocket dispatch order in upgradeHandler.ts, the actual
hello / enroll_ack round-trip, the long-lived token swap. A
regression that moves /api/pilot/tunnel below the auth gate, breaks
the hello / enroll_ack ordering, or changes the token shape would not
be caught today.
Spin up a real http.Server with attachUpgrade wired and a real
ws.WebSocket client. Three tests:
- Enroll-ack happy path: connect with an enrollment token, receive
hello + ctrl enroll_ack, assert the manager has registered the
tunnel.
- Replay rejection on the wire: connect, complete enroll-ack, close,
reconnect with the same enrollment token, assert HTTP 401 from the
upgrade handshake.
- Long-lived token reconnect: capture the enroll_ack token, close,
reconnect with the long-lived pilot_tunnel JWT, assert hello but
no enroll_ack and the tunnel is registered.
The test does not mock the agent side beyond the framing protocol.
The goal is to confirm the wires connect end-to-end, not to drive a
full request round-trip (which would require a synthetic agent bridge
and adds little marginal coverage over the existing bridge unit tests).
This file also establishes the in-process WS-pair test pattern for
any future WebSocket integration work; no such pattern existed in the
suite before.
* test(e2e): cover operator-side pilot-agent enrollment in Playwright
Backend integration is covered by pilot-tunnel-integration.test.ts
and the vitest enrollment suites. The browser-only surfaces (mode
selector default, enrollment dialog, docker run code block,
regenerate affordance on an existing pilot-mode node) had no
automated coverage; a typo in the mode label, a styling regression
that hid the docker-run code, or a backend response shape change
that broke the rendered command would all ship unchecked today.
Two specs reusing the existing loginAs helper and the same
Settings -> Nodes navigation pattern from nodes.spec.ts:
- Create flow: open Add Node, switch type to Remote, confirm pilot
mode is the default (api_url field stays absent), submit, assert
the enrollment dialog renders a docker run command containing
SENCHO_MODE=pilot, SENCHO_PRIMARY_URL=, and a JWT-shaped
SENCHO_ENROLL_TOKEN=. Cleanup deletes the row.
- Regenerate flow: create a pilot node, capture the first token,
open the row's edit dialog, click Regenerate enrollment token,
assert the second token differs from the first. Cleanup deletes
the row.
Out of scope for this E2E: simulating an agent connecting to flip the
row to Online. The integration test covers the wire side; this spec
keeps focus on the operator-visible UI.
Each test name-suffixes with Date.now() so re-runs do not collide on
the UNIQUE name constraint and so leftover rows from a partial run
get distinct names instead of stacking on the same row.
* fix(pilot): address PR B code-review findings
Three code-review findings:
- High: NodeManager row action buttons were icon-only with no
accessible name. Added aria-label="Edit node",
aria-label="Delete node", aria-label="Test connection" so the
Playwright E2E can target them by role/name (currently the
accessible name is rendered into a Radix tooltip portal that
Playwright cannot resolve as the button's name).
- High: Replaced UI-driven cleanup in the E2E with API-based
deletion via page.request, run in both beforeEach (sweep
leftovers) and afterEach (sweep this test's row even on failure).
Targets every node whose name starts with the test prefix so a
crashed previous run cannot affect the next.
- Medium: Replaced two fixed 50ms sleeps in the integration test
with vi.waitFor polling on hasActiveTunnel(nodeId) === false,
eliminating the race between the WS close hop chain and the next
test step on slow CI runners.
Plus two cleanup items:
- The integration test's afterAll now removes 'tunnel-up' /
'tunnel-down' listeners from the manager singleton so this
file's runs do not leak listeners into other test files in the
same Vitest worker.
- Tightened the WS error swallowing in the rejection-path test:
only swallow the expected "Unexpected server response" error
shape; surface anything else (ECONNREFUSED etc.) instead of
silently masking it.
No em dashes added (Directive 18 verified clean).
|
||
|
|
3b650523c1 |
Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans
Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.
Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.
* feat(security): acknowledge misconfig findings
Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.
Schema and behavior parity with cve_suppressions:
- UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
collide as expected
- blockIfReplica on every write
- Audit-log entries name the scope (rule_id, stack_pattern) but
never the reason text
- replicated_from_control flag controls UI delete affordance and
drives clearReplicatedRows on demote/reanchor
- Validators reused: validateStackPatternForRedos for glob safety,
sanitizeForLog for log fragments
SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.
Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.
Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.
* fix(security): reap orphaned trivy tmp dirs at startup
When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.
* perf(security): emit per-batch summary for scanAllNodeImages
Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.
* perf(security): cap SARIF export at 5000 findings per type
Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.
A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.
* docs(env): document TRIVY_BIN host-binary override
The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).
* test(security): cover scanComposeStack failure modes
Two new cases drive the existing try/catch through real failure
paths:
- Malformed Trivy stdout: row flips to status='failed' with the
parser error preserved on `error`.
- execFile rejection: row flips to status='failed' with a string
error message.
Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.
* test(e2e): security scanner + misconfig acknowledgement flow
Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
- Trivy availability gate (skips suite when binary absent so CI
without Trivy can opt out via E2E_SKIP_TRIVY=1)
- Stack config scan completes and records misconfig findings
- Concurrent stack scan returns 409 from the dedup gate
- Misconfig ack POST creates and lists on Settings
- Duplicate (rule_id, stack_pattern) returns 409
- Malformed rule_id (shell metacharacters) returns 400
- Misconfigs tab renders against a real stack scan
Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.
* docs(features): add misconfig acknowledgement workflow and SARIF cap
Refreshes vulnerability-scanning.mdx with:
- Misconfig acknowledgements section covering the per-row dialog,
Settings panel, scope/matching rules, and SARIF emission
- Tier table row for the new feature
- SARIF section note on the 5000 row-per-type cap and the
properties.truncated marker for partial exports
- Troubleshooting entries: SARIF cap, hidden Acknowledge button,
findings resurfacing after delete, Trivy DB phone-home, and
409 on concurrent compose-stack scans
* fix(ci): clear backend lint and CodeQL alerts
- Remove the dead fetchAllPages helper in routes/security.ts. It lost
its callers when the SARIF endpoint switched to direct paged reads
for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
paths under os.tmpdir() with predictable names tripped CodeQL's
js/insecure-temporary-file rule (high severity), which warns about
symlink-pre-creation attacks even in test code. mkdtempSync appends
a process-random suffix and creates the dir atomically; the
sencho-trivy- prefix is preserved so the production sweep still
matches the test fixtures.
|
||
|
|
9ab7819b24 |
refactor(frontend): migrate Stack/Fleet confirms and Git source dialog (#951)
* refactor(frontend): migrate Stack/Fleet confirms and Git source dialog
- LocalUpdateConfirmDialog -> ConfirmModal with kicker LOCAL · UPDATE
- GitSourcePanel form -> Modal with kicker {STACK} · GIT SOURCE; the
custom three-button footer (Remove / Pull now / Save) stays as-is
since ModalFooter's two-slot pattern doesn't fit a left-aligned
destructive action; ModalHeader provides the canonical chrome
- GitSourcePanel remove confirm -> destructive ConfirmModal with
kicker {STACK} · GIT · DISCONNECT
* test(e2e): scope git-source dialog assertion to the heading
The migration adds a kicker rune "<STACK> · GIT SOURCE" inside the
dialog. The previous locator getByText('Git Source', { exact: false })
matched both the kicker (uppercase, mono) and the italic-serif title,
producing a strict-mode violation. Use getByRole('heading') to target
the title specifically.
|
||
|
|
0442bd29b3 |
refactor(frontend): migrate NodeManager dialogs to Modal chrome (#949)
* refactor(frontend): migrate NodeManager dialogs to Modal chrome
Bring all four NodeManager dialogs onto §10 Modal primitives:
- Add node form -> Modal at lg, kicker NODES · ADD LOCAL or
NODES · ADD REMOTE depending on the form's type radio. The Add
button is now a normal button that calls setCreateOpen(true)
rather than a DialogTrigger
- Edit node form -> Modal at lg, kicker NODES · EDIT
- Pilot enrollment dialog -> Modal at xl, kicker NODES · PILOT ENROLLMENT
- Delete confirm -> destructive ConfirmModal, kicker
NODES · DELETE · IRREVERSIBLE
handleDelete now closes from finally so the dialog clears on errors
too, matching the ConfirmModal Promise-aware contract.
* fix(frontend): make ModalBody scrollable, scope nodes test locator to dialog
Two related fixes for the Add Node modal regression on small viewports:
1. ModalBody now caps at max-h-[calc(85vh-12rem)] with overflow-y-auto.
Tall forms that would push the footer offscreen on a 720px viewport
(Add Node has 6 form sections plus header and footer) now scroll
inside the body while the cyan-rail header and Cancel/Submit footer
stay anchored. Benefits every form modal, not just NodeManager.
2. e2e/nodes.spec.ts now scopes the submit-button locator to
getByRole('dialog'), removing the .last() pattern. The previous
approach worked when the modal was guaranteed to render after the
trigger in DOM order, but it doesn't survive an offscreen footer.
|
||
|
|
6f45a3b788 |
fix(frontend): align sidebar brand box with top nav chrome (#928)
* fix(frontend): align sidebar brand box with top nav chrome Match the TopBar's 56px height so the bottom border of the brand box meets the bottom border under the nav, removing the 7px seam. Drop the duplicate uppercase "SENCHO" label and place the version number baseline-aligned next to the italic wordmark. Center the brand row, enlarge the logo (28→36px) and wordmark (22→28px), and mark the logo as decorative since the wordmark already names it. * test(e2e): key dashboard sentinel off logo src instead of alt text The brand box change made the sidebar logo decorative (alt=""), since the adjacent wordmark already names the brand. The Playwright helper keyed loginAs() off img[alt="Sencho Logo"], so every login wait timed out and 11 specs failed (with 25 cascading skips via suite teardown). Switch DASHBOARD_INDICATOR to img[src*="sencho-logo"]: same DOM target, unaffected by accessibility wording. The selector is unique to the authenticated sidebar — login/setup screens do not render it. |
||
|
|
945259f048 |
refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4) (#908)
* refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4)
Swap raw shadcn Dialog/DialogContent/DialogHeader/DialogFooter for the
Modal/ModalHeader/ModalBody/ModalFooter primitives shipped in D-1 and
introduce an inline ModeRail to replace the TabsHighlight chip. The new
chrome carries the cyan rail, mono kicker (STACKS · NEW), italic serif
title, and contextual footer hints per mode (ALPHANUMERIC · HYPHENS,
HTTPS REPOS ONLY, CONVERT FIRST / YAML READY + line-count accent).
ModeRail implements the full WAI-ARIA tabs pattern: aria-selected on the
active tab, aria-controls/role=tabpanel/aria-labelledby linkage to each
mode panel, roving tabIndex (active=0, inactive=-1), and ArrowLeft /
ArrowRight / Home / End keyboard navigation matching the contract that
the prior shadcn Tabs primitive provided.
Each mode also gains an explicit Cancel button alongside the existing
primary action, the empty-mode primary button is now disabled until the
stack name is non-empty, and the ModeRail disables itself while a Git
or docker-run create is in flight.
The async handlers (handleCreateStack, handleCreateStackFromGit,
handleConvertDockerRun, handleCreateStackFromDockerRun) and form-reset
helpers are unchanged; this PR is structural plus the segmented-control
redesign called out in the migration tracker as the D-4 risk note.
* test(e2e): align git-sources spec with new CreateStackDialog title
D-4 renamed the dialog title from "Create New Stack" to "New stack".
The shared openCreateStackDialog helper in git-sources.spec.ts was
still asserting the old literal, so three tests in the "Create stack
from Git" group failed at the helper's first assertion.
Update the assertion to match the shipped title and refresh the two
stale references in docs/features/stack-management.mdx so the docs
stay in sync with the UI copy.
* test(e2e): use dialog accessible name in openCreateStackDialog helper
The previous helper used getByText('New stack') which matched two
elements: the dialog title h2 and the sr-only description (which
starts "Create a new stack: empty, ..." and contains the substring).
Playwright fails with a strict mode violation.
Switch to getByRole('dialog', { name: 'New stack' }), which asserts
the dialog by its accessible name (provided by DialogTitle via the
Radix aria-labelledby wiring). One match, more precise, and
independent of any future description copy.
|
||
|
|
1f8ce773ff |
feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists. |
||
|
|
1115650e78 |
chore(ci): drop auto screenshot refresh job, switch to manual capture (#884)
The release-only `update-screenshots` job opened a `chore/refresh-screenshots` PR and immediately tried to squash-merge it. Branch protection (1 review, 6 status checks) rejected the merge on every release, leaving an open PR behind. Screenshots will instead be refreshed manually after UI changes. Removed: - The `update-screenshots` job from ci.yml (~57 lines). - The `paths-ignore: docs/images/**` push trigger filter; its sole purpose was to break the auto-merge re-trigger cascade. Its absence also fixes a latent bug where docs-only pushes to main would have skipped sync-docs. - Four `head_ref != 'chore/refresh-screenshots'` guards in other jobs. - The "doc screenshots" mention in the skip-bot-PRs comment. Reworked the screenshot capture spec to be opt-in: - playwright.config.ts now defines two projects. The default `chromium` project ignores screenshots.spec.ts; a separate `screenshots` project matches it and is invoked manually. - The e2e CI job runs `--project=chromium` so the screenshots project cannot accidentally run in CI. - Updated the spec's module comment with the new manual invocation. Net: 86 lines removed, 27 added. |
||
|
|
6fa0272e79 |
fix(frontend): replace post-dismissal blur in PaidGate / AdmiralGate with click-to-restore pill (#874)
* fix(frontend): replace post-dismissal blur with click-to-restore pill PaidGate and AdmiralGate fell through to a "blurred children + small pill" render when a user clicked Dismiss on the full-page upsell, for the next 24h. The blurred-children path rendered the gated subtree, so any lazy chunk behind the gate (FleetView, AuditLogView, etc.) fetched on click during the dismissal window even though the user saw only an obscured preview. This was the last lazy-chunk leakage path remaining after the recent splitting work; CapabilityGate's short-circuit refactor closed the others. Split the dismissed branch from the compact branch. Compact mode (used for inline list-item locks like a single SSO provider card) is unchanged: its blur is intentional UX and the IP exposure is minor because the children are tiny inline UI. Dismissed mode now renders only a small pill in an empty 200px-tall area, with no children mounted, so the lazy chunks behind the gate never fetch during the dismissal window. The pill is a button: clicking it removes the dismissal flag from localStorage and re-renders the full upsell card. Users who dismissed accidentally or want to revisit pricing have a way back without clearing site data manually. The dismissal flow itself is preserved: users who want to mute the upsell pressure for 24h still can, they just get the static pill instead of a blurred preview during that window. * test(e2e): narrow upgrade-pill locator to file-upload button The dismissed PaidGate branch introduced in this PR now renders a <button> (was a <div>), which matched the same /upgrade to unlock/i regex as the FileUploadDropzone button. Narrowing to /upgrade to unlock upload/i targets only the file-upload pill and resolves the strict-mode locator ambiguity. |
||
|
|
eead195529 |
feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)
Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.
Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.
Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.
Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.
* refactor(settings): drop react-router-dom and align with DESIGN.md
The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:
- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
useNavigate. SettingsPage owns currentSection via props lifted to
EditorLayout, so cross-component navigation (openLabelManager,
onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json
The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:
- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
--success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
tone now genuinely uses --success green; new brand tone for promotional
callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
aside gains text-card-foreground transition-colors per §10 canonical
card class
- SettingsPage main panel: text-card-foreground transition-colors added;
uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
tokens with literal fallbacks so Settings respects the comfortable/
compact toggle
* fix(e2e): update mfa openAccountSettings to match settings redesign
Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.
* test(e2e): fix MFA enrolment assertion after settings redesign
The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.
* test(e2e): fix low-backup-codes warning assertions after settings redesign
Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
identifies the zero-codes error card without hitting strict-mode
from two identically-labelled Regenerate buttons on the page
* test(e2e): navigate to root before re-opening settings for mock refresh
The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.
* test(e2e): scroll zero-codes callout into view before asserting visibility
The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.
* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion
The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.
* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue
The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
|
||
|
|
9a1c043189 |
refactor(settings): replace modal with nested full-page route (#848)
* refactor(settings): replace modal with nested full-page route
Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.
- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx
* fix(settings): validate sectionId against registry before property write
Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.
* fix(settings): eliminate remote property injection via Map and registry-sourced key
Two-part fix for CodeQL js/remote-property-injection:
1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
registry data) instead of the raw sectionId URL param. The tainted
string never flows into any property access.
2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
instead of a plain object. Map operations do not write to the prototype
chain, removing the prototype pollution vector entirely.
* test(e2e): align settings selectors with full-page route
The settings refactor (
|