Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ All JSON, all under `/api/v1`, all on the loopback listener.
| `GET` | `/api/v1/services` | Every service with its running, merged, and latest-available digests, drift flag, and last receipt. |
| `POST` | `/api/v1/services/{name}/deploy` | `{"digest": "sha256:…"}` → `{"journalId": N}`, `202`. |
| `POST` | `/api/v1/services/{name}/rollback` | `{"digest": "sha256:…"}` (optional) → `{"journalId": N}`, `202`. |
| `POST` | `/api/v1/services/{name}/converge` | No body. Re-applies the merged manifest without moving the pin → `{"journalId": N}`, `202`. |
| `GET` | `/api/v1/services/{name}/history?n=20` | Newest-first receipts. |
| `GET` | `/api/v1/events` | Server-sent `state` events for every transition. |
| `GET` | `/healthz` | `200`, no identity required. |
Expand All @@ -122,14 +123,23 @@ address.
snowdeploy status
snowdeploy deploy <service> [--digest sha256:…]
snowdeploy rollback <service> [--to sha256:…]
snowdeploy converge <service>
snowdeploy history <service> [-n 20]
snowdeploy validate <dir> [--volume-prefixes p1,p2] [--env-file-prefixes p1,p2]
```

`--server` and `--token-file` (or `SNOWDEPLOY_SERVER` and
`SNOWDEPLOY_TOKEN_FILE`) point it at a daemon. `deploy` and `rollback` follow
the event stream and exit non-zero unless the service ends healthy, so they
work in a script.
`SNOWDEPLOY_TOKEN_FILE`) point it at a daemon. `deploy`, `rollback`, and
`converge` follow the event stream and exit non-zero unless the service ends
healthy, so they work in a script.

`converge` exists for the manifest change a deploy cannot carry: an env,
volume, or template edit that merged without moving the image digest. It
re-renders and re-applies the manifest as main has it — no pull request, since
the change already went through review — and, unlike a deploy, it has no
automatic rollback: the previously rendered unit is recorded nowhere, so a
failed probe leaves the run `failed` with instructions rather than silently
restoring anything.

## Security model

Expand Down
5 changes: 4 additions & 1 deletion cmd/snowdeploy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ func (c *client) startDeploy(
JournalID int64 `json:"journalId"`
}
path := fmt.Sprintf("/api/v1/services/%s/%s", url.PathEscape(service), action)
body := map[string]string{"digest": digest}
var body any
if action != "converge" {
body = map[string]string{"digest": digest}
}
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
return 0, err
}
Expand Down
59 changes: 59 additions & 0 deletions cmd/snowdeploy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -145,6 +146,8 @@ type fakeDaemon struct {
deployPath string
authSeen string
rollbackHit bool
convergeHit bool
convergeRaw []byte
journalID int64

states []string
Expand Down Expand Up @@ -218,6 +221,17 @@ func (f *fakeDaemon) handler(t *testing.T) http.Handler {
f.begun(8)
})

mux.HandleFunc("POST /api/v1/services/{name}/converge", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.convergeHit = true
f.convergeRaw = raw
f.deployPath = r.URL.Path
f.mu.Unlock()
writeJSON(t, w, map[string]int64{"journalId": 9})
f.begun(9)
})

mux.HandleFunc("GET /api/v1/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
Expand Down Expand Up @@ -373,6 +387,51 @@ func TestRollbackWithoutTarget(t *testing.T) {
}
}

func TestConvergePostsNoBodyAndFollowsToHealthy(t *testing.T) {
f, url := newFakeDaemon(t, "reconciling", "probing", "healthy")

code, c := runCLI("converge", "web", "--server", url)
if code != 0 {
t.Fatalf("exit = %d, want 0\nstdout: %s\nstderr: %s",
code, c.out.String(), c.err.String())
}

f.mu.Lock()
defer f.mu.Unlock()
if !f.convergeHit {
t.Fatal("converge endpoint not called")
}
if f.deployPath != "/api/v1/services/web/converge" {
t.Errorf("posted to %q", f.deployPath)
}
if len(f.convergeRaw) != 0 {
t.Errorf("converge sent a body it has no use for: %q", f.convergeRaw)
}
for _, want := range []string{"reconciling", "probing", "healthy"} {
if !strings.Contains(c.out.String(), want) {
t.Errorf("output missing state %q:\n%s", want, c.out.String())
}
}
}

func TestConvergeExitsNonZeroOnFailure(t *testing.T) {
_, url := newFakeDaemon(t, "reconciling", "failed")

code, c := runCLI("converge", "web", "--server", url)
if code == 0 {
t.Fatalf("a failed converge exited 0:\n%s", c.out.String())
}
}

func TestConvergeRefusesADigestFlag(t *testing.T) {
_, url := newFakeDaemon(t)

code, _ := runCLI("converge", "web", "--digest", "sha256:aaa", "--server", url)
if code != 2 {
t.Fatalf("converge with --digest = %d, want the usage exit code 2", code)
}
}

func TestHistoryPrintsEntries(t *testing.T) {
_, url := newFakeDaemon(t)
code, c := runCLI("history", "web", "--server", url)
Expand Down
21 changes: 16 additions & 5 deletions cmd/snowdeploy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return followedCommand(ctx, "deploy", rest, stdout, stderr)
case "rollback":
return followedCommand(ctx, "rollback", rest, stdout, stderr)
case "converge":
return followedCommand(ctx, "converge", rest, stdout, stderr)
case "history":
err = cmdHistory(ctx, rest, stdout)
case "help", "-h", "--help":
Expand All @@ -86,10 +88,14 @@ func usage(w io.Writer) {
snowdeploy status
snowdeploy deploy <service> [--digest sha256:...]
snowdeploy rollback <service> [--to sha256:...]
snowdeploy converge <service>
snowdeploy history <service> [-n 20]
snowdeploy validate <dir> [--volume-prefixes p1,p2] [--env-file-prefixes p1,p2]

Connection flags (status, deploy, rollback, history):
converge re-applies the merged manifest without moving the image pin — for an
env, volume, or template edit that merged on its own.

Connection flags (status, deploy, rollback, converge, history):
--server daemon base URL (env `+envServer+`)
--token-file file holding the CLI bearer token (env `+envTokenFile+`)
`)
Expand Down Expand Up @@ -180,11 +186,16 @@ func followedCommand(
fs := newFlagSet(action, stderr)
server, tokenFile := connectionFlags(fs)

digestFlag := "digest"
if action == "rollback" {
digestFlag = "to"
// converge has no digest to accept: it re-applies the pin main already
// holds, so it registers no digest flag at all.
digest := new(string)
if action != "converge" {
digestFlag := "digest"
if action == "rollback" {
digestFlag = "to"
}
digest = fs.String(digestFlag, "", "digest to pin (sha256:...)")
}
digest := fs.String(digestFlag, "", "digest to pin (sha256:...)")
timeout := fs.Duration("timeout", defaultFollowTimeout,
"give up waiting for a terminal state after this long")

Expand Down
133 changes: 133 additions & 0 deletions docs/superpowers/specs/2026-08-27-converge-action-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Converge action

2026-08-27. Motivated by the snowsys Phase 12 budget red test: a manifest
change that only edited `env` (a new `BUDGET_MONTHLY_USD`) merged to main, but
`Engine.start()` refuses any request whose digest equals the manifest pin
(`ErrAlreadyAtDigest`), so the rendered unit on the host kept the old
environment until the next digest change. Observed live 2026-08-27.

## Problem

The engine's only two actions — deploy and rollback — are both "pin a digest".
A manifest edit that changes env, volumes, or the template without moving the
digest has no path to convergence: nothing re-renders the unit from the merged
manifest.

## Approaches considered

1. **Explicit `converge` action (chosen).** A third engine action that applies
the merged manifest as main has it right now: sync, render, write the unit,
restart, probe. No pull request — the change it applies already merged
through the configuration repository's own review. Distinct intent, small
surface, honest journal receipts.
2. **Same-digest deploys fall through to apply when the rendered unit would
differ.** Requires reading the current unit back from the host (a new
`Systemd` surface) and comparing renders, and it muddies the deploy
contract ("pin a digest") with a second meaning. Rejected.
3. **Automatic unit-content drift reconciliation.** A watcher that re-renders
every manifest and applies on difference. A standing-behavior change with
its own failure modes; far beyond what the incident needs. Rejected.

## Design

### Engine

- `journal.ActionConverge = "converge"`.
- `Engine.Converge(ctx, service, actor) (id int64, joined bool, err error)`,
wired through the existing `start()`: the resolve function returns the
current pin, and the `ErrAlreadyAtDigest` guard is skipped for converge
(equality is the point). `setDigest` is skipped; there is no content change
to propose.
- Deduplication: `pendingRun` gains the action. A second converge click joins
the pending converge run; it never joins a deploy that happens to carry the
same digest, because a deploy's apply may predate the manifest edit the
converge was clicked for.
- The run reuses `run()`'s prologue — detected event, sync, per-service lock
(the one-run-per-service queue) — then branches into a converge path that
never touches the merge queue (`mergeMu`): converge opens no pull request,
so it has no open-to-merge span to serialize.
- At the front of the queue the converge **syncs again** and then re-reads the
manifest. The prologue sync predates the queue wait, so the mirror can be
stale by the time the converge's turn comes — or still pinning a digest a
revert since took back. The sync failure wording goes through the same
`syncFailure` path as every other sync, so the credential-bearing transport
error never reaches the journal.
- If interleaved deploys moved the pin while the converge waited, converge
applies the new pin — host matches main is the whole contract — and rewrites
the still-unfinished journal entry's digests to the pin actually applied
(`Journal.SetDigests`, which refuses finished receipts). Receipts stay
immutable; an in-flight entry is not yet a receipt. Without the rewrite, a
healthy converge row carrying the click-time pin would feed
`PreviousHealthyDigest` a digest that was never verified by this run.
- States: detected → reconciling → probing → healthy/failed. The UI's
progress rail already renders runs that skip pr-open/checks/merged (the
same-digest deploy path produces the same shape).
- `applyMerged` is split: the template-fetch-and-apply tail becomes
`applyManifest(ctx, req, m)`, shared by the deploy path and converge.
Converge does not use `applyMerged` itself — its digest assertion is against
the Begin-time pin, which converge deliberately does not hold fixed.

### No automatic rollback

Deploy can roll back because it holds the click-time manifest (`originalRaw`).
For converge, the click-time manifest *is* the merged manifest being applied;
the previously rendered unit's content is recorded nowhere. A converge whose
probe fails finishes `failed`, with a detail saying exactly that: there is no
previous unit to restore — fix the manifest and converge again, or deploy a
known-good digest. `LastHealthyDigest` is untouched by a failed run, so a
subsequent bare rollback still resolves sensibly.

A healthy converge journals `new_digest` = the current pin with state healthy,
which is true and keeps the healthy-digest queries correct.

### API

`POST /api/v1/services/{name}/converge`, authenticated like deploy and
rollback, no request body (any body is ignored — there is no digest to accept,
and accepting one would invite the same-digest confusion converge exists to
end). Responds 202 with `{journalId, joined}` via the shared `dispatch`.

### CLI

`snowdeploy converge <service>` — the followed-command flow (subscribe to
events first, POST, stream to a terminal state, exit nonzero unless healthy),
minus the digest flag and the latest-digest resolution. `client.startDeploy`
sends no body for converge.

### Out of scope

- A converge button in the web UI. The UI renders converge runs arriving on
the event stream (action rides on every event and history row); adding a
button is a separate, purely frontend change.
- Skipping the restart when the rendered unit is byte-identical to what is on
the host. Converge is a deliberate operator action; an occasional no-op
restart, gated by the probe, is acceptable. Revisit only if converge grows
an automated caller.

## Tests

Engine (`internal/deploy/engine_test.go` harness):

- Converge applies the current manifest with no PR opened, states
detected→reconciling→probing→healthy, journal receipt action=converge.
- Converge applies the manifest as it stands at the front of the queue after
an interleaved deploy moved it, and its receipt records the applied pin.
- Converge syncs the mirror at the front of the queue: an edit that lands on
main after the prologue sync is still applied (the fake repo keeps main and
the fetched mirror apart so staleness is visible at all).
- A front-of-queue sync failure fails the run in the repository's own words,
never the transport error.
- Converge probe failure finishes failed, applies nothing else, opens no PR,
and leaves LastHealthyDigest unchanged.
- A duplicate converge click joins the pending converge run.
- A converge does not join a pending deploy carrying the same digest.
- Sync failure fails the run without leaking the transport error (existing
wording path).

API (`internal/api/api_test.go`):

- POST converge dispatches to the engine and returns 202 with the journal id;
unknown service 404s; unauthenticated 401s (existing middleware table).

CLI (`cmd/snowdeploy/client_test.go` / main): converge posts to the right
path with no body and follows to a terminal state.
15 changes: 15 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
type Engine interface {
Deploy(ctx context.Context, service, digest, actor string) (int64, bool, error)
Rollback(ctx context.Context, service, toDigest, actor string) (int64, bool, error)
Converge(ctx context.Context, service, actor string) (int64, bool, error)
Drift(ctx context.Context) (map[string]string, error)
QueueDepth() int64
}
Expand Down Expand Up @@ -185,6 +186,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/services/{name}/revisions", s.authed(s.handleRevisions))
mux.Handle("POST /api/v1/services/{name}/deploy", s.authed(s.handleDeploy))
mux.Handle("POST /api/v1/services/{name}/rollback", s.authed(s.handleRollback))
mux.Handle("POST /api/v1/services/{name}/converge", s.authed(s.handleConverge))
mux.Handle("GET /api/v1/events", s.authed(func(w http.ResponseWriter, r *http.Request, _ string) {
s.handleEvents(w, r)
}))
Expand Down Expand Up @@ -424,6 +426,19 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request, actor st
})
}

// handleConverge re-applies the merged manifest as it stands. It reads no
// body: converge has no digest to accept, and accepting one would reintroduce
// the same-digest ambiguity the action exists to end.
func (s *Server) handleConverge(w http.ResponseWriter, r *http.Request, actor string) {
name, ok := s.service(w, r)
if !ok {
return
}
s.dispatch(w, r, func() (int64, bool, error) {
return s.opts.Engine.Converge(r.Context(), name, actor)
})
}

// accepted is the body of a 202: the journal id to follow, and whether the
// click joined a run already carrying the same digest instead of starting one.
type accepted struct {
Expand Down
Loading