mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 21:20:25 +00:00
docs/tutorials-batch-3
2180 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
712c96ac6b |
fix(editor): keep the compose toolbar inside the pane at 1280 (#2125)
The save cluster was laid out past the viewport, so Save & Pull Images was unreachable without widening the window. |
||
|
|
7a6682ed3f |
fix: pull the next commit on a manual Git-managed Blueprint (#2130)
* fix: pull the next commit on a manual Git-managed Blueprint A claimed manual source had no operator fetch: polling skips it, and stack Pull now stays refused. Pull source stages a candidate and leaves acceptance to Accept source, except an automatic policy, which accepts the same way a poll does. * test: supply a node list to the GitOps sheet mocks The authority row reads the default node from the node context. These mocks omitted nodes, so the sheet threw before it rendered. * test: type the Git source permission mock without unused parameters The default can implementation always returns false. Naming the permission arguments made lint fail. * fix: send the Blueprint id and report what Pull source did The claimed sheet posted the internal application id, so the pull never reached the Blueprint. An automatic pull could accept and start a rollout while the result only said the commit was fetched. * fix: report a continuing automatic pull instead of a failed one Acceptance can commit before preparation and rollout finish. Answer once that wait reaches its deadline, keep the source locked until the work finishes, and do not describe the deadline as a failed pull. * fix: report why an automatic pull cannot prepare a commit A completed preparation reason was replaced by the generic unresolved-artifact skip. Keep that reason, and describe the claimed sheet by the source actions it still offers. * fix: refuse a pull that would replace an interrupted apply's candidate Apply recovery stays eligible only while the interrupted generation is still the current candidate. Pull source waits in that case. An interrupted fetch still pulls, and a pull stays available once the candidate has moved on. * refactor: share the bounded follow-up after a Git-managed acceptance Accept source and an operator pull each had their own copy of the deadline, the continuation notes, and the slot hold. One follow-up now answers both. * fix: declare the post-accept deadline timer as const The deadline id is assigned once. A later assignment made the lint fail. * fix: confirm Accept source before it can start a rollout Accept source recorded the fetched commit immediately, including when stored rollout authorization can deploy it, and those actions were not on the sheet Overview. The confirmation names the commit, the approved targets, and whether a rollout can start, and confirm posts the generation that was on screen. * fix: read placement approval only on a Blueprint projection The accept preview read approvals on every projection, and the one with nothing to show has no approvals. The Blueprint check comes first so the build can read the placement ref. * fix: name the approved rollout scope and report settled failures Two confirmations could describe a destination the action would not use, and one result reported work as starting when it had already failed. Accept source named every observed target row as approved placement. Those rows are where an application runs, not where a rollout goes, and they disagree whenever placement moved since the last rollout ran: an approved node has no row until the rollout that places one, and a withdrawn node keeps one. A projection now carries the scope the placement approval froze, read from the rollout candidate and accepted only once the approval resolves for that set, which is the evidence the dispatch boundary already uses. Both confirmations read it through one reader and name the approved nodes, or say the scope cannot be read rather than naming a set they did not. The bounded follow-up used its continuing-work answer for a deadline and for a rejection alike, so a dispatch that had already failed was reported as starting, with nothing pending. The three outcomes are now distinct: only a deadline holds work still running, and a rejection reports the rollout as held with the failure named. * fix: bind a source confirmation to the request that carries it Pull source and Accept source asked the operator to confirm a destination and then sent nothing that named it. Fetch posted an empty body and acceptance posted only a generation, so execution read whatever the placement and the rollout policy happened to say at the moment it dispatched. An operator could confirm a placement for one node and deploy to another, and a dialog promising a waiting rollout could start one after another session armed the policy. Both bodies now carry what the dialog read: the placement approval the scope was resolved from, and whether the confirmation said a rollout could start. The description and the claim are frozen together when the overlay opens, so the words the operator agreed to and the state the server checks are one read of one projection. The claim is checked at every consequential boundary rather than only at the route, because each one is preceded by asynchronous work: at the route and at the controller entry, again before an automatic acceptance after the reconcile, and again in the handoff after preparation. A refusal that lands after the acceptance committed is held with the reason instead of answered, since the reconciler re-drives any accepted generation with nothing to check it against and a refusal that recorded nothing would be undone by the next tick. The hold carries an operator origin, so only a person clears it, and the recovery is the existing one: review the placement, then resume the rollout. The destination is compared exactly, because the dialog named specific nodes. The rollout promise is compared one way: a confirmation that allowed a rollout and finds the policy waiting for an operator does less than it said, and the dispatch gate already reports that, while the reverse would start something the operator was told would wait. A manual or review pull only fetches, so it asks for nothing, and the poll and resume paths send no confirmation at all because a cadence is configured rather than confirmed. * fix: keep the confirmation hold inside the mode that can clear it Three ways the refusal did not survive contact with the rest of the model. The hold was recorded below the rollout authorization policy gate, so a manual source paused the rollout over a confirmation the handoff could never have acted on. A manual policy never dispatches from here, and its Authorize is already the operator's own decision against the placement as it stands, so all that bought was an extra resume step before an authorize the pause also blocks. The gate is back first and the confirmation is checked under it, so only an automatic policy, the one that would have dispatched on its own, is held. The hold outlived the Blueprint. Every resume route resolves a Git-managed Blueprint and answers NOT_GIT_MANAGED for the row afterwards, so a pause carried through Retire or Detach read as a paused rollout nothing could clear. Both demotion paths now clear it, which is what the Blueprint demotion already does with every other column it invalidates. And the module claimed the check ran at every consequential boundary, which is only true of the boundaries an operator action reaches. A cadence carries no claim to check, so the poll and the reconciler's re-drive are not among them, and the hold is what keeps them from undoing a refusal. Each correction is covered by a test that fails without it: a manual-policy acceptance whose placement moves during preparation is not held, an operator hold survives a later acceptance on the policy's own behalf, and retiring a Blueprint releases a held rollout. * fix: make a confirmed acceptance keep its placement past the request A confirmation carried only in a request body stops existing when the request answers, and two paths outlive it while both still dispatch. Preparation can run past its deadline and hand the handoff to the reconciler, and a restart re-drives any accepted generation with no request in the picture. The guard therefore protected the fast path and nothing else, which is worse than none: the operator was told to review a change that then deployed on its own. The acceptance now records the precondition on its own approval row, in the column that already existed there and was unused for this kind. A null recording is a real answer rather than missing evidence: an acceptance made by the poll records none, because a cadence is configured rather than confirmed. It is enforced in ensureRolloutAuthorization, the single funnel every mint passes and the only place an unattended dispatch reaches, so it covers the reconciler's re-drive and the startup preflight backfill as well as the request that carried the claim. Only a policy mint is bound: an operator mint is the decision the confirmation deferred, taken against the placement as it stands. A stale precondition holds rather than merely refuses, because a refusal with nothing behind it is re-attempted every reconcile and leaves a rollout queued with no reason attached. The acceptance itself stands, so the content is recorded and only its destination waits for a person. An application-scoped resume releases the recording; a target-scoped one does not. The same function now re-resolves ingredients after the registry preflight await and refuses when the placement approval or the frozen node set moved. The mint re-reads ingredients inside its write transaction, so a placement re-approved mid-preflight produced a rollout naming the replacement nodes with readiness evidence that described the old ones. That gap predates this work, but a request-local guard cannot see past an await, and a rollout generation is immutable once written. * fix: compare the whole ingredient record, and let an operator mint decide Two gaps in the durable guard, both found by reading it against the paths that reach it. The post-await comparison watched the placement approval and the frozen node set and nothing else. A source acceptance landing during the same preflight window changes the generation, the artifact set and the acceptance without moving either of those, so the check passed and the mint named the new generation with readiness evidence evaluated for the old one, which is the same defect the comparison was added to close. Every field the mint copies onto an immutable rollout generation is compared now. An operator mint is exempt from the guard, because asking to authorize the rollout is the decision the confirmation deferred. Nothing moved the recording on that path, so once the authorization lapsed the automatic policy held a rollout asking the operator to resume a placement they had deliberately authorized. An operator mint now re-points the recording at what was decided, inside the mint's own transaction, which also keeps the guard working for the next replacement. * fix: keep the whole source confirmation after the request ends A stored placement pointer let a later re-drive start a rollout the operator was told would wait, or deploy to a destination the confirmation never named. The acceptance now stores both facts, and unattended authorization reads that record rather than the request that already finished. * fix: show source confirmations and start a ready automatic resume Operators were asked to confirm a sentence they could not read, and Resume reported that an automatic policy would start a rollout it never dispatched. |
||
|
|
1e52f3f134 |
ci(release-notes): require a conventional PR title (#2123)
release-please derives the changelog and the version bump from conventional-commit subjects read off main. Squash-merging takes the subject from the PR title, so a title the parser cannot read drops the change from the release notes with no warning. The build-identity feature (#1899) merged with an untyped title and never appeared in any release notes. The local commit-msg hook cannot catch this: it runs against the PR branch commit and never against the squash that actually lands. Add a pull_request workflow that validates the title, and read the allowed types from commitlint.config.js so the two cannot drift. The check enforces the conventional shape only, not the length or casing rules the local hook applies to a full commit. Those would only add noise here: Dependabot generates group titles longer than the commit header limit, and blocking on that would fail a PR whose title is otherwise fine. Correct CONTRIBUTING.md while here. It claimed that hand edits to the Release PR survive a regeneration; release-please replaces the PR body and force-pushes the release branch, so they do not. Point the guidance at the override block, and record that a dependency PR clearing an advisory must not merge as chore(deps), which is conventional and therefore passes the new check while staying out of the changelog. |
||
|
|
023801c980 |
docs: correct the cosign verification identity in release artifact guides (#2122)
cosign matches --certificate-identity-regexp case-sensitively against the certificate SAN. The signed identity is https://github.com/Studio-Saelix/sencho/.github/workflows/docker-publish.yml@refs/tags/vX.Y.Z, so the lower-case org spelling in the documented commands failed to match: verified against saelix/sencho:0.97.1 with cosign 3.1.3, the lower-case form errors with "no matching CertificateIdentity found" and the capitalised form verifies. Registry paths (ghcr.io/studio-saelix/...) stay lower-case because the registry lower-cases them; only the certificate identity is capitalised. Also restate what actually guards publication. The Docker Hub credentials live in the production environment, that environment allows only v* tag deployments, and required reviewers are intentionally unset because the Release PR merge is the only approval step. The dev workflow no longer describes that environment as an approval gate; the real distinction is that the dev build never publishes to Docker Hub. |
||
|
|
d48daa08ae |
feat: raise the Compact launcher quick-link cap to 10 (#2120)
Operators can pin ten destinations on the bar. The recommended starting set stays at six, and the server still accepts up to sixteen so a ten-pin list keeps syncing. |
||
|
|
7c99a65085 |
feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails (#2119)
* feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails Fresh installs now start with the Signature chart palette, per-service log chip colors, inline deploy progress, one retained rollback generation per stack, and the missing-required-env deploy guard on. Existing stored values are unchanged. * fix(settings): make appearance reset match the fresh-install chart default Reset to default was still applying the Calm preset, which put the muted chart palette back. It now restores upright headings, reduced effects, and the Signature chart palette, and the appearance screenshots match that look. |
||
|
|
23f87354c4 |
feat(stacks): rescan from the scan banner; recheck Blueprint name conflicts (#2118)
The post-deploy scan banner in the stack editor gains a Rescan verb. It starts the same scan on the node that owns the stack, follows it to the new result, and says so when the rescan finishes clean, since the banner then goes away. A second click while a scan is running starts nothing, and the scan-status read now reports whether one is running. Dismiss stays separate and still lifts when a new attempt lands. A Blueprint deployment stuck in Name conflict now says what to do and offers Open conflicting stack and Check again. Check again only re-evaluates the conflict: when the name is free the row returns to Pending and the next reconcile deploys it, with the usual state review for stateful Blueprints. It never deploys by itself, Git-managed Blueprints are left to their rollout, and an ownership check that cannot tell keeps the row in conflict. The two banner dismiss hooks are merged into one that keeps the existing storage keys and events. |
||
|
|
63220a7d92 |
feat(notifications): mark alerts read and mute them where they appear (#2117)
* feat(notifications): mark alerts read and mute them where they appear Add POST /api/notifications/:id/read, a node-scoped single-row read that answers 404 for an id that is not on that node. Recent alerts and the bell now carry a per-row Mark read, and Recent alerts gains the same admin-only Mute menu the bell has. Both run in place: neither navigates, and the bell stays open. * fix(notifications): keep a confirmed read from being undone and mute on the hub Refetch the feed after a confirmed mark-read so an older in-flight response cannot restore the unread row. Send mute-rule creation to the hub regardless of the active node, so Mute from Home works while a remote node is selected. Cover the Home wiring, the shared mute menu, and both behaviors with tests. * docs(notifications): describe mark read and mute on Recent alerts and the bell |
||
|
|
87db3f121e |
feat(drift): say how to resolve drift and dismiss it (#2116)
* feat(drift): let a team dismiss a drift finding on the ledger Drift findings can now be dismissed until they change, for a set number of days, or permanently. The dismissal lives on the finding's own row in the drift ledger on the node that owns the stack, so the ledger's detected to resolved lifecycle retires it: once the finding clears its row is closed, and a recurrence is a new finding with no dismissal. Each live finding is published with a stable id, a fingerprint of its structured facts (kind, service, compose value, running value, never the message), a count, and a per-kind policy. The Git managed-path conflict is not dismissable. Dismissing and restoring need exact deploy authority on the stack, the dismiss route re-reads the stack and refuses a finding that changed or cleared since it was seen, and the drift status and finding count keep counting dismissed findings. Image mismatch findings also carry whether the running image was built after the one compose names, read from the images' build times. Anything that cannot be proven reads as unknown. * feat(drift): resolve and dismiss drift findings from the Drift tab Each drift finding now names its fix and carries the verb that runs it: redeploy to match compose (confirmed first, through the editor's own deploy handler), start a missing service, or open the stack editor or Git source where the change is a decision about compose rather than the runtime. When the running image is provably newer than the one compose names, the editor leads and the redeploy confirm says it moves the service back to an older image. Verbs the account cannot run are dropped instead of left to answer 403. Findings can be dismissed until they change, for 7 days, or permanently, and restored from the dismissed list. The status keeps its verdict and counts the dismissed findings. A report from an older remote node, which cannot record a dismissal, offers no Dismiss. * docs(drift): document resolving and dismissing drift findings Describe the verb each finding kind offers, the redeploy confirm, dismissing and restoring, and who can run what. Rework the drift tutorial around the redeploy button on the finding, with fresh screenshots of the finding, the overflow menu, the confirm, the resolved state, and the Activity timeline. * fix(drift): scope dismissals and late answers to the stack they belong to Dismissing a finding now writes only that finding's own ledger row instead of reconciling the whole stack from a snapshot that may be behind a concurrent check, so it can no longer resolve or insert sibling findings or stamp the check time. The Drift tab ignores a re-check, dismissal, or restore that finishes after the operator moved to another stack or node, and closes an open redeploy confirm on a switch. Open stack editor needs permission to edit the stack, so a deployer is not shown a button that does nothing. The dismissal hook takes either a hub surface or a node endpoint, a restore that finds nothing re-reads the list, a dismissal's mode and expiry are one type, and the drift dismiss route bounds the count like the other dismissal routes. Adds tests for the dismissal lifecycle, the race, the failure paths, and the handler wiring. * docs(drift): say which buttons need permission to edit or deploy |
||
|
|
184f2ceacb |
feat(security): act on posture findings from the overview (#2113)
* feat(security): give posture reasons a key and fingerprint, and a dismissal surface Each posture reason now carries a stable key, a fingerprint of its structured targets, and a policy for how a team may set it aside. Blockers cannot be dismissed; evidence that is missing or old can only be dismissed for a set time. A confirmed image update names the stack and service behind it so it can be applied from the overview. * feat(security): act on posture reasons from the overview and dismiss review items Each reason in the review queue carries the verb that resolves it: review and apply a confirmed image update per stack, set exposure intent per service, check update availability again (and refetch), rescan the node, open the scan on its secrets or misconfigurations tab, or go to Triage or History. The masthead action runs the same verb. Review and informational reasons can be dismissed until they change; blockers never can. * test(security): isolate the SecurityView refresh test from the new reason hooks * docs(security): describe in-place verbs and dismissing review items * fix(security): tighten posture actions from review Scan failures are reported and only a finished scan refreshes the overview, an overview read for another node never drives actions on this one, a service behind several findings is listed once, and the per-key severity is one exhaustive record. * fix(security): match verbs to route guards and keep tab shortcuts Rescan node follows the node scan route's global permission, secrets and Compose risks keep their tab shortcut instead of opening an arbitrary scan, a verb that runs in place keeps the tab shortcut in a menu, accounts that cannot read dismissals skip the read, and failed scans are dismissed for a set time only. * fix(security): keep refreshing the overview after a failed node scan * fix(security): keep the overview overflow menu after Dismiss The review queue built its shortcut menu inside the verb slot, so Dismiss landed after the overflow icon. Pass that shortcut through the shared finding-row menu, which already keeps the menu at the trailing edge. |
||
|
|
f935fa7b1d |
feat(networking): resolve and dismiss networking findings in place (#2106)
* feat(networking): give networking findings a structural key and dismissals Networking finding ids were hashes of the message, so rewording a message moved every id. They are now `kind|stack|service|network|subject`, and each finding carries a fingerprint of its severity and structured targets, a target count, and the dismissal policy of its kind. Adds the networking dismissal surface: a per-kind policy record, a key parser that derives node, stack and kind, and routes to dismiss, list and restore. Doctor-only findings stay with Compose Doctor: acknowledged ones are kept and marked instead of dropped, and left out of the counts. * feat(networking): resolve and dismiss networking findings in place Each networking finding now carries its own verb and Dismiss, in the Findings tab and in the Overview "Operator attention" block alike. Exposure intent is set in a popover over the row, a Doctor-only finding is acknowledged through a confirm that says it changes update readiness, copy actions move to an overflow menu, and Compose Doctor's remediation line is finally shown. Dismissed and Doctor-acknowledged findings sit under a quiet "N dismissed" row with Restore, and the posture word still counts them. * docs(networking): describe in-place verbs and dismissing findings * fix(networking): tighten dismissals and Doctor acknowledgement from review Setting an exposure intent now drops the networking memo so the finding is re-checked at once. Acknowledge in Doctor is offered only when every open occurrence names a service, because a service-less rule is acknowledged for the whole stack. Service-less Doctor findings (such as port conflicts) stay separate cards, finding keys are length-bounded, a failed dismissals read keeps the last list and says so, and a partial acknowledge or clear reports how far it got and re-reads the list. Adds tests for the dialog and the hook. * fix(ui): order finding row actions as verb, Dismiss, overflow Enforce outline primary and split Dismiss chrome, move networking overflow into a trailing slot, and drop the mobile-only dismiss kebab. * fix(ui): match finding row comments to the controls The verb class does not set outline, and the overflow slot only renders what the caller passes. * revert: restore the original finding row action layout The outline split and trailing overflow order read worse than the previous row. * Revert "fix(ui): order finding row actions as verb, Dismiss, overflow" This reverts commit 6948a084960ab00cb639b3e1c6cf6d5d1e50dfb5. * fix(ui): keep finding row actions in one order with the overflow menu last Every finding row now lays out its controls as the resolving verb, then Dismiss, then the overflow menu at the trailing edge, whichever of them a row lacks. Networking used to place its overflow menu between the verb and Dismiss, so the order changed from row to row and Dismiss shifted whenever a row had secondary verbs. FindingRowActions takes the secondary verbs as `more` and owns the menu. A surface that has an overflow menu keeps its slot reserved on rows with nothing to put in it, so Dismiss lines up across rows. Fleet Readiness has no overflow menu and is unchanged. Below the md breakpoint the secondary verbs and the Dismiss options fold into one trailing menu instead of two adjacent ones. A dismiss in flight locks only the Dismiss items there, as it does on desktop. |
||
|
|
2fedeafbf7 |
feat(readiness): resolve readiness findings in place (#2101)
* feat(readiness): resolve readiness findings in place Each Readiness finding now carries the verb that fixes it where it is listed, instead of a link to another page: - Test connection, Start stack or stopped services, Check again, Capture recovery point, Take fleet snapshot and Scan node run on one click against the finding's own node. - Review update, Install scanner and Re-anchor go through an overlay first. The update itself runs through the same request the editor uses, so the policy gate and the progress panel still apply. - A verb is offered only when the account passes the route's own check; otherwise the row keeps its page link. Findings from a peer that predates the new fields fall back to the page link too. Update and recovery findings now carry the signal behind their reason and whether an update is known, and both feed the finding fingerprint so a new image or a changed blocker brings a dismissed finding back. Capturing a recovery point drops the cached rollup so the finding it clears leaves the list at once. The stack update request and its error parsing move out of the editor hook into one shared module used by the editor, Auto-updates Apply and the new verb. * fix(readiness): report update failures and verb errors accurately - A stack update that never got an answer (dropped connection) now toasts the error instead of leaving only the deploy panel. - A failed update says when the stack was rolled back and shows the cause and next step; a recheck warning replaces the success line rather than stacking under it. - A failed connection test reports its own reason instead of "could not reach"; a node scan with failures warns instead of reading as clean. - The re-anchor confirm picks the paused sync row, ignores a late lookup for another finding, and says when the anchor could not be read. - A refused or failed update still re-reads the check. - Reason facts of the wrong type from a peer are ignored. - Add tests for the shared update request, its hook, the lifecycle helpers and the verb failure paths; correct stale comments. * refactor(readiness): flatten the verb confirm description * fix(readiness): label the partial-stack verb for what it does The start route starts every container of the stack, so both the stopped and the partly-down finding now offer "Start stack". |
||
|
|
11312d84d3 |
feat(readiness): dismiss readiness findings until they change (#2100)
Findings on Fleet > Readiness can now be set aside for the whole team. One click on Dismiss moves a finding out of the list with an Undo, and it comes back by itself if the finding gets worse or changes. A chevron offers a 7-day or permanent hold. Each row also says in one line what to do about it. Dismissed findings are never hidden: the status line counts them and a quiet "N dismissed" row lists who dismissed each one, when, and for how long, with Restore. Dismissing never changes the matrix cells or the fleet state. Dismissals are held on the hub. The finding id names the node and stack, and the server derives permissions from it (deploy on the stack, manage on the node, admin for Control). The server reads the finding itself and refuses a dismissal if it changed after the operator saw it. A dismissal is retired only when a complete live read shows the finding gone, so an outage never drops one. Findings that restate the Security posture cannot be dismissed here, and findings about evidence that could not be read can only be dismissed for a set time. |
||
|
|
20740804b1 |
fix(gitops): stop asking a detached Blueprint for a rollout authorization (#2114)
* fix(gitops): stop asking a detached Blueprint for a rollout authorization A Blueprint detached from Git, then Applied, reported its placement as `rollout_authorization_pending` and offered no way to resolve it. The authorize route refuses with 409 `NOT_GIT_MANAGED` for any target mode that is not `blueprint`, switching the policy to manual did not clear it, and every authority action was hidden for a non-`blueprint` mode, so the row read as waiting for a decision that no route, policy, or button could supply. It persisted across ticks, and the only way out was a compose edit followed by Apply, which changes the artifact set and invalidates the stored evidence by accident. Two facts met to produce it. `derivePlacementAuthority` ran the Git-managed rollout authorization chain for every target mode, although only `target_mode === 'blueprint'` has that surface. Detach leaves `intent_revision_id`, `rollout_candidate_id`, `accepted_generation_id`, `artifact_set_id` and `source_acceptance_ref` in place, because those are history rather than authority, so a detached application still resolved `authorizationIngredients`. The first placement granted afterwards put a resolvable `placement_approval_ref` back on the row and the chain ran against an application that could never be authorized. Second, `latest_preflight_evidence_json`, with its `preflight_fingerprint`, had no writer that ever cleared it on a mode change. `recordPreflightEvaluation` is its only writer and nothing in the apply, invalidation, or retarget paths touched it, so the Git-era evidence outlived the authorization it had been evaluated for. Two changes address one concern: an Inline application has no rollout authorization to wait for. The chain is scoped to `target_mode === 'blueprint'`. A non-`blueprint` mode returns `blueprint_bound` before `authorizationIngredients`, which is what an Inline application with no ingredients already returns. `direct` returns `unbound_direct` earlier still, and the legacy-combined, source-acceptance and `placement_review_pending` branches above the guard are untouched, so the only behavior that changes is the chain running for an Inline row. Demotion now also nulls `latest_preflight_evidence_json` and `preflight_fingerprint` alongside the three pointers it already cleared, extends its early-return condition to cover all five columns, and records every clear in the `application_retargeted` history row. `inline_blueprint` is a terminal mode: the only transition into it is this demotion, and `loadConvertBinding` refuses anything that is not `direct`, so the row a Detach produced is never converted back and binding a fresh Git source to the same Blueprint tombstones it first. The evidence column is never refreshed for that row again, so leaving it would be a permanent stale claim about content the row will never place. The clear does not cover rows detached before it existed. `commitTargetBinding` short-circuits a row that already matches its destination and returns before `onRetarget` runs, so replaying the demotion never reaches the clear and no backfill sweeps what the earlier code left behind. The derive guard is what covers that population, and the new test that writes the evidence onto an already-demoted row pins the guard rather than the clear as that protection. * fix(gitops): close the preflight evidence race and pin the demotion guards Follow-ups from the pre-merge audit, in the same concern as the parent commit. A preflight evaluation that completes after a Detach could write its evidence onto the inline row. Neither caller can reach such a row legitimately: the Direct apply path resolves its application through `getLiveDirectApplication`, and the Git-managed authorization path is refused for a mode that is not `blueprint` before it evaluates. Nothing reads the column for an inline row either, so the write only contradicts the mode the row now runs. `recordPreflightEvaluation` now skips an `inline_blueprint` row. It is skipped rather than refused because an exception there would turn a benign race into a failed authorize request, and the mint the caller reaches next already reports the refusal. Two tests pin the coverage the audit found missing. The demotion clear now has a case for the one shape where every no-op condition except the evidence one reads clean, which is what an invalidated placement on a manual-policy row produces. And a late evaluation can no longer write the evidence back. Two comments are corrected. The demotion comment named only the Git-managed caller of `recordPreflightEvaluation`, which the Direct apply path also calls. The derive comment read as choosing the guard over the demotion clear, when the two are complementary: the clear cannot fix the symptom for a row that still resolves its other Git-era pointers, nor for a row detached before the clear existed. |
||
|
|
ca90588e4e |
fix(gitops): bound GitOps history with a retention window (#2105)
* fix(gitops): bound GitOps history with a retention window `gitops_history` is insert-only and the reconcile tick appends to it continuously, so it grew for the life of an installation. A new `gitops_history_retention_days` setting (default 30, range 1 to 365) seeds with the other GitOps settings and prunes history from the monitor's cleanup pass. Drained outbox rows are pruned with the history they announced; an undrained row gets a grace period past the window because the boot repair can still drain it from its payload, and a row still undrained after the grace is counted, logged, and dropped so it cannot accumulate for ever. The window is instance-local, like the artifact retry interval beside it: the history being pruned is the one this instance holds, and the monitor that prunes it runs here. * fix(gitops): keep each live application's newest history row through the prune `cleanupOldGitOpsHistory` deleted `gitops_history` purely by age, but `latestTransitionByApplication` reads `MAX(created_at)` per application and feeds the "last activity" fact the portfolio and application sheet render. An application with no history inside the window, because polling is off, a source is suspended, a node is detached, or a Blueprint is idle, lost every row and its activity column silently went empty. The prune now exempts each live application's newest row, so the claim the JSDoc, the PR body and the settings helper text already make is true. Scoped to live applications rather than to every application_id: a tombstone in `gitops_applications` is permanent, so an unscoped exemption would retain one history row per stack ever retired and leave exactly the growth this setting exists to stop. A retired application's rows are now pruned in full. Also folds in three follow-ups from the audit: - The prune is chunked to GITOPS_PRUNE_CHUNK_ROWS rows per table per pass, so a first prune on a long-lived install clears its backlog over several janitor ticks instead of holding SQLite's single writer for the whole delete. The method reports historyRemaining and outboxRemaining so the debug log says whether a pass is still catching up. - The outbox OR-delete is two statements, so the undrained branch uses idx_gitops_settled_outbox_undrained instead of scanning for the drained one. - The retention tests assert exact counts and exact surviving row ids rather than toBeGreaterThanOrEqual over a shared database. New tests pin each guard: a quiet application's newest row survives every window, its older rows are pruned, a tombstoned application clears entirely, an unsettled attempt is still recoverable after a prune both inside the window and outside it, and a backlog clears in bounded passes with the remainder reported. Removing the newest-row exemption turns 10 of the 11 tests red; removing only the liveness clause turns the tombstone test red. Docs: the GitOps portfolio page describes the window and its setting, and now states that the newest entry per application is kept, which is what the code does. * test(gitops): drop the unused db handles from the recovery assertions * fix(gitops): keep a settled history row ahead of its started row A reconcile attempt stamps both rows with the same time, and the prune broke that tie on a random id, so it could delete the settlement and leave startup recovery to reopen the attempt. Recency now follows insertion order, in the exemption and in each chunk. Remaining counts run only while developer mode is on, drained outbox deletes use an age index, and the settings copy says the kept row is the newest one for each live application. * fix(gitops): log the undrained rows a history prune actually deletes A pass is capped, so the warning was reporting the whole backlog. The history-window tests now fail if the permission, the page save, the monitor window, or the getter fallback stops matching the code. * fix(gitops): prune a superseded unsettled attempt past the window An unsettled reservation survives past the retention window only while it is that live application's newest row. Creating applications keep that row; detached applications do not. * fix(gitops): skip the history prune when the retention setting cannot be read A failed settings read used to fall back to 30 days and delete rows the operator had chosen to keep. The getter now throws, and that pass deletes nothing. A missing or unusable stored value still uses 30. * fix(gitops): restore the history window when the retention spec fails The retention spec restored the seeded 30 days as its last statement, not in a finally, so a failed assertion left the shared instance on 14. The CI retry then read 14 and failed the fresh-install assertion, turning one failure into a persistent red. Everything after the first save now runs inside a try whose finally puts the window back, so the spec leaves the instance as it found it whether it passes or fails. * docs(settings): list the GitOps history window in the retention reference The Data Retention table listed every other retention window and not the GitOps one, so an operator checking retention windows did not find it. The portfolio page described the setting in a paragraph, which is not where retention windows are looked up. Adds the row with its default and range, and a line under the table saying the window is set per Sencho instance and where the control lives. * fix(gitops): report an unmeasured prune remainder as unknown The prune reported a remainder of 0 whenever developer mode was off, so the diagnostic log read "none left" for a table it had never counted. It now reports null, and the log line says the remainder was not measured. Three smaller corrections from the same review: - The diagnostics allowlist omitted the history window, so a support bundle could not explain a timeline that had already been pruned. - The GitOps registry entry described every control as belonging to this node, while the retry interval and the history window are instance local. The sentence now names no scope it does not have. * fix(gitops): correct what the history window is documented to govern The copy said the window governs notifications. It does not: the prune deletes history rows and their outbox rows, and the bell entries those outbox rows announce age out under the Notification log window instead. An operator setting 7 days would have expected their bell to follow. It also promised to keep the database small, which nothing delivers. The prune stops old rows from being written; it never reclaims the file. - The helper now names the record behind the GitOps timeline, points bell entries at the Notification log, and says the window stops history from growing. - The settings reference gains the GitOps section it never had, with the window under it rather than under a per-node table that ends in a Save button the control does not use. - The portfolio page stops claiming a shorter window never changes what an application is doing: a webhook redelivery reads this same record, which the same paragraph already said. - A fraction below one day now clamps to a day instead of flooring to zero, which as a cutoff is now and would delete everything not exempt. The only caller passes an integer, so this is a guard, not a reachable fix. * fix(settings): snap a typed number chip to its own step on commit The chip clamped to min and max but never rounded, so typing 1.5 wrote "1.5" and every integer setting rejected it at the route with a bare "Validation failed" toast. Nothing was saved, so no data was at risk, but the control accepted input it could not store. Rounding to the control's step fixes that and leaves a genuinely fractional chip alone: the Docker janitor asks for 0.5 GiB and keeps it. Clamping still runs after rounding, so a fraction below the floor cannot round down under it. The settings reference also listed GitOps last under Infrastructure while the sidebar renders it third. * docs(gitops): replace the unsourced window figure with what the code does The helper and both docs said a window under about 3 days can let a Git host retry a webhook. Nothing in the code picks three days, and the dedupe the sentence describes only applies to the direct webhook path. The text now states the condition instead of a number: a redelivery can run the work again once the record of its first delivery is pruned. A provider endpoint keeps its own delivery record, pruned to a fixed depth, so the window does not affect it, and the sentence says so. * docs(settings): say where a remote instance's window is set The reference explained that the window belongs to an instance rather than a node, but not how to change the window of a remote one, which is on that instance. |
||
|
|
22df038b5d |
fix(filesystem): require lstat to report a link before refusing a path (#2115)
The walk that confirms a path stays inside the compose root probed realpath and then lstat. Any successful lstat was read as proof of a dangling symlink, so a plain file that a concurrent writer created in the gap between the two syscalls made an in-base path look like a symlink escape. Two racing creates of one stack env file hit this. The loser was refused with 409 "env file path is not usable" instead of the 412 conflict carrying the current content, which is what the editor needs in order to show the operator what the file now holds. Read lstat for whether it is a link before refusing, and let a plain file walk up to the nearest real ancestor as before. Containment is unaffected. A symlink that resolves outside the base still fails the walk, a genuine dangling symlink still lstat's as a link and is still refused, and walking up still confirms the ancestor is under the base. Add coverage for both directions across that probe gap: a plain file created in it is read as inside the base, and a real dangling symlink is still refused. |
||
|
|
f208a1f9da |
fix(gitops): announce one pull result per attempt, and only when it changes (#2104)
* fix(gitops): announce one pull result per attempt, and only when it changes Two writers announced one source attempt: the fetch path recorded its result immediately, and the settled outbox announced the same outcome after the transition committed. A pull therefore produced two bell entries, and a candidate awaiting review re-notified on every poll. Both writers now carry one dedupe key per attempt and event family, so the second write is a no-op. A ready event that names a staged candidate is keyed to the candidate instead, so the poll that staged it announces it and a later poll for the same commit stays silent. Settled outcomes that carry no news (no source change, candidate already fetched, suspended, superseded, retry scheduled, unknown) write no outbox row, and the boot repair applies the same rule to rows older builds left behind. The category and level each outcome renders are unchanged. * fix(gitops): announce one pull result per attempt, and only when it changes Two defects on the GitOps notification path. Every attempt wrote two bell entries for one event. The fetch path announces its result when it stages or fails, and the settled outbox announces the same attempt's normalized outcome after the transitions commit. Both now carry one dedupe key per attempt, so the second write is a no-op. The key is per attempt rather than per attempt and outcome family: the two writers classify one event independently and disagree by construction (a rolled-back apply records `blocked` while the `applyFailed` settle that confirms it carries `failed_previous_intact`), so a family-scoped key would let both through. A staged candidate re-notified on every poll, because each poll reserved a fresh attempt id. A ready event naming a staged candidate is keyed to the candidate generation instead, so the poll that staged it announces it, later polls of that same candidate stay silent, and a candidate staged afresh announces again. Keying on the commit rather than the generation would keep a commit that is legitimately staged again later silent for the whole notification retention window. A pull whose staged project does not validate now writes no live entry at all: it staged no candidate, so its failed settle is the single entry. Announcing "ready" there both doubled the entries for the attempt and consumed the candidate key a real candidate for that commit needs once the operator fixes the cause and pulls the same commit again. Settled outcomes that carry no news (no source change, candidate already fetched, suspended, superseded, retry scheduled, and the pre-existing unknown) write no outbox row, and boot repair applies the same rule to rows older builds left behind. Category and level per notified outcome are unchanged. The candidate-generation key is used only for a ready outcome, so a blocked or failed attempt stays on the attempt key even though it can also carry a candidate generation. The settled payload gains the candidate generation, decoded tolerantly so rows written before the field existed still drain. New coverage runs real pulls and applies and counts notification rows per attempt rather than by recomputing a key: a ready pull, an invalid-validation pull, a re-pull after the cause is fixed, a commit staged again after a different commit, a rolled-back apply, a converted Blueprint source staging a candidate, and a pre-upgrade outbox row that predates the candidate field. * fix(gitops): announce the failure when a staged result cannot be recorded A pull whose staging transition was rejected announced "ready" and then suppressed its own failure entry. The rejected transition left no durably staged candidate, but the pull carried on to the live write, which took the attempt key; the abandon that closed the operation stamped the attempt failed, so the settle that carried `failed_previous_intact` found the key already taken and wrote nothing. The only bell entry said ready for an attempt that failed. The live write is now skipped whenever the attempt has no staged result to announce, which is the case both when the staged project does not validate and when the staging transition was rejected. The settle is the single entry either way. The notification documentation now claims only what is delivered. It previously promised that a suspended source or an armed retry writes no entry at all, but the live failure write fires on every thrown fetch regardless of the settle, so those attempts do report. It also promised that later polls of a waiting candidate stay silent without saying what makes them silent. The candidate key is enforced by a unique index against rows that still exist, so clearing the bell or letting the retention pass prune the entry frees the key and the next poll announces the candidate once more. That is pinned rather than engineered around: the live write runs on every poll and relies on the key to collapse it, which is what lets a failed write recover on a later poll. Gating it to one write per staged generation would turn a swallowed write error on an automatic source, whose settle carries no news of its own, into a permanently lost notification. * fix(gitops): key a blocked candidate to the candidate, and announce a hold A plan-blocked candidate still notified on every poll. It settles `blocked`, which is news, under a fresh attempt key each interval, so a local conflict left standing filled the bell with the same warning. That is the per-poll repeat this work set out to stop, and the earlier docs sentence claimed it away as out of scope. A blocked candidate is now keyed to the candidate the same way a reviewable one is, and announces once for as long as the conflict stands. The candidate's identity gained its state, read from the live application row at the moment the entry is written: `awaiting_review`, `blocked`, or `held`. The first two are standing states that re-settle every poll. The third is a change, not a state to sit in: a safety refusal on an automatic source turns a candidate the system was going to accept into one needing review, and because that hold settles `pending_review` exactly as the waiting candidate did, it was colliding with the entry the candidate wrote when it was staged and the operator never learned the candidate stopped being automatic. It now announces under its own key, once. The family and the candidate's state must agree before the key is the candidate's, which keeps a blocked attempt on the attempt key: a dispatch refused while a reviewable candidate is live settles `blocked`, and that refusal is about the run, not the candidate. The row-counting test helper matched only rows carrying an attempt id, so a live write that lost its attempt metadata was invisible to every assertion. It now matches by stack name as well, and a second helper asserts no attempt-owned row for the stack is unkeyed. New cases cover a thrown fetch, a pre-mutation apply failure, and a plan that blocks between the pull and the apply; the unattended invocation-drift refusal asserts the same. Each of those sites was checked by dropping its attempt metadata and confirming a test fails. The rejected-staging corner the audit raised is already covered: the staging transition runs inside one transaction and `markSettled` runs inside the same call, so a rejected transition leaves the fetch open, the abandon records the failure, and the settle announces it. The existing test proves the end state. |
||
|
|
0ce9754766 |
fix(gitops): make automatic placement approvals reach execution (#2103)
* fix(gitops): make automatic placement approvals reach execution Two defects on the bounded automatic placement path. A removal was never auto-approved: the decision read `gitops_targets.connectivity`, a column seeded to null that no producer ever wrote, so a withdrawal resolved to `unknown_connectivity` even though the target projection already derived reachability from the recorded observation. The decision now reads the projection's own `targetConnectivity`, requires the node registry to have the node online (the same gate an addition passes), and carries an observation that cannot be decoded as a limitation, so the refusal names malformed evidence instead of sending the operator to check a node that answered. An Inline Blueprint's policy placement approval was recorded and then never read: an operational edit clears the combined approval, and the reconciler returned at the legacy gate before consulting the policy's approval. The reconciler now executes a plan a current policy placement approval covers, under seven conditions: the application is inline, the placement policy is still the one that could have written the approval, the approval is a policy's, the rollout authorization policy is automatic, at least one node is already placed at the current revision, every retained node runs the current revision, and the approval covers every action in the plan. An uncovered plan waits for the operator rather than partly executing, a first placement always waits for Apply, and a retained node is observed but never repaired under this approval, because the placement policy decided the node set and not the content. A node-driven roster change no longer hides a policy approval behind a current combined one: a label or cordon change moves the node set without moving the combined approval's fingerprint, so the reconciler tries the policy approval first and falls back to the combined approval when the policy cannot run the whole plan. That fallback narrows the operator's blast to the placement's frozen target set instead of invalidating it, so a removal a policy approved no longer stops drift observation on the nodes that stay; the preview and the lightweight list and detail evaluators read the same narrowing, so no surface reports an approval the executor withholds or withholds one it will run. An operator's approval is never discarded by a policy decision. Demoting an application back to Inline takes the Git-era authority with it: the placement pointer is cleared and the rollout authorization policy returns to operator authorization, because neither was an operator's choice about Inline placement. An Inline Blueprint can reach the automatic rollout authorization the policy path requires from the control beside the placement policy. * fix(gitops): say when automatic placement waits for Apply A selector edit that moves one stateless node runs on the next tick, and Enforce holds a policy-approved plan until Apply. The docs described both as waiting, and the preview showed a pending plan with no reason. * fix(gitops): keep a cleared pin from automatic placement A pin clear left the new intent unpinned, so bounded auto could place the freed node without Confirm Apply. The decision now treats a clear as pin-driven when the standing approval was recorded on a pinned intent, or when a pin was written after an older approval and never confirmed. * fix(gitops): keep a detached snapshot off automatic placement Git-managed deploys stamp the Blueprint revision, and Git content never bumps it, so after Detach a retained node still looked current. Recording a new revision on Detach makes that stamp stale until Apply. * fix(gitops): hold Enforce plans that drop a mid-deploy repair A retained node that is correcting or deploying leaves the plan with no repair, so a policy approval could place an added node in Enforce. The mode itself now holds that plan until Apply, and the preview says so only when Enforce is the reason it is waiting. * fix(gitops): keep a pin chain and a re-armed policy from placing on their own Three authority gaps, all on the automatic placement path, found by the pre-merge audit. A pin that was set and cleared with no Apply between them read as no pin at all once a third intent followed: the intent before the current one was the clear, so the pin that moved the work was out of view and the freed node was placed without Apply. With no decomposed placement approval to anchor on there is no record of any confirmation, so the whole intent history now counts. A Confirm Apply mints the approval the decision anchors on from, and the next change is judged against that, so the wait ends at the next operator decision rather than never. Re-enabling a policy revived a decision the operator had taken back: execution stopped the moment the policy went, but the approval the policy wrote stayed on the row, so setting it back handed the next tick a decision made days earlier on evidence nobody had read since. Arming either policy again now withdraws what that policy decided, with the rollout authorization that only resolves through it. An operator's approval is untouched, the approval rows stay as the record of what was decided, the rollout generation stays so a fresh approval re-drives it, and the history row records the pointers that went with the policy. The refusals a withdrawal produces now read as what the backend checks: a node that has not reported, or that is not a node this Sencho knows about, is a different answer from a node that is not answering, and a withdrawal needs the node to have reported the workload it was running. * fix(gitops): clear the attention a policy placement left behind A Blueprint the placement policy placed a node for sat at Needs attention with a stale acknowledgement and a runtime drift, and neither Confirm Apply nor an API apply cleared it: the retained nodes' plan is observation-only, so nothing ever re-acknowledged them. Two causes, both fixed. The Inline freeze is now keyed on the compose digest rather than the intent revision, so a roster change no longer drops the accepted generation and re-points every placed target at a new generation id for the content it already runs. And the projection's identity test yields when both intent revisions resolve with the same compose digest and the current candidate still requires that node: a node the candidate dropped keeps its signal, because a withdrawal the plan is asking for is not a superseded acknowledgement. Two roster changes inside one tick also left both additions unplaced with no refusal recorded. The reconciler runs one approval and refuses a plan it does not cover whole, so the second approval named only its own addition while the plan still wanted the first. The blast now names every node the fleet does not hold, carrying the earlier decision's unlanded placement forward, while the decision still reads the approved set for its size and safety reasons and the evidence is read for every node the blast names. Removals stay on the approved set, because a withdrawal is judged from the observation of the workload that ran there. The Confirm rollout dialog no longer claims the fleet cannot change until it is confirmed when a configured policy has already approved the plan. * fix(gitops): write the placement blast in canonical order and name a withdrawn node again Two findings from the QA run of this PR, both now pinned by failing-first tests. The first: a roster move that adds a higher node id while withdrawing a lower one left the approval unwritable and the operator looking in the wrong place. The blast effect was assembled places-then-removals, while the decoder requires the node ids strictly increasing, so the write was refused as out of canonical order. The refusal reason then reported that refusal as `conflicting_operation`, so the operator was told an operation was in flight when none was, the plan read `reapproval_required`, and nothing executed. The effect is now sorted by node id, which is the order the decoder wants and carries no meaning of its own: the reconciler reads the effect as a map keyed by node id and the transition re-encodes it to canonical form anyway. Blast decode failures are also recorded as `malformed_evidence` rather than falling through to the conflict default, because a blast that did not decode is a data fault. The roster window that reaches it: the reconciler executes one approval per tick, so an addition approved but not yet landed is carried into the next approval's blast, which then names a placed node alongside the withdrawal. The second: a withdrawn node that the selector wants again produced no row in the plan at all. The tombstone holds deliberately, since only an explicit deploy re-opens a severed placement, but the node stayed online and labelled with nothing to say about why it was skipped, so the plan read as nothing to do. A new informational `skip_withdrawn` row now names what the operator has to do. It is excluded from the confirmable set and from both executor groups, so it authorizes nothing and cannot be confirmed into a mutate, and it yields to the status-precedence pass so an in-flight or guard row for the same node wins rather than leaving two explanations stacked on one node. Follow-up, not addressed here: the replay-guard refusal (`placement approval already recorded for this intent and candidate`) still maps to `conflicting_operation`, which is the same class of untruthful reason on the crash-retry path. It predates this change and is left for its own pass. Tests: a new case drives the add-then-withdraw window end to end and asserts the approval lands with `stateless_removal` and no refusal recorded; a second drives a severed target the selector wants again and asserts the informational row; a third pins that a severed node carrying a `withdrawing` row reports once, as the in-flight withdrawal. Backend: `npx tsc --noEmit`, `npm run lint` (0 errors), `npm test` (11013 passed; the single file-level failure is `git-transport-ssh.integration.test.ts`, which requires loopback port 22 free and fails identically on an unmodified tree because this host runs sshd on 22, freed in CI by `scripts/ci-free-loopback-ssh-port.sh`). Frontend: `npx tsc -b --noEmit`, `npm run lint` (0 errors). |
||
|
|
325d4e3007 |
fix(docker): rebuild the CLI and Compose binaries with Go 1.27.2 and x/net v0.60.0 (#2107)
The Docker Build & Scan gate went red with four HIGH findings in the two Go binaries built from source into the image (usr/local/bin/docker and usr/local/lib/docker/cli-plugins/docker-compose), all denial-of-service paths: - CVE-2026-78669 (golang.org/x/net v0.58.0, fixed in v0.60.0): HTTP/2 SETTINGS-flood denial of service - CVE-2026-78667 and CVE-2026-97031 (Go stdlib v1.27.1, fixed in v1.27.2): net/http Range-header and crypto/tls ECH denial of service Neither fix could arrive through Dependabot. The stdlib CVEs need a new toolchain digest on both builder stages, and x/net is pinned by an explicit `go get` in each stage, not by a manifest a bot reads. Bump the shared golang base from 1.27.1-alpine to 1.27.2-alpine, digest resolved from the registry, and x/net from v0.58.0 to v0.60.0 in both stages. x/net v0.60.0's module requirements raise x/text to v0.42.0 and x/crypto to v0.57.0, so both move with it. The five places the file still recorded Go 1.26.3 are corrected to the toolchain this change ships. Verified locally: both stages build; `go version -m` reports go1.27.2 and the expected module versions; a Trivy image scan of a scratch image holding both binaries (--severity CRITICAL,HIGH, same trivy.yaml as CI) reports zero findings and exits 0. The full-image build and scan are CI's to confirm. |
||
|
|
b8de90d13c |
fix(gitops): match webhook deliveries on canonical repository identity (#2102)
Provider webhook deliveries were matched to the owning source by raw host and pathname equality, so a stored `...repo.git` clone URL never matched the `html_url` a provider sends, and ssh-configured sources, trailing slashes, host case, ssh ports and path case were dropped the same way. The delivery was recorded `source_mismatch` and the source kept polling as if no webhook existed, with nothing surfacing the mismatch. Compare canonical repository keys instead, folding path case before the key is built so a differently-cased `.git` suffix normalizes too. The fold is local to this signature-verified check: an endpoint binds exactly one source, so it reads a signed statement rather than looking a repository up, and the pull that follows uses the configured URL, never the delivered one. Folding in the Blueprint claim guard would add refusals, so the guard keeps its case-sensitive path: a case-sensitive host can hold two repositories whose paths differ only in case. |
||
|
|
a6eaea0f54 |
feat(editor): create the stack .env from the editor on first save (#2097)
* feat(composeEditor): create .env file on demand when saved instead of relying on doing so via Files * fix(editor): scope .env creation to the editor and adopt the resolved path - PUT /:stackName/env creates the default .env only when the request carries create=1 and the stack exists; every other caller keeps the handled 404, so a Fleet Secrets push to a stack with no env file never creates one. - The create response returns the resolved path; the editor adopts it, so the next save targets the file directly instead of failing the allowed-file check. - Deep links to /env open the env tab on a stack with no env file. - Mobile always shows the compose/.env toggle and the creation notice. - Backend and frontend tests pin the create path, the canonical second save, the no-flag 404, the deep-link behavior, and the notice; docs, the guardrails tutorial, and the mobile screenshot are updated. * fix(editor): make the first env save exclusive and pin the confirmed overwrite Audit follow-up on the create-on-first-save path: - PUT /:stackName/env?create=1 now creates exclusively with open(..., 'wx'). An existing target answers the same 412 conflict the optimistic-concurrency path uses, with its current content and path, so a stale editor that still believes no env file exists can never overwrite one it has not read. - The editor adopts the conflict path, and its confirmed overwrite retries with create=1&force=1&file=<path>, pinned to the file the dialog named and re-validated against the allowlist. - With project env files configured, the create targets the first configured file (the one Compose reads), derived from the shared env resolver instead of a second copy of the fallback; a configured path that resolves outside the stack directory is refused with 409 rather than written. - The conflict read repeats the containment check and refuses to follow a link, so the conflict payload cannot be used to read outside the compose root. - A directory target maps to 409 instead of an opaque 500. - Tests cover the conflict instead of an overwrite, the forced retry, the pinned retry, the configured target with a stray .env untouched, the escaping config refused, force ignored on the non-create path, and the frontend conflict, decline, and fallback flows. - Docs restore the compose row's "Always available." note, describe the creation target and the no-overwrite guarantee, and make the notices generic. * fix(editor): guard the env create against older nodes and unknown state Audit follow-up on the create-on-first-save path: - The non-forced create request now carries If-Match: W/"0". This build's exclusive create ignores it; a node that predates create-on-save treats it as a normal conditional write, so an existing env file answers 412 with its content instead of being overwritten, and a missing file keeps the handled 404. - A failed stack load clears the previous stack's env state instead of leaving a stale "no env file" assertion that the next save could act on. - A failed env inventory or content read is tracked as unknown, shows a failure notice, locks the buffer, and refuses the save; it is no longer presented as an empty file. - Declining the create conflict on a node whose 412 names no path keeps the conflicted content and refreshes the inventory under the current load's signal; a failed refresh reports the failure instead of a false reload. - Save & Deploy and Save & Pull on an untouched empty env tab no longer create an empty file; the deploy proceeds. - The exclusive create opens with O_NOFOLLOW so a planted symlink cannot be followed, and a failed write's leftover is removed only when the path still names that same file (inode and size match). - Tests cover the older-node precondition, concurrent creates, a directory target, escaping and dangling symlinks, the write-failure cleanups, the unknown-state refusal, the conflict-retry copy, and the deploy skip. * fix(editor): leave create mode on older nodes and return 409 for path conflicts Audit follow-up: - A create-mode 200 without envPath is the normal response of a node that predates the envPath field. The editor now refreshes the env inventory in that case, so it leaves create mode and later saves are normal conditional writes with a fresh ETag instead of prompting for an overwrite every time. - The recovery refresh uses the load signal captured before the PUT, so a stack switch during the request aborts it and the new load's state is not repainted. - PUT /:stackName/env maps INVALID_PATH and SYMLINK_ESCAPE from the create path to 409, so a symlink at the target is a recoverable conflict instead of an opaque 500. - Docs: deep-links no longer says the created file is always the default .env. - Tests: the older-node create, confirm and overwrite sequence asserts the editor leaves create mode and the next save is a conditional write; a route test covers an in-root symlinked .env returning 409 without leaking content. - Comment cleanups from the audit. * fix(editor): do not paint a create response that lands after a stack switch Audit follow-up: - saveFile checks the load signal captured before the PUT (opLoadSignal) in the 412 and success paths: a response landing after the editor switched stacks now returns without adopting the old stack's envPath, ETag, or buffer state and without prompting, so the new stack's editor state stays intact. - Tests: two hook tests switch stacks mid-PUT, for both the success and the conflict response, and assert no adoption and no prompt. - Docs: node-compatibility no longer claims every older node detects the conflict (nodes before 0.87 cannot) and notes that the confirmed-overwrite prompt does not repeat. - Comment: createTarget wording matches the resolver's containment filter. * fix(editor): create missing parent directories for a configured env file Audit follow-up: - A configured project env file can point into a subdirectory that does not exist yet; the exclusive open failed with ENOENT and the route answered an opaque 500. The create now makes the missing parents inside the checked, stack-bounded path and retries the exclusive open once, so the first save creates the file Compose reads at its configured path. - The retry re-runs the containment check and still loses to EEXIST if another actor created the file meanwhile. - Tests: a service test for a nested missing-parent target and a route test that configures config/prod.env with no config directory, creates it, and saves again through the returned path. * fix(editor): report a vanished stack on env create instead of resurrecting it Audit follow-up: - Drop openExclusiveCreatingParents and its recursive mkdir. The state it served (a configured project env file in a missing subdirectory) cannot be produced by the API: PUT /project-env-files rejects names with "/" or "\" and requires the file to exist, so no product path reaches that branch. The only reachable ENOENT is a stack directory deleted between requireStackExists and the exclusive create; recreating it left the deleted stack visible only as a hidden folder holding the env content and blocked the stack name. - Restore the direct exclusive open and map ENOENT in the route catch to the handled 404 Stack not found, so the race is reported and nothing is written or recreated. - Replace the two tests for the unreachable nested target with regression tests: the service refuses a target under a missing stack directory and creates nothing; the route answers 404 Stack not found when the directory vanishes before the create (real service ENOENT path) and does not recreate it. * fix(editor): recover when the create conflict target is deleted mid-dialog QA follow-up: - The confirmed create retry pins the file the conflict named. When that file is deleted before the operator confirms, the route rejected the pinned path with a 400 because it was no longer a present env source, and the editor (which had already adopted the path) failed every later save the same way. The create branch now accepts the pinned path when it is the resolved create target, so the retry recreates the file; any other pinned path keeps the scoped rejection. - Declining a create conflict now reloads the env state instead of trusting the 412 payload: if the file was deleted meanwhile, the editor returns to create mode instead of adopting a path that no longer exists. - Save failures toast the server's error text (404 vanished stack, 409 unusable path, 413 too large) instead of the raw JSON envelope. - The save-refusal toast for an unreadable env state uses the same wording as the editor notice; the file list may have loaded while the content did not. - A viewer with no env file sees "This stack has no environment file yet." instead of an unexplained empty read-only tab; editors keep the create promise. - The route test for a vanished stack restores its spy in finally. --------- Co-authored-by: Anso <dev@anso.codes> |
||
|
|
d13fc14274 |
fix(ci): let the CLA workflow re-run its check after a signature (#2099)
After a contributor signs, the action records the signature and then re-runs the PR's failed pull_request_target run so the check turns green. That call needs actions: write, which this workflow withheld, so the run ended with "Resource not accessible by integration" and the check stayed red until the next push. The permission now matches the action's documented sample. |
||
|
|
bcdb0cb822 |
fix(ci): store CLA signatures on an unprotected branch (#2098)
The CLA action commits signatures with the built-in GITHUB_TOKEN, which cannot bypass the main ruleset's "Changes must be made through a pull request" rule. Signatures have never been recorded since the ruleset was added: the file on main is empty and the check fails for every external contributor even after they sign. The action requires a branch it can commit to directly, so signatures move to the dedicated cla-signatures branch. The file is removed from main, and the branch is guarded against deletion and force-pushes without blocking the bot's direct commits. |
||
|
|
a77eb1b690 |
fix(exec): send Tty on exec start so the stream is not multiplex-framed (#2096)
* fix(exec): send Tty on exec start so the stream is not multiplex-framed Docker decides the hijacked exec stream's framing from the start request's Tty, not the exec-create Tty, so every chunk of the interactive terminal carried an 8-byte multiplex header (01 00 00 00 <len>) before its payload. Pass Tty: true to dockerExec.start so the terminal receives raw shell output. Covered by a unit assertion on the start options and a Docker-backed integration test asserting no frame headers and clean output. * test(exec): assert evaluated shell output and close the socket in finally The framing regression test waited for the marker text, which the PTY echo of the typed command already contains, so the positive assertion could pass before the command output arrived. Type a command whose output differs from the echo, wait for the evaluated form, wrap the session teardown in try/finally, and give the scratch container a process-scoped name. Verified: passes with the Tty: true start option and fails with Tty forced false, where the frame header assertion fires. |
||
|
|
63b825376c |
chore(deps): bump anchore/sbom-action (#2095)
Bumps the all-actions group with 1 update in the / directory: [anchore/sbom-action](https://github.com/anchore/sbom-action). Updates `anchore/sbom-action` from 0.24.2 to 0.24.3 - [Release notes](https://github.com/anchore/sbom-action/releases) - [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md) - [Commits](https://github.com/anchore/sbom-action/compare/3ad7283483fc7af8ff2b4ea19663c2d5ca935e26...66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c) --- updated-dependencies: - dependency-name: anchore/sbom-action dependency-version: 0.24.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
02f6fc4946 |
chore(deps): bump the all-npm-frontend group (#2094)
Bumps the all-npm-frontend group in /frontend with 10 updates: | Package | From | To | | --- | --- | --- | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.48.0` | `1.52.0` | | [motion](https://github.com/motiondivision/motion) | `13.4.4` | `14.0.0` | | [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `10.0.1` | `10.0.2` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.3` | `26.6.4` | | [eslint](https://github.com/eslint/eslint) | `10.11.0` | `10.12.0` | | [globals](https://github.com/sindresorhus/globals) | `17.12.0` | `17.13.0` | | [jsdom](https://github.com/jsdom/jsdom) | `30.1.1` | `30.1.2` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.1` | `8.71.0` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.2` | `5.0.3` | Updates `lucide-react` from 1.48.0 to 1.52.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.52.0/packages/lucide-react) Updates `motion` from 13.4.4 to 14.0.0 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v13.4.4...v14.0.0) Updates `react-day-picker` from 10.0.1 to 10.0.2 - [Release notes](https://github.com/gpbl/react-day-picker/releases) - [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md) - [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.2/packages/react-day-picker) Updates `@types/node` from 26.6.3 to 26.6.4 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` from 10.11.0 to 10.12.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.11.0...v10.12.0) Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) Updates `jsdom` from 30.1.1 to 30.1.2 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v30.1.1...v30.1.2) Updates `typescript-eslint` from 8.70.1 to 8.71.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint) Updates `vite` from 8.3.1 to 8.3.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite) Updates `vitest` from 5.0.2 to 5.0.3 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.52.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: motion dependency-version: 14.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-npm-frontend - dependency-name: react-day-picker dependency-version: 10.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@types/node" dependency-version: 26.6.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: eslint dependency-version: 10.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: jsdom dependency-version: 30.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: typescript-eslint dependency-version: 8.71.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: vite dependency-version: 8.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: vitest dependency-version: 5.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-frontend ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
60a4e54b89 |
chore(deps): bump the all-npm-backend group in /backend with 12 updates (#2093)
Bumps the all-npm-backend group in /backend with 12 updates: | Package | From | To | | --- | --- | --- | | [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.33.13` | `5.33.15` | | [tar-stream](https://github.com/mafintosh/tar-stream) | `3.2.1` | `3.2.2` | | [@types/multer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/multer) | `2.2.0` | `2.3.0` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.3` | `26.6.4` | | [@types/ws](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ws) | `8.18.1` | `8.18.2` | | [eslint](https://github.com/eslint/eslint) | `10.11.0` | `10.12.0` | | [supertest](https://github.com/ladjs/supertest) | `7.3.0` | `7.3.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.1` | `8.71.0` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.2` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.2` | `5.0.3` | | [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1141.0` | `3.1146.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1141.0` | `3.1146.0` | Updates `systeminformation` from 5.33.13 to 5.33.15 - [Release notes](https://github.com/sebhildebrandt/systeminformation/releases) - [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md) - [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.33.13...v5.33.15) Updates `tar-stream` from 3.2.1 to 3.2.2 - [Commits](https://github.com/mafintosh/tar-stream/compare/v3.2.1...v3.2.2) Updates `@types/multer` from 2.2.0 to 2.3.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/multer) Updates `@types/node` from 26.6.3 to 26.6.4 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `@types/ws` from 8.18.1 to 8.18.2 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ws) Updates `eslint` from 10.11.0 to 10.12.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.11.0...v10.12.0) Updates `supertest` from 7.3.0 to 7.3.1 - [Release notes](https://github.com/ladjs/supertest/releases) - [Commits](https://github.com/ladjs/supertest/compare/v7.3.0...v7.3.1) Updates `typescript-eslint` from 8.70.1 to 8.71.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint) Updates `vite` from 8.3.1 to 8.3.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite) Updates `vitest` from 5.0.2 to 5.0.3 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest) Updates `@aws-sdk/client-ecr` from 3.1141.0 to 3.1146.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1146.0/clients/client-ecr) Updates `@aws-sdk/client-s3` from 3.1141.0 to 3.1146.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1146.0/clients/client-s3) --- updated-dependencies: - dependency-name: systeminformation dependency-version: 5.33.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: tar-stream dependency-version: 3.2.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: "@types/multer" dependency-version: 2.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: "@types/node" dependency-version: 26.6.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: "@types/ws" dependency-version: 8.18.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: eslint dependency-version: 10.12.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: supertest dependency-version: 7.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: typescript-eslint dependency-version: 8.71.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: vite dependency-version: 8.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: vitest dependency-version: 5.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-ecr" dependency-version: 3.1146.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1146.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
cee2ec83a6 |
fix(gitops): capture Pilot recovery and stop refused rollbacks from parking the application (#2087)
* fix(blueprints): capture recovery on Pilot apply-local hops The leaf's apply-local route honored allowGitManagedContent and captureRecovery only under the node_proxy machine scope, but a request forwarded through the Pilot tunnel arrives with the agent's pilot_tunnel loopback token. The auth middleware treats the two scopes as the same hub-to-leaf credential and every sibling route already accepts both, so the gate silently dropped the capture and a later rollout rollback on a Pilot target failed with no recovery point to restore. Accept either machine scope for both flags. Browser sessions and API tokens hold neither and are still refused. * fix(gitops): keep a refused rollback from parking the application A rollback a node refused before its restore touched anything was recorded application-wide as a failed recovery. The source facet then read recovery_failed, offered no action, withheld deploys, and stopped polling, while the only exit, a successful restore, was impossible on the refused target. Recovering it took manual row edits. Classify each failed restore by whether it can have mutated the target. The restoring node's own pre/post verdict, attached to the compensation error and carried over the rollback response, wins; otherwise only codes that prove the restore moved nothing are pre-mutation. A pre-mutation refusal is recorded on the target and settles the application restoring stamp in the same transaction, keeping a same-ref completion receipt when another target came back. A 2xx that does not echo the requested generation is its own unconfirmed code and still holds, because the node did restore something unproven. A later successful deploy acknowledgement now retires a stale recovery failure on the acknowledging target and, when no target still reports one, on the application. * docs(blueprints): describe how a refused rollout rollback resolves A target refused before its restore changes anything keeps its visible failure, but it no longer holds the application. The source keeps converging and the next successful deploy to that target retires the stale failure; only a rollback that may have changed a target holds automated work. * fix(gitops): keep rollback holds conservative across retries and refusals A later pre-mutation refusal could erase the application hold an earlier target still needed when that target may have been half-restored: the settle cleared the stamp without checking the other targets, and a retry refused during an unresolved failure overwrote the target's moved class. The settle now restores the hold from a surviving mutated failure, keeps the stamp while any restore is still moving, excludes the refusing targets themselves, and only a pre-mutation refusal defers to an earlier failure's class. A refusal whose own record write throws is settled by the route for every missed target, not just the first. Also carried through the same path: the node's own pre/post classification rides the compensation result and every uncoded refusal (a restore-not-sent outcome for a request that never left the hub, a coded stack-not-found, the self-stack refusal), stack-busy answers hold instead of settling because another operation is moving that stack, a failed mid-write restore whose own revert failed is classified post-mutation, and Enforce runtime repair follows the same pre-mutation release rule. * fix(gitops): derive rollback holds from targets and separate recovery claims The settle no longer keys on one operation's recovery ref: it derives the application phase from the live targets, so two overlapping rollbacks can no longer pin the application in restoring, and the other operation's refusal settles it. The open writes the target's active-operation marker, so a crashed open is selected by boot and reclassified instead of staying restoring, and a missed refusal record is retried for its target rather than only settling the application. The health executor settles a proven refusal whose record throws, the same as the manual route. A recovery hold is now claimed by one shared predicate, targetRecoveryFailureMoved: a failure stage that has moved on no longer claims a recovery, and a pre-mutation refusal never did. The settle, the acknowledgement clearing, and the repair binding use it, and the failure writers clear a superseded recovery phase, so a deploy failure on a refused target cannot re-park the application or re-hold repair. * fix(gitops): retire replaced recovery claims and keep repair behind a retry A deploy failure that replaced a moved recovery failure cleared the target's claim but left the application hold with no claimant, and the acknowledgement's clearing branch then never ran. Claims are now retired through one helper that re-derives the application hold in the same transaction; a successful bind, an acknowledgement and a revived placement retire the claim too, and every rollback terminal retires the recovery_started interruption a crashed open left. Repair checks a restore in flight before the failure claim, so a retry over a refused target holds repair again. A terminal rollback whose recovery ref no longer matches the target changes nothing, a newer rollback open takes over the active marker, and a failed record is retried with the class the attempt was proving for either class. * fix(gitops): keep a moved recovery claim through later failures A recovery claim that may have moved a target was retired whenever a deploy or withdraw failure replaced it, releasing the application hold with nothing proving the target consistent. The claim now keeps its own class in a dedicated column, so a later failure records itself beside the claim instead of overwriting it. Only success evidence (an acknowledgement or a bind) retires a moved claim; a claim that moved nothing is still retired by a later failure. A restore in flight is never rewritten: retirement returns early on capturing, restoring and compensating, and a deploy, withdrawal or acknowledgement terminal that clears its own marker hands it back to the restore so a crash remains boot-recoverable. Superseded terminals are scoped by class: a refusal that moved nothing is a true no-op with no row write, history row or application stamp, while a failure that may have moved the target records its class without touching the newer attempt's phase, ref or marker. rollbackCompleted carries the same ref guard, and the settle re-reads each row after its retry and drops one a newer rollback re-opened. The application hold counts a restoring sibling as a claimant, and a tombstoned target stops answering for the hold it claimed. * fix(gitops): serialize rollbacks per target A second rollback could open over a target whose restore was still running, take over its recovery slot and ref, and leave the application hold released while the first restore was still moving files. One restore per target is now the rule: rollbackInProgress and recoveryStarted refuse a target that is already restoring, the route answers with 409 ROLLBACK_IN_PROGRESS before any restore starts, and the health executor skips its rollback on a target that is already restoring. With overlap impossible, the machinery that arbitrated it is gone: the ref and marker takeover, the skipped-terminal plumbing and the ref guards. A refusal that defers to a moved claim leaves the shared failure slot alone, so a later deploy failure is not relabelled a recovery failure, and the claim keeps its own class and time in dedicated columns. rollbackCompleted, recoverySucceeded and a moved rollbackPartialFailed derive the application stamp from the live targets instead of writing it outright, so one target's outcome cannot release or overwrite a sibling's hold. A restore marker is handed back on every terminal that clears it, including through a tombstone, and both revival paths share one helper. * test(gitops): restore the boot recovery selection test The superseded-terminal tests removed with the rollback serialization also took the boot test that drove the real startup selection, leaving its import unused. Restore it against the current semantics: the open's marker makes the application selectable, boot reclassifies the restore as interrupted, and a later terminal rollback retires the interruption. * fix(gitops): record a terminal when a health rollback hold throws The health executor opened a rollback and then ran the hold and two store reads outside the block that records a terminal. A throw there left the target restoring, and with rollbacks now serialized per target that state refused every later rollback in-process. Everything after the open now lives inside the recording try, so every exit records a terminal; a regression test forces the hold to throw and checks that a later manual rollback is accepted. Boot also selects applications whose own or any target's recovery phase is in flight without an active-operation marker, and records the interruption as recovery_started, so a row an older build left restoring is reclassified instead of being refused for ever. The docs now say every Git-managed rollout deploy captures the node's previous project on every target type, and that a target can have one rollback at a time. Tests removed with the rollback serialization were restored where the behavior still exists. * fix(gitops): stop stamping an application interruption for a recovery Boot reclassification wrote recovery_started into the application's interruption_stage for a marker-less in-flight restore. No recovery terminal clears an application interruption stage, so after any crashed rollback the value stayed for ever, and the drift collectors read a set application stage as a standing suppression while a Direct source deferred its retry. The application row records the interrupted recovery in its recovery phase; the target keeps the operation stage. A regression test reclassifies a crashed rollback and asserts the application stays clearable through a later successful rollback. The route's 409 test now asserts the live rollout authorization and generation are untouched, so a regression that supersedes the rollout before refusing fails the test. The repair test for a refused recovery replaced by a deploy failure now drives the real writers, and the rollback docs no longer claim the source keeps converging. * fix(gitops): keep the poll cadence through a recovery hold A held application settles every evaluation into recovery_required, which armed neither a retry cursor nor a fresh poll. The fetch that ran while the hold stood consumed the poll cursor, so once success evidence cleared the hold the source sat idle: a pushed commit was never fetched and next_poll_at stayed null until the poll interval was saved again. The controller now re-arms the poll for a recovery wait, an in-flight restore or a hold that success evidence retires. Automatic acceptance stays gated on success-shaped outcomes, so the hold still withholds progression. A regression test holds an application through the real rollback writers, consumes the cursor through the real fetch transitions, and asserts the cursor is re-armed. The rollback section of the Blueprint docs again states that every Git-managed rollout deploy captures the node's previous project on every target type. |
||
|
|
a54ebcc7e2 |
feat(fleet): separate the Fleet tabs into two sections, with a layout setting (#2092)
* feat(fleet): group the Fleet tabs into Observe and Operate, with a layout setting The Fleet tab strip was ten tabs in one row, wider than a 1280px window. The strip now sets the tabs under two labelled groups on one strip: Observe (Overview, Readiness, Map, Docker Labels) and Operate (Snapshots, Blueprints, Routing, Federation, Actions, Secrets). It scrolls sideways when the window is too narrow and keeps the tab you are on in view. Every tab still opens with one click and every tab keeps its value, so deep links and URL sync are unchanged. A new account preference, Settings, Appearance, Fleet, Fleet tab layout, picks how the strip is laid out: Grouped (default), Flat (the previous single row), or Compact (Overview, Readiness, Map, Blueprints and Actions, with the rest under a More menu that names the active hidden tab). It is saved to the account and synced like the other appearance preferences, defaults to Grouped for a document that lacks it, and is cleared with the rest of the preference cache when another account signs in. * refactor(fleet): drop the Observe and Operate labels and the old flat strip The group labels only lengthened the tab row, and the previous single-row layout is no longer needed. The Fleet tab strip is now one row: Overview, Readiness, Map and Docker Labels, a vertical separator, then Snapshots, Blueprints, Routing, Federation, Actions and Secrets. It still scrolls sideways when the window is too narrow and keeps the active tab in view. The Fleet tab layout setting now offers Flat (the default, this row) and Compact (five tabs with the rest under More). The retired grouped value reads as Flat. * test(fleet): raise the cross-tab storage event without the StorageEvent constructor The code quality scan flagged the two-argument StorageEvent calls as superfluous trailing arguments. The tests now build the event from a plain Event carrying the key and the new value, which is all the hook reads. |
||
|
|
d7e0ebc4e5 |
feat(fleet): draw the Map one node at a time as each answers (#2091)
* feat(fleet): draw the Map one node at a time as each answers The Map tab asked for the whole fleet in one request, so every node waited on the slowest one and a single unreachable node held the tab until its timeout. GET /api/fleet/dependency-map now takes an optional nodeId (positive integer; 400 if malformed, 404 if unknown) and answers for that one node in the same shape. The Map tab reads each node on its own, four at a time, and draws each slice as soon as it arrives, with "n of N nodes" beside Refresh while the rest are pending. A node that fails keeps its last slice and is named in the banner with its reason, and Retry in the banner reloads only the failed nodes. A node that never answers is given up on after 20 seconds so Refresh and Retry never stay locked behind it, and an answer that is not a map is reported as a failure instead of drawing nothing. When no node could be read the tab says so instead of calling the fleet empty. * test(fleet): compare the request origin, not a URL prefix, in the map filter tests CodeQL flagged two startsWith checks on a base URL as incomplete URL substring sanitization. The assertions now parse the request URL and compare its whole origin. |
||
|
|
fd5824c142 |
feat(fleet): add Cordon and Delete to the node details sheet (#2090)
* refactor(fleet): give each node one state and one action, and redesign the node details sheet The Fleet node card carried up to six chips (version, update, critical, cordoned, GitOps, integration image) and said "Node unreachable" three ways with no reason and no action. One model now reads a node's state for both surfaces: the loudest condition is the Answer, the rest are counted. The card shows the version as quiet text, one state chip with a +N for the others, and at most one action button, shown only when the session may run it. An offline node says why in one line (Pilot disconnected, last seen 12m ago, or Proxy unreachable, last contact 1h ago) with a Test connection button that runs in place, instead of a dimmed card. Stale update, GitOps and networking readings are no longer shown for an offline node, but a failed or running update stays visible with its Retry and Dismiss. The details sheet opens on an Overview that answers first, with a quiet row of the stages that apply and a collapsed Details section, plus Resources and Settings tabs. Its toolbar holds the object-level actions (Edit, Cordon or Uncordon, Delete) and the action for every other state the node is in, so no action is lost when a louder state is the Answer. Cordon confirmation and the connection test are shared by the card and the sheet. * refactor(fleet): keep the original node card and one-page sheet, add Cordon and Delete to the sheet The one-state-chip card and the tabbed details sheet are withdrawn. The node card is back to its original layout, where the only action button is the node update, and the details sheet is back to its single page. What stays: the details sheet toolbar now carries Edit node, Cordon node or Uncordon node, and Delete node (with View networking when the node has a networking signal), under the same permission rules as the card menu. The cordon confirmation is one shared component used by the card and the sheet, so the reason field, busy lock and failure handling are the same in both. The phone screen is unchanged. |
||
|
|
c925c7b887 |
refactor(fleet): put the Fleet masthead on the shared pattern and list nodes attention first (#2089)
The Fleet masthead was hand-built: its state word always read "The fleet", it showed a healthy tone with zero nodes while loading, and its "alerts" tile restated the critical-node count. It is now a PageMasthead hero with a FLEET kicker and a real verdict (Healthy, Degraded, Critical, plus Checking before the first overview, Unavailable if that first overview failed, and No nodes for an empty fleet). One detail line names the node count, offline and critical counts and sync age, and CPU, memory and containers sit in the metadata. The peak-CPU sub-line and the alerts tile are gone. Fleet nodes can now be sorted by Attention (offline, then critical, then name), which is the default for new browsers. The Local node stays pinned first in the grid. |
||
|
|
f38d4886d5 |
fix(fleet): report a gone Pilot as offline and bound the Fleet map reads (#2088)
A Pilot whose agent stopped kept reading "online" on Fleet forever with no stats, because pilot_last_seen was written only when a tunnel connected. The tunnel close now stamps it, and the overview treats a Pilot with no tunnel as online only for a 30s reconnect grace window. The dependency map and networking summary waited up to 15s per remote, so a half-open Pilot tunnel (agent frozen, not yet reaped, up to about 90s) held the Map tab for that long. Both now share an 8s per-remote budget, say "Timed out after 8s" instead of the runtime's abort text, and tell a body-read timeout apart from a malformed payload. A failed tunnel-down write no longer blocks the tunnel-down event. On the Fleet screen, an aborted overview request no longer clears the loading state of the request that replaced it (which flashed "No nodes configured"), the networking and GitOps requests have their own controllers so a poll does not cancel them, and polling pauses while the tab is hidden. The Overview skeleton waits for the busy delay, and the Map tab keeps its last result, revalidates when reopened, shows a layout-matched skeleton, names each unreachable node with its reason, and offers Retry. |
||
|
|
612c25a670 |
feat(gitops): complete the Git-managed Blueprint rollout path (#2084)
* fix(gitops): materialize frozen rollout targets on dispatch A Git-managed Blueprint rollout dispatched against its authorized frozen target set failed on every node except the source stack's own node with "Could not open deploy for node N: target not found". Only the Inline producer created a target row on first contact, and the Git adapter sent to nodes that had never received a Direct deploy. Create an empty target row for each frozen node that has none before the deploy queue runs, mirroring the Inline producer's deploy_start write. A node that no longer exists is skipped and still refused by name. An existing row is left untouched, so a tombstone, fence, or spent retry budget survives row creation. * feat(gitops): materialize and freeze the accepted generation for Git-managed rollouts A converted source keeps polling, but the Direct pipeline is refused for a claimed source, so nothing promoted a new generation into its applied directory and nothing resolved its artifact set. The rollout dispatch reads the applied materialization and authorization refuses an unresolved set, so a second commit could never be authorized. Promote the accepted generation's candidate into its applied directory on the hub (no writes to the live source stack) and resolve the artifact set from that materialization through the shared approved-content parser. The reconciler's artifact retry now re-runs both halves for a Git-managed Blueprint, so a transient failure heals. A generation that materializes more than one compose file is refused rather than resolved from a subset, and candidate and applied paths get the same shape validation the Direct promotion uses. * fix(gitops): rebind the rollout candidate to a newly accepted generation After a source-only change, sourceAccepted cleared the rollout authorization but kept the placement approval, while the candidate still named the previous generation and artifact. authorizationIngredients refuses a candidate whose binding disagrees with the application, so a second commit could never be authorized. Move the candidate's generation binding to the newly accepted generation and clear its artifact binding in the same transaction. The artifact binding is cleared rather than pointed at the acceptance's seed set because the freeze advances the application's set to the resolved one; authorization re-stamps both from that resolved set. Both acceptance entry points share the rebind, and the store method checks ownership and is idempotent. * fix(gitops): hold an authorized rollout whose dispatch is refused A refused dispatch after a successful authorization returned only a note on a 200 and left the projection reading rollout_queued with nothing an operator could act on. Hold the rollout through the same application pause the health executor uses, so the Answer carries the reason and Resume is the resolving verb. The hold is a no-op when no authorization is live and never overwrites an existing hold. It is placed by the authorize route, the automatic acceptance path, the resume path when the retry is refused again, and the startup reconstruction, with a fresh operation id each time so a repeated hold still writes history and announces itself. A hold that cannot be recorded is logged, never thrown into a dispatch that already answered. * test(gitops): cover a second source commit end to end Drive the audit's repro at the service level: a Git-managed Blueprint rollout to two frozen nodes, a second source commit accepted and prepared through the real materialize and freeze producers, re-authorization through the configured policy, and a second dispatch that deploys the new compose to both nodes. The test asserts the promoted materialization, the resolved artifact set, the minted authorization, the rebound candidate, the deployed compose bytes, and the per-node applied generation. * docs: document the Git-managed source update path Describe what a new commit does on a Git-managed Blueprint: acceptance materializes the generation and attempts to resolve its artifact identity (the reconciler retries an incomplete resolve), placement stays approved across a content-only change, and authorization starts the sequential rollout again, on its own under the automatic policy or held with the reason when preflight or artifact evidence is not ready. The Resume bullet records that a refused retry re-holds. The What's New blurb now describes the working chain instead of a blocked state. * fix(gitops): retry Git-managed preparation and classify dispatch holds A newly accepted generation whose preparation failed (a registry outage, or a crash between the acceptance commit and the materialize call) was stranded: re-accepting is refused, and the per-target artifact retry only looks at the generation a target already holds. The reconciler now runs an application-driven retry for every live Git-managed application, re-materializing and re-resolving the accepted generation and completing the automatic handoff once it resolves. A compose shape the authored-text parser cannot model is refused and remembered, and its reason is persisted as an evidence limitation the projection can show. Blocked dispatches now carry whether they are holdable. Lock contention, an already-recorded pause, a per-target pause, a superseded generation, and a suspended source are not: pausing on them stopped a healthy rollout another dispatch was advancing, and the per-target case needed two Resumes to undo one decision. The health rollout executor shares the classification. A converged Git-managed Blueprint no longer projects the Direct manifest cache as managed-project drift. * fix(gitops): clear system holds on acceptance and record their origin A system hold (a refused dispatch, a health fence) belonged to the rollout it was holding, but acceptance never cleared it: the next generation dispatched into "The rollout is paused." with a reason describing the superseded one, and before this change the next commit dispatched on its own. Holds now carry an origin. A system hold is cleared when a source acceptance supersedes its rollout, for every target mode, with a rollout_unpaused history row; an operator pause survives until the operator resumes. Health-executor and fence holds record the system origin too, so the same rule covers them. The docs and the accept-route note now describe the retry, the automatic handoff, and the unmodellable-compose refusal the way the code behaves. * fix(gitops): close the re-audit findings on holds and the retry The health executor now forwards the pause-already-on-record marker and the adapter marks the paused refusals with it, so a per-target pause is not escalated to an application hold on that path. Every blocked result declares whether it is holdable, and the Direct pipeline's exact-shape tests follow. The reconciler's preparation pass runs before the enabled check for a disabled-only fleet, uses the same durable retry gate as the per-target path, and is detached from the tick; the tests prove the isolation, the permanent-failure gate, and the non-blocking tick. The accept route's dispatch wait is bounded, the resolved-artifact gate lives in the one handoff, and the unmodellable-compose reason now reaches the operator in the caveat and the accept note. * test(gitops): use the fixture in the non-blocking tick test The test declared a fixture without reading it, which failed the lint gate on CI. The fixture is now authorized and the test asserts that the released durable refusal held the application, which also proves the detached preparation pass reached the dispatch. * fix(gitops): re-drive the automatic handoff and keep hold origins honest The reconciler's content pass now has a handoff arm: a resolved generation under the automatic policy with no live authorization (a slow accept, a re-enabled Blueprint) or with unacked targets (a transiently refused dispatch) is dispatched on the next pass. The warranted predicate keeps it off a settled, progressing, withdrawn, or not-yet-placed rollout, and an in-memory floor bounds a failure that records no evidence row to the artifact retry interval. The pause origin now comes from the refusal's kind: executor dispatch refusals are system holds that a new commit clears, and only real health decisions (a stop, pause, or rollback) carry the health origin. The accept route gates its dispatch half on stack:deploy while the acceptance stays stack:create, the inventory and the UI match, the unmodellable-compose copy no longer prescribes a fix for the wrong refusal, and its cause is capped. * fix(gitops): tighten the redrive guards and report both accept halves The warranted predicate now leaves a disabled Blueprint, a target with an operation in flight, a fenced target, and a stale open run under an observe policy alone, so the reconciler does not race a progressing rollout or re-drive a no-op. A refusal that did not hold the rollout is diagnostic rather than a per-tick warning. The accept route reports the preparation note and the withheld-deploy note together, the UI fails closed when the rollout policy is unreadable, and the rollback-fence clear is scoped to the live rollout generation. Per-application memory is pruned. * fix(gitops): detach a superseded rollout on acceptance and bound the redrive A source acceptance now always detaches the named rollout generation, marking it superseded only when it was not already, so an earlier Supersede or Rollback cannot leave the frozen strategy reading as moved on and disable the automatic handoff for every later commit. The warranted predicate is generation-aware and skips per-target pauses, and the handoff arm has an in-memory floor at the artifact retry interval and a concurrency cap, so a steady blocked preflight or a persistent refusal is not re-evaluated on every tick. A rollback fence blocks the hold clear whatever generation wrote it, matching the dispatch queue's behaviour. The accept route drops the deploy split: the configured Automatic policy is the authority for starting the rollout, as it is for the automatic source acceptance, so the acceptance stays `stack:create`, the UI gate returns to `stack:create`, and the inventory row says so. * fix(gitops): guard the preflight-drift supersede and pin the review tests The preflight-drift invalidation only records a supersede when the generation is not already marked, so a repeat drift does not append a duplicate history row. The accept-route test seeds the automatic policy it names, and the paused-target test asserts the predicate's decision rather than only that it ran. * chore: re-trigger CI after the Actions runner delay * fix(gitops): make the withdrawal check durable and refuse over an unfinished rollback The automatic re-drive predicate no longer trusts the rollout generation pointer for a withdrawal: it refuses while the latest rollout authorization recorded for the accepted source is superseded, so a placement re-approval cannot revive a rollout the operator withdrew. It also refuses to mint when a live target still carries a rollback_pending fence, whichever generation wrote it. The supersede recorder is idempotent, so repeat invalidations and re-approvals stop appending duplicate history rows; the hold clear and the predicate both ignore retired rows, which can never finish a rollback. The accept route joins the preparation note and the dispatch refusal again. * chore: refresh CI after the Actions outage * fix(gitops): mark an operator withdrawal explicitly and share the rollback gate A dedicated withdrawn_at on the rollout generation is written only by the operator's Supersede and Rollback, so the automatic policy can tell a deliberate withdrawal from a system supersede (a preflight drift, a placement invalidation, a re-authorization) and still re-drives after those. Legacy superseded rows backfill as withdrawn, the safe direction. The unfinished-rollback refusal moved into the shared handoff, so the accept route and the SourceController honour it too, with the reason logged; the predicate no longer carries its own copy. The accept note joins its parts as sentences. * fix(gitops): withdraw the latest authorization and hold over an unfinished rollback The operator's Supersede and Rollback now mark the latest rollout authorization recorded for the accepted source as withdrawn, not only the generation the pointer names, so a placement re-approval after a system supersede cannot leave the automatic policy free to redeploy what was rolled back. The unfinished-rollback refusal moved into the shared handoff as a holdable blocked refusal: it writes the reason on the application and the accept note shows it, instead of a silent skip. The rollback target set falls back to the fenced target's own generation, so the rollback can still be finished after an acceptance detached the pointer. The migration backfill is flag-tracked and transactional, the marker lookup is ordered by rowid, and the accept note joins its parts as sentences. * test(gitops): pin the rollback route, the withdrawal migration and the tick tests The rollback route gained its own tests: a placement pointer with an older authorization is withdrawn, and a fenced target keeps a resolvable target set after the pointer detached. The withdrawal backfill has a migration test covering the four states (fresh boot, column without flag, later system supersede, dropped column), and the latest-authorization lookup has a skewed-clock ordering test. The re-drive tests now use the settled tick-until helper instead of a single tick, so the handoff concurrency cap cannot defer the application under test. The rollback hold reason, the feature docs and the Resume note now say "finish it, then resume". * chore(deps): bump proxy-addr to 2.0.8 and source-map-js to 1.2.2 New advisories (GHSA-jqcg-44mw-7w3h, critical, and GHSA-68fv-2mgg-jv7q, high) require either a patched version or a VEX statement. Both patches are inside the existing dependency ranges, so the lockfiles move without any application change. * test(gitops): pin every withdrawal-migration state, including the flag gate The migration test now boots one database state per case: the column dropped without the flag, the column present without the flag (an earlier head, or a crash between the ALTER and the backfill), a fresh boot with no rows, and a later system supersede left untouched on the next boot. Reverting the flag logic to a column-presence check now fails three of the four, which is what the flag exists for. * fix(gitops): report the handoff outcome on accept and scope the docs claims The accept route now returns a skipped handoff's reason, so the operator can tell a started rollout from one waiting for authorization, paused, or held, and the UI prefixes the acceptance to the warning toast. The Resume reply under the Automatic policy no longer tells the operator to authorize when the reconciler will start the rollout itself. The feature docs scope the recovery-capture claim to Local and proxy nodes (Pilot capture is a tracked follow-up) and state that a Git-managed Blueprint skips the managed-project drift comparison by design. * fix(gitops): stop the accept note repeating the artifact reason When the preparation did not resolve the artifact identity, the accept note carried the preparation note and the handoff's unresolved-artifact skip, which say the same thing. The skip reason is now a shared constant and is suppressed when the preparation note already covers it; every other skip reason still reaches the operator. |
||
|
|
661412a7cb |
fix(blueprints): announce a Suggest-mode drift once per episode (#2086)
* fix(blueprints): announce a Suggest-mode drift once per episode A Blueprint in Suggest drift mode raised one identical blueprint_drift_detected notification per reconciler tick for as long as the drift lasted. A drifted row is re-checked every tick so a drift that resolves is noticed, and every check re-observes the same drift, so the suggest branch dispatched on every pass and filled the bell and any routed channel. The check now passes whether it observed a new episode into the mode's response. An episode starts when the row first reads a drift state and ends when a check finds the workload converged, so a repeat is silent while a drift that clears and returns announces again. A held target belongs to the same episode: the hold is a decision about the repair, not a resolution of the drift, and the episode keeps its original start time through it. Enforce and Observe behavior is unchanged. Verified: 10687 backend tests pass; the only failure is the SSH transport integration test, which needs loopback port 22 and fails the same way on main because this host runs an SSH daemon. Typecheck and lint are clean. The new tests drive the real reconciler tick on both the Inline and Git-managed paths: three ticks on a persistent drift produce one alert, a drift that clears and returns produces two, and a held target produces none while keeping its drift age. * fix(blueprints): stamp the drift start when a hold began the episode An episode that began as an Enforce hold carried no drift start: the hold write records no last_drift_at, and the drift write that followed the hold treated the row as already drifted, so it skipped the stamp. The row then read drifted with a null start time, and the deployment table fell back to last_deployed_at for "Last activity", showing a stale deploy time for a current drift. The drift write now stamps the episode start when the row has none and keeps the existing start otherwise. Both the Inline and Git-managed observation paths read the episode through one helper, so the episode definition and its start time have a single home. Verified: 108 tests pass in the touched files, including a new test that drives a real Enforce tick from active into a hold, switches the Blueprint to Suggest with a fresh approval, clears the hold, and asserts the drift start is recorded with no new alert. Typecheck and lint are clean. |
||
|
|
fc96137788 |
fix(gitops): authenticate SOPS values with their key path (#2081)
* fix(gitops): authenticate SOPS values with their key path SOPS authenticates every encrypted value with its key path as AES-GCM additional authenticated data. The decoder never passed it, so values in a real sops file failed authentication and the pull surfaced a generic error while the poller retried. Thread the key path through YAML/JSON decryption (colon-joined with a trailing colon, array elements keep the parent path) and derive it for dotenv and INI stores, where a [sops] section marks INI and values outside a named section belong to DEFAULT. Authentication failures now classify as decrypt_failed instead of being reported as a wrong identity. The regression test decrypts files generated by sops 3.13.3 with a test-only age key, so fixtures no longer share the decoder's assumptions. * fix(gitops): keep dotenv newlines and nested sops keys in SOPS output A dotenv value containing a newline was emitted across two lines, which truncated the secret and turned the tail into a separate env_file line. Escape newlines back to \n for dotenv output, matching sops. A nested map key named sops was dropped because the metadata skip applied at every depth. Strip sops from the root record only; nested keys are data. Add real-sops fixtures and exact assertions for both, and pin the full INI output instead of substring checks. * fix(gitops): quote dotenv output Compose would otherwise rewrite sops escapes newlines as \n, but Compose expands escapes only inside double-quoted env_file values. Unquoted output delivered a literal \n, an interpolated $, or a comment-truncated value to the container. Quote and escape exactly the values Compose rewrites: newline, carriage return, tab, backslash, double quote, $, #, a leading single quote, and edge whitespace. Add a real-sops metacharacter fixture, an exact-output unit test, and a docker-integration test that feeds the decrypted file to docker compose and asserts the container receives the original bytes. * fix(gitops): format decrypted dotenv for the consumer role Compose-style quoting applies only to inputs Compose parses as env files. A dotenv-format file consumed verbatim as a file-backed secret kept different bytes than the plaintext: $ was escaped, # quoted, and edge whitespace wrapped. Pass the consumer role through the overlay and default the decoder to the sops-faithful output, so only env_file, include-env, and interpolation-env inputs get Compose formatting. Add role-split coverage at the overlay level and a fixture test pinning the sops-faithful output. * test(gitops): cover the secret role in the dotenv consumer split The overlay role test covered env and config; add a secret input so a broadened Compose-formatting condition fails the suite. * fix(gitops): validate and deploy SOPS stacks from the decrypt overlay Two pre-existing defects kept a SOPS-managed stack from ever validating or deploying: - The compose runner's working-directory guard allowed only the managed area and the temp root, so candidate validation rejected the decrypt overlay under $DATA_DIR/git-secrets with "Invalid working directory". Allow that root, shared with the overlay's own path resolution. - A single-file stack carries no project flag in its live or captured prefix, because the Compose project name normally comes from the working directory basename. Running from the overlay made Compose create a stray project named after the operation id and leave the real stack untouched, while the API reported success. Pin -p <stack> whenever Compose runs from an override directory that is not the canonical stack directory. Add regression tests for the guard and for live and captured prefixes, and cover the no-double-pin case. * fix(gitops): correct SOPS readiness, JSON output, and failure reporting Three QA follow-ups in one pass: - Multi-recipient readiness: a file encrypted to several age recipients is decryptable by any one of them. Readiness, LKG, recovery, rollback readiness, rotation impact, and the readiness route now evaluate per input instead of requiring every recipient on the node. Legacy capabilities without per-input recipients keep the flat-list check. require_encrypted now governs managed repository content only, so a missing unmanaged project .env no longer refuses every stack. - JSON output: a JSON SOPS document is detected and re-emitted as JSON with sops's tab indentation instead of being converted to YAML. - Decrypt failure reporting: prepareOverlay preserves SopsDecryptError, a shared asGitSourceFailure maps it to a new SOPS_DECRYPT_FAILED code for fetch, apply, and reconcile, the route returns 400 with the message, and backoff treats it as operator action instead of a transient retry. * fix(gitops): enforce the encrypted-source policy on a synced .env A plaintext repository .env with sync_env on was accepted under require_encrypted because discovery records it as unmanaged and the policy judged only managed inputs. The synthesized sync entry now carries its encryption kind and is merged into the capability inputs, so the file Sencho writes to the stack root is judged by the policy. The SOPS project-name pin overrode a compose-declared name:, starting a second project beside the running stack. The pin now stands down when the compose layers declare a top-level name. * fix(gitops): contain the compose name probe to the stack tree The compose-name check read every -f layer, including generated overlay files outside the stack tree, with no containment barrier and a stat-then-read pair. Resolve the stack directory once, skip any layer that resolves outside it with an inline barrier at the read sink, and read directly with a content-length bound so the path-injection and file-system-race findings are cleared. Generated overlay layers cannot declare a project name, so behavior is unchanged. |
||
|
|
143f5a2005 |
feat(blueprints): open a Blueprint and the create dialog over the GitOps workplace (#2083)
From the GitOps workplace, opening a Blueprint or declaring a new one sent you to Fleet first and only then opened the sheet, so the list you came from, with its filters and scroll, was gone. The application sheet's Open Blueprint did the same, and Escape could not take you back. The Blueprint detail sheet and the create dialog now open over the workplace, the way a Direct application's Git source already does. They are the same sheets the Fleet Blueprints tab hosts, driven by one shared hook, so permissions, the create-then-review flow and label handling are identical in both places. A row's Open Blueprint, the application sheet's Open Blueprint (which hands its sheet over) and New Blueprint all go through it, and fall back to the Fleet Blueprints tab where the workplace cannot host them. The link back to the GitOps portfolio is left out of a sheet that already sits over it. Reading node labels is sequenced so an older answer never replaces a newer one, and a failed read says so instead of leaving label targets silently empty. |
||
|
|
8a9115f48c |
feat(blueprints): call the tab Blueprints, add a Needs attention filter and keep the catalog on screen (#2082)
The Fleet tab was called Deployments while every surface inside it said Blueprints, its empty state repeated a heading and offered two create buttons, and the populated catalog opened with a third naming of the same thing. The filter row had a Drifted chip that missed failed, conflicting, blocked and awaiting-confirmation Blueprints, and a tile showed only a coloured dot, never the state it stood for. Every refresh blanked the whole tab (closing an open sheet's context) behind a plain "Loading blueprints" line. The tab is now Blueprints. One header row carries the count and New Blueprint; the empty state is just its card with one create action. Needs attention replaces Drifted and covers failed, name conflict, evict blocked, awaiting confirmation, held repair, drift and a rollout that needs reconfirming; a tile names its strongest non-active state, and an empty filter offers Show all. The first load shows a skeleton shaped like the catalog, and only if it is slow. Later refreshes keep the catalog and any open sheet on screen; a failed refresh over a catalog shows a notice with Retry and the last list, and the error card is only for a tab with nothing to show. Overlapping refreshes are sequenced, and a failed refresh after creating a Blueprint no longer opens its sheet later. Docs and the e2e tab lookup follow the new name. |
||
|
|
d3f6783457 |
fix(gitops): make native provider webhooks work on remote nodes and for GitLab (#2078)
* fix(gitops): forward provider webhooks with one content type
A provider hook forwarded to the node that owns the stack carried two
Content-Type headers: the forwarder seeded one and copied the incoming
lower-case one, and the HTTP client merged them into
"application/json, application/json". The owning node's JSON parser
skipped the unparseable type, so the raw body was empty and the
signature check rejected every remote delivery.
Seed a single lower-case content-type, skip the incoming one, and
forward every other header under its lower-case name. The integration
test drives the real parser and internal hook route on a loopback
owning node and verifies the signed body and the reconcile handoff.
* fix(gitops): read the GitLab event type from the payload
GitLab's X-Gitlab-Event header is a display label ("Push Hook",
"Merge Request Hook"), not an identifier, so comparing it to "push" or
"merge_request" classified every GitLab delivery as unsupported.
Prefer the payload's object_kind, with the label normalized as a
fallback when object_kind is absent. Tests use the real header values
and cover push, merge request, and the label fallback.
* test(gitops): cover provider webhook forwarding and GitLab labels
Add coverage for a charset content-type on the inbound request, a
Pilot-shaped owning node with no API token, a GitLab tag push outside
the configured ref, an unknown GitLab label, and a payload object_kind
that disagrees with the header label.
* fix(gitops): deduplicate GitLab deliveries by event UUID
GitLab sends X-Gitlab-Event-UUID on every delivery (14.8 and later)
alongside Idempotency-Key and webhook-id. Read it as a delivery id so
a redelivery of the same event is recognised as a duplicate instead of
queuing a second reconcile.
|
||
|
|
fd95c8e19b |
fix(stacks): make remote create-from-git honor deploy keys and private CAs (#2076)
* fix(stacks): make remote create-from-git honor deploy keys and private CAs The remote create-from-git flow discovers the repository on the target before the create request is forwarded. That discovery payload carried the repository URL, branch, compose paths, auth type, and token, but not the deploy key, known_hosts entry, host-key fingerprint, or CA bundle, and the target never mapped the missing fields into the fetch. SSH and private-CA repositories therefore failed during discovery on a proxy or Pilot node. Discovery failures were also reported as a generic 500, hiding the classified repository, ref, auth, redirect, and TLS errors the direct create route already returns. Forward the four fields, build the same fetch auth the direct create path uses, and map classified git failures to the same status, message, and code, never 401. Raise the discovery request cap so a route-legal deploy key plus CA bundle fits. * fix(registry-delivery): relay classified git failures through the hub The target classifies a git discovery failure and answers with the same status and code the direct create route uses, but the hub kept only the status and message and replaced every code with REGISTRY_DELIVERY_FAILED. Webhook and scheduler consumers and durable failure records could not tell rejected credentials from a missing ref, and a classifier timeout lost its message to the generic text. Carry a known git code from a 4xx or 504 discover response through the result, re-derive the status from the code so a target can never answer 401 and sign the operator out, map an unclassified target 401 to an upstream failure, and keep every other 5xx generic. A git code counts as a delivery refusal only when its status is the one that code maps to, so an unrelated git error is not relabeled. The runtime git code list and the GitSourceErrorCode union now share one source. * test(git): read the git error code list from its single source The support-matrix guard parsed GitSourceErrorCode out of the type alias, which no longer carries string-literal members now that the union derives from GIT_SOURCE_ERROR_CODE_VALUES. Read the const array instead, unwrap its as-const, parenthesized, and satisfies forms, and require a top-level declaration so a nested local cannot satisfy the guard. The partition assertions are unchanged. * fix(git): keep the git error code set out of the service import cycle gitSourceHttp built its closed code set at module load from a value exported by GitSourceService. That module sits on an import cycle completed by the registry delivery path, so under some load orders the array was still undefined when the set was built. The set came out empty, isGitSourceErrorCode rejected every code, and the classified error relay silently fell back to the generic failure. Move the code list and its derived type into a leaf module with no imports and have both the service and the HTTP helper import from there. The service re-exports both names, so existing importers are unchanged. Add a module-order test that loads GitSourceService first and a route that reaches it first, asserting the predicate still accepts a known code; both cases fail when the helper reads the array through the service again. * fix(proxy): authorize remote create-from-git before the discovery fetch Hop-1 discovery runs a full git fetch on the target with the caller's credentials, before the target's own permission check. A viewer, deployer or auditor could therefore trigger a fetch on a remote node, and could probe an internal host's SSH port through the failure timing. The same ordering already applied to token auth; forwarding deploy keys and private CAs widened it. Check the caller on the hub before the discover call, mirroring the direct route: stack:create globally, then stack:deploy when the request asks to deploy. The check runs before the body is inspected, so an unauthorized caller is refused whatever the body shape. The target still enforces both. The trailing-slash form now classifies like its sibling gates so the same protection applies, and a loopback integration test covers the viewer and authorized paths. |
||
|
|
d3fb532dfa |
feat(blueprints): give the Blueprint sheet one Answer and a shorter toolbar (#2080)
The Blueprint detail sheet opened on GitOps rollout and runtime stages that say nothing true about a Blueprint that is applied rather than rolled out: a healthy, fully deployed one showed an amber "rollout not executable" and "runtime never applied". A failed deploy was told five times, its Docker log pushed Re-apply and Withdraw off the right edge of the table, and the toolbar carried five buttons, three of them rare. An Inline Blueprint now leads with one status block built from its own deployments: in sync, failed, drifted, needs confirmation, reconciler off and so on, naming the affected nodes and offering the verb that resolves the loudest problem (Re-apply, Review rollout, Review state, Evict, Enable) only when this session may run it and the server would accept it. The GitOps status takes over when it holds a louder warning, and the source that did not speak is named in a quiet marker. Rollout "not executable" and runtime "never applied" are left out for a Blueprint that has no Git rollout; a paused rollout or a failed runtime still show. The toolbar is Apply now, Edit, Enable or Disable, and Delete. Convert to Git, Detach Git and Retire to Direct sit in the Content row next to the source they change, and a pinned Blueprint gets an Unpin button. A failed row shows the one line that says why, with the full output behind a control, and its action is named Re-apply to match the status block. |
||
|
|
7051673553 |
feat(blueprints): pin the create actions and show form errors beside the field (#2079)
The Blueprint create dialog kept Cancel and the primary action at the end of the scrolling body, so at common window heights the button was off screen, and a missing name or target only surfaced as a toast while the field it named was scrolled out of view. The create actions now live in the dialog's own footer and stay in view, with a short line saying whether the action adds node labels or deploys nothing yet. Name, compose and target problems show below their field, the name field takes focus, and each message clears as soon as that field is fixed. Reconciler enabled is a switch with a one-line consequence, the three stateful notices are one line, a failed compose analysis is logged and labelled instead of silently leaving a stale classification, and the default compose and drift card captions read in plain words. Deleting a Blueprint uses the shared destructive confirm with a busy state and keeps the typed-name check. |
||
|
|
03e2636e6a |
feat(blueprints): create node labels and see matched nodes from the Blueprint form (#2077)
The Blueprint form sent operators to Settings > Nodes whenever no node label existed, and closing it to do so discarded everything typed. It also showed the same labels in two rows (any / also all) with no list of the nodes a selector actually reaches. Targets now has one row of node labels with a Match any / Match all switch and a live "Matches N nodes" list. "+ Label" stages a new label on chosen nodes inside the form; the pending tags are listed and removable, and they count toward the match list immediately. Nothing is written to a node until the form is committed: Review rollout (or Create blueprint / Save changes where a rollout cannot be reviewed) saves the Blueprint, adds the staged labels through the existing node label endpoint, then opens the rollout preview. Save as draft stores the Blueprint only. Staged labels that the final selector does not use are never written. A Blueprint saved with both an any and an all list keeps showing two rows. Docs and the create-and-approve tutorial follow the new flow; two screenshots that no longer match any step are removed. |
||
|
|
4b060ea216 |
fix(registries): key delivered Docker Hub credentials under the v1 auths key (#2075)
* fix(registries): key delivered Docker Hub credentials under the v1 auths key Delivered credentials were keyed by the normalized image host, which folds Docker Hub aliases to `index.docker.io`. The Docker CLI only matches Hub credentials under `https://index.docker.io/v1/`, so remote pulls of private Hub images failed with "pull access denied" after the hub reported a clean delivery. GHCR and custom hosts match their bare hostname and were fine. The delivery seam now keys its output as the Docker CLI reads `auths`, and the single-host resolver uses the same mapping. Target-local credentials still win over delivered ones. * test(registries): cover Docker Hub alias refs at the delivery seam The seam test used the explicit index.docker.io spelling only. Point its Hub services at docker.io and registry-1.docker.io so discovery's alias folding is exercised through the delivery path, keeping the same v1 key assertions. The explicit spelling stays covered by the local-wins case. |
||
|
|
6178bc6c4b |
fix(gitops): normalize Docker architecture names to OCI platform names (#2074)
* fix(gitops): normalize Docker architecture names to OCI platform names Docker reports the daemon host's `uname -m` spelling in `docker info` (`x86_64`, `aarch64`, `armv7l`), while registry index descriptors name platforms with OCI names (`amd64`, `arm64`, `arm`). The digest resolver compares the two by exact string, so GitOps artifact resolution failed on every Docker host with "no linux/x86_64 variant", and a Direct Git source apply with deploy stayed applied_not_deployed with "Private registry readiness could not be proven". Translate the architecture and OS at the read boundary, so the local and remote platform reads return OCI names. Tests cover the docker-info spellings, the Windows path, and the resolver's exact-match contract. * fix(gitops): tolerate legacy platform labels and drop the ARM guess An artifact set stored before the read translated docker-info architecture spellings keeps platform labels like linux/x86_64. The identity comparator and the evidence decoder now canonicalize both sides, so digest repair and the stale-probe membership check still find the approved child, including when the platform label comes from a leaf that has not been upgraded yet. The armv6l/armv7l/armv8l rows are removed: OCI distinguishes the arm variants and this code does not model the variant, so mapping them to `arm` could qualify a child built for a different ABI. Those hosts fail closed again. loongarch64 now maps to loong64. * fix(gitops): match a containerd node's index-only observation The containerd image store exposes only the image index digest locally (image Id, RepoDigests and descriptor all carry it), so the platform child digest is never visible on the node. The identity comparison required the child and reported every multi-arch deploy as runtime artifact drift even though the running image was the approved child. An observation that names no child of the expectation now proves the child through the frozen index digest and the platform the running image declares. An observation that names a recorded child must still name the approved one, and any digest that is neither the expected index nor a recorded child keeps the comparison fail-closed. The hub also translates the platform a leaf reports, so a leaf running before the normalization no longer fails every resolve, and a stored artifact row that cannot be decoded is logged instead of dropped silently. |
||
|
|
a1285754d1 |
fix(dashboard): keep the Stack Health name column readable at 1280 (#2073)
* fix(dashboard): keep the Stack Health name column readable at 1280 The GitOps grid minima (1004px this node, 1104px all nodes) did not count the grid's own horizontal padding, which border-box folds into min-width. At 1280 wide the table sat on that floor and the stack-name track was left 2px, so names were unreadable and the STACK and STATE headers ran together. Raise each GitOps minimum so it covers the fixed tracks, the gaps, the 40px row padding and a 160px stack-name floor: 1162px this node, 1266px all nodes. Below that the table scrolls horizontally with the name readable. * fix(dashboard): give the plain Stack Health grids the same name floor The non-GitOps grids carried the same floor-accounting defect as the GitOps ones: their min-widths did not count the grid's own horizontal padding. At the floor that left 2px (this node) and nothing (all nodes) for the stack name. Apply the same accounting, 998px this node and 1102px all nodes, each leaving a 160px stack-name track, and extend the regression test to all four grids. |
||
|
|
2f6c0fffe0 |
fix(preferences): migrate explicit UI preferences on first login after upgrade (#2072)
* fix(preferences): migrate explicit prefs on first login after upgrade An upgraded browser profile has explicit appearance and navigation preferences in localStorage but no owner marker, because the marker only exists once the server-backed sync has run. The first sync treated the missing marker as a foreign cache and wiped every preference key before hydration, so the absent-row migration then persisted defaults. Keep the cache whenever no marker exists: it is the account's migration source on first sync. Only a valid marker naming a different account wipes the cache before hydration. Signing out also keeps the cache and its marker, so a same-account re-login can still hydrate or migrate it; the next different account is wiped at claim time, before anything can render or migrate. * fix(preferences): reset live stores when a different account claims the browser Wiping the localStorage cache on an account switch left the theme and navigation stores holding the previous account's live values, so a brand-new account could render that look and then migrate it into its own server row. The foreign-cache claim now resets both stores to defaults, under the hydration guard, before hydration starts. A same-account login, a marker-less upgrade, and the tombstone and corrupt paths are unchanged. |
||
|
|
ef2bd00576 |
fix(security): forward only validated client addresses to remote nodes (#2071)
The hub relayed the caller's X-Forwarded-For and X-Forwarded-Proto to a remote node unchanged. A remote that lists the hub as a trusted proxy therefore recorded an address the caller chose: a caller could send X-Forwarded-For from an untrusted peer and that value appeared in the remote's audit row. Strip every x-forwarded-* header at the hop, then set x-forwarded-for to req.ip and x-forwarded-proto to req.protocol, the values Express resolved under the hub's own trusted-proxy policy. Remove x-real-ip and forwarded, which nothing at this layer vouches for. Apply the same rule to the WebSocket forwarder with resolveUpgradeClientIp and the new resolveUpgradeProtocol, because Express middleware does not run on an upgrade. The pilot tunnel keeps stripping these headers: the agent's loopback listener cannot verify a relayed client address. |
||
|
|
966cf7255b |
fix(security): make ignored forwarded headers loud (#2069)
* fix(security): make ignored forwarded headers loud Behind a reverse proxy, forwarding headers are accepted only from peers listed in SENCHO_TRUSTED_PROXY_CIDRS. When the list is unset or invalid, that failure was silent: client addresses, secure-cookie detection, SSO callback URLs, Pilot enrollment URLs, and rate-limit keys all fell back to the proxy, and an invalid list (for example a bare IP) was ignored without a word. Log the effective policy at boot, naming every rejected entry and the reason. Warn once per untrusted peer when a request carries forwarding headers the policy is ignoring, with the exact CIDR to set. Document the behavior in the reverse-proxy and troubleshooting pages. The fail-closed semantics of the allowlist are unchanged. The login limiter test now drives the real createApp pipeline instead of setting trust proxy by hand, which is what hid this regression. * fix(security): keep forwarding headers out of the pilot tunnel The tunnel bridge framed the hub's incoming request headers as-is, so a reverse proxy in front of the hub had its X-Forwarded-* headers relayed to the agent. The agent replays them to its loopback listener, which saw them from 127.0.0.1 and warned about an untrusted proxy, suggesting the operator trust loopback. Strip forwarding headers when framing http_req and ws_open so the tunnel carries no unverifiable client address; the hub's own pipeline has already applied its trusted-proxy policy to the original request. Forwarded (RFC 7239) is stripped defensively. Also narrow the reverse-proxy docs to the limiters that actually share a bucket (sign-in, SSO, and other unauthenticated requests; signed-in requests stay per user or per token), assert the boot log wiring, and word the runtime warning as "add" when the list is configured, or point at the rejected entries when it is invalid. * docs(security): qualify trusted-proxy attribution and warning scope QA follow-ups on the previous commit. The reverse-proxy paragraph now says the proxy transport is the one where a remote can name the operator's address if it trusts the forwarding instance; Pilot agents receive tunneled requests without forwarding headers, so their local audit trail records the tunnel ingress and the hub's audit log names the operator. The runtime warning is documented as HTTP-only, since WebSocket upgrades bypass Express middleware. The warning itself now says "a reverse proxy or another Sencho instance" because a proxy-mode remote's peer is the hub. |
||
|
|
d30e4daadc |
fix(security): triage the unfixable braces advisory (#2070)
The braces advisory GHSA-vfj7-8cjw-p6xm (CVE-2026-93687) marks every released version (<= 3.0.3) HIGH for stack exhaustion while parsing a deeply nested glob pattern, and no patched release exists. braces reaches the production image through http-proxy-middleware -> micromatch, and dev tooling through chokidar, so both the Trivy image scan and the npm audit gate fail on every branch. Record the triage as an OpenVEX not_affected statement: the only patterns braces compiles come from static proxy and watcher configuration at startup, and request URLs are matched against the compiled matcher, never compiled as patterns, so an adversary cannot control the vulnerable code path. Verified against a real scan with `trivy fs --scanners vuln --vex`. Replace the two `npm audit --audit-level=high` CI steps with scripts/audit-with-vex.mjs, which fails only on advisories the VEX document does not cover and propagates suppression through npm's `via` dependency chains. The statement carries the CVE in `name` for Trivy and the GHSA URL in `@id` for the script, so one record suppresses both gates. |