feat(stacks): persist a drift ledger with temporal source-change detection (#1333)

* feat(stacks): persist a drift ledger with temporal source-change detection

Build on the read-only compose-vs-runtime drift check so a stack's drift is
remembered over time, not just shown at a glance.

- Record a deploy baseline: on a successful deploy, update, or rollback, store
  the deployed compose file's source and rendered-model hashes on the stack so
  the Drift tab can tell whether the file has changed since the last deploy.
- Surface temporal drift in the Drift tab: "matches last deploy", "source
  changed since last deploy" (distinguishing a model change from a
  formatting-only edit), or "no deploy baseline yet".
- Persist findings into a drift ledger: a re-check reconciles the current
  findings, recording newly detected ones and resolving cleared ones, and shows
  a short drift history under the findings. The drift report read stays
  side-effect-free; only an explicit re-check (and a deploy) writes the ledger.
- Write drift detected/resolved events to the stack Activity timeline so the
  provenance sits alongside deploys and restarts.

Node-local and available on the Community tier. Reconciliation is skipped when
a check is not authoritative (Docker unreachable or a compose parse error) so an
open finding is never falsely cleared.

* fix(stacks): record the drift baseline for every deploy path and harden the ledger

Address review feedback on the drift ledger:

- Record the deploy baseline in ComposeService.deployStack/updateStack instead of
  only the manual route, so bulk, Git-source, App Store, scheduler, and webhook
  deploys all capture source/rendered hashes. Reconciliation stays on the explicit
  re-check.
- Store no rendered baseline when the local parser cannot model the compose (for
  example a file over the parse cap) rather than a sentinel that would make a later
  real change read as unchanged.
- Let temporal-overlay failures surface as a 500 instead of being hidden behind a
  neutral "no baseline"; only the compose read stays best-effort.
- Omit the temporal card entirely when a report (for example from an older remote
  node) carries no temporal data, instead of showing a misleading "no baseline".
- Keep drift_detected / drift_resolved history-only by excluding them from the
  routable-category whitelist, so they are never offered as a channel route that
  would never fire.
- Use a JSON separator for the finding identity key so the source file is plain
  text (no embedded control byte).

* fix(stacks): sanitize logged errors in the drift report handlers

The drift report and re-check handlers logged the caught error object
raw alongside the stack name, which a code scan flagged as a
log-injection vector: a crafted stack name surfacing inside an error
message or stack could forge log lines. Route the error through the log
sanitizer so control characters are stripped before writing. Render it
with util.inspect first so the stack trace, cause chain, and underlying
error codes are preserved for debugging.
This commit is contained in:
Anso
2026-06-07 20:44:22 -04:00
committed by GitHub
parent 421177e4a6
commit b21324f97a
11 changed files with 920 additions and 18 deletions
+23 -2
View File
@@ -5,7 +5,7 @@ description: See at a glance whether a stack's running containers still match th
The **Drift** tab in the right-hand **Anatomy** panel answers a single day-two question: does what is actually running still match the Compose file on disk? Sencho treats your Compose file as the source of truth, so it compares the file against the live Docker runtime and reports exactly where the two have diverged.
The check is read-only. It tells you what changed and never alters a stack on its own, so you can trust the report before deciding what to do about it. The report is built fresh each time you open the tab, with no separate state to maintain.
The check is read-only. It tells you what changed and never alters a stack on its own, so you can trust the report before deciding what to do about it. Opening the tab builds the comparison fresh. When you deploy a stack through Sencho, it also records the Compose file it deployed as a baseline, so it can later tell you whether the file has changed since then, and it keeps a short history of the findings it has seen.
## Status
@@ -18,6 +18,18 @@ Every stack resolves to one of four states, shown as a badge at the top of the t
| **Not running** | The stack is defined on disk but no containers are running. |
| **Unreachable** | Docker could not be reached, so drift cannot be assessed right now. |
## Since your last deploy
Below the status badge, a second line compares the Compose file on disk against the version you last deployed through Sencho:
| Signal | Meaning |
|--------|---------|
| **Matches last deploy** | The Compose file is unchanged since you last deployed it. |
| **Source changed** | The Compose file has been edited since the last deploy. If the change affects the model (an image, port, or service), Sencho says so; a comments or formatting only edit is called out separately. |
| **No deploy baseline** | This stack has not been deployed through Sencho yet, so there is nothing to compare against. Deploy it once to start tracking. |
This is independent of the runtime status above: a stack can be **In sync** with its running containers while its file has already **changed** for the next deploy.
## Findings
When a stack is drifted, each reason is listed against the service it affects:
@@ -31,6 +43,12 @@ When a stack is drifted, each reason is listed against the service it affects:
Image references are compared after normalizing the implicit Docker Hub registry and a missing tag to `:latest`, so `nginx` and `docker.io/library/nginx:latest` are treated as the same image. A running container pinned to a digest is compared against the declared tag as written.
## Drift history
Each time you **re-check** a stack, and after every deploy, Sencho records the findings it sees. The **Drift history** list under the findings shows recent entries with when each was first **detected** and, once it clears, when it was **resolved**. This turns a point-in-time check into a short ledger of how a stack has drifted and recovered over time.
Drift that appears or clears is also written to the stack's **Activity** timeline, so **Drift detected** and **Drift resolved** events sit alongside deploys and restarts.
## Accessing the Drift tab
1. Click any stack in the left sidebar to open it.
@@ -52,6 +70,9 @@ On a phone, the same report appears under the **Compose** section of the stack d
A Compose port range such as `8000-8002:8000-8002` is compared conservatively and can read as a ports difference even when the deployment is correct. Sencho errs toward surfacing a possible difference rather than hiding one. The status reflects this as drift you can confirm against the file.
</Accordion>
<Accordion title="The status says 'Unreachable'">
Sencho could not reach Docker on the active node, so it cannot compare the runtime. Confirm the Docker engine is running and the node is online, then use **re-check**. Other stacks on the same node will show the same state until Docker responds.
Sencho could not reach Docker on the active node, so it cannot compare the runtime. Confirm the Docker engine is running and the node is online, then use **re-check**. Other stacks on the same node will show the same state until Docker responds. While Docker is unreachable, Sencho does not change the recorded drift history, so an open finding is never cleared just because the check could not run.
</Accordion>
<Accordion title="The tab says 'No deploy baseline'">
Sencho records a baseline the first time you deploy a stack through it. A stack you imported or have not yet deployed from Sencho has nothing to compare against. Deploy it once from Sencho and the line changes to **Matches last deploy**.
</Accordion>
</AccordionGroup>