carlos / platform Public

console: one promote per channel at a time, fleet errors surfaced, unverifiable pointers named

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
Paul Campbell pushed by paul@keymail.dev c6447b0f4453376a3c1a2c38d9c0cf32900fbd60 parent 6017307
3 files changed, +406 −51
  • internal/console/apisystem.go +224 −43
  • internal/console/apisystem_test.go +172 −1
  • internal/console/server.go +10 −7
diff --git a/internal/console/apisystem.go b/internal/console/apisystem.go
index a99cc3f89..e7befef93 100644
--- a/internal/console/apisystem.go
+++ b/internal/console/apisystem.go
@@ -65,6 +65,20 @@ func (j *systemJob) finish(err error) {
j.mu.Unlock()
}
+func (j *systemJob) done() bool {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ return j.Done
+}
+
+// snapshot copies the three published fields under the lock, so the caller
+// can serialize them without holding it across a socket write.
+func (j *systemJob) snapshot() (steps []string, done bool, errMsg string) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ return append([]string(nil), j.Steps...), j.Done, j.Err
+}
+
// systemUpload is the one parked local build: `carlos system update --file`
// PUTs the bytes, then names the handle on the update request. There is no
// timestamp on it and no TTL sweep, because there is only ever ONE — the next
@@ -105,6 +119,18 @@ func (s *Server) apiSystemOwner(w http.ResponseWriter, r *http.Request) (string,
// subject is the deployment itself — so there is no *Account to pass and a
// nil would panic. The ops account is the right home for the row: its owners
// are the only callers that ever reach these doors.
+//
+// KNOW WHAT THIS IS AND IS NOT. These rows are keyed (OpsAccountID, app
+// "platform"), and NO SURFACE READS THEM TODAY: Store.Events is asked for a
+// real app name by the activity tab, and "platform" is not one. That is
+// deliberate, not an oversight — the row is the DURABLE record of who moved
+// the platform pointer and when, written where a reader can be added later
+// (an operator activity view, `carlos system log`) without a migration.
+// Until such a reader exists this is a write-only table, and the answer to
+// "who promoted this?" comes from the pointer's own Audit list, which
+// PromoteSystem signs into the bucket. Do not delete these rows on the
+// grounds that nothing reads them; do not present them as an audit trail an
+// operator can consult, because they cannot.
func (s *Server) auditSystem(addr, action, detail string) {
if err := s.store.RecordEvent(OpsAccountID, "platform", addr, action, detail, time.Now()); err != nil {
log.Printf("console: audit %s platform: %v", action, err)
@@ -126,36 +152,85 @@ func (s *Server) systemBucket(w http.ResponseWriter) bool {
return true
}
+// systemFeed is the release feed this console fetches from: cfg.SystemFeed
+// when it is configured, the vendor feed otherwise.
+//
+// "Configured" means ALL THREE strings, not just one. A feed's three fields
+// are a single fact — where to ask, where to download, and the key the
+// checksum list must verify under — and any subset of them is not a feed at
+// all: a DownloadBase with the vendor's PubKey would fetch one publisher's
+// binaries and check them against another's signature, which cannot ever
+// succeed and would read as "the release is corrupt" rather than as "this
+// console is misconfigured". So a partial override is refused OUT LOUD and
+// falls back, rather than being half-honoured.
func (s *Server) systemFeed() publicrelease.Feed {
- if s.cfg.SystemFeed.DownloadBase == "" {
+ f := s.cfg.SystemFeed
+ set := 0
+ for _, v := range []string{f.LatestURL, f.DownloadBase, f.PubKey} {
+ if v != "" {
+ set++
+ }
+ }
+ switch set {
+ case 0:
+ return publicrelease.Default()
+ case 3:
+ return f
+ default:
+ log.Printf("console: SystemFeed is only partly configured (latest_url=%t download_base=%t pub_key=%t) — "+
+ "a feed is all three or none, so this console is using the vendor feed instead",
+ f.LatestURL != "", f.DownloadBase != "", f.PubKey != "")
return publicrelease.Default()
}
- return s.cfg.SystemFeed
}
// systemBoxes reads every heartbeat: the platform prefix and each fleet's.
-func (s *Server) systemBoxes(ctx context.Context) ([]status.Box, error) {
+//
+// strict is what a fleet the console cannot enumerate MEANS, and the two
+// callers need opposite answers. The promote (systemPlatforms) decides from
+// this list which platforms to mirror, and a fleet it cannot see is a fleet
+// whose architecture it would silently omit — so strict refuses rather than
+// guesses. The status read only DESCRIBES, and an operator asking what is
+// running is better served by the boxes that did answer plus an honest
+// "partial" flag than by a blank page. The second return is that flag: true
+// means some fleet's boxes are missing from the slice.
+func (s *Server) systemBoxes(ctx context.Context, strict bool) ([]status.Box, bool, error) {
boxes, err := status.LoadBoxes(ctx, s.cfg.Releases)
if err != nil {
- return nil, err
+ return nil, false, err
}
reg, err := s.openPassRegistry()
if err != nil {
- return boxes, nil // no registry: platform boxes only
+ return s.systemFleetsUnreadable(boxes, strict, err)
}
fleets, err := reg.ListFleets()
if err != nil {
- return boxes, nil
+ return s.systemFleetsUnreadable(boxes, strict, err)
}
+ partial := false
for _, f := range fleets {
fb, err := status.LoadBoxes(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+f.Name+"/"))
if err != nil {
+ if strict {
+ return nil, false, fmt.Errorf("fleet %s: cannot read its box heartbeats, refusing to guess which platforms to mirror: %w", f.Name, err)
+ }
log.Printf("console: api system: fleet %s boxes: %v", f.Name, err)
+ partial = true
continue
}
boxes = append(boxes, fb...)
}
- return boxes, nil
+ return boxes, partial, nil
+}
+
+// systemFleetsUnreadable is the shared answer to "this console cannot say
+// what fleets exist": a refusal under strict, a logged degradation otherwise.
+func (s *Server) systemFleetsUnreadable(boxes []status.Box, strict bool, err error) ([]status.Box, bool, error) {
+ if strict {
+ return nil, false, fmt.Errorf("cannot list fleets, refusing to guess which platforms to mirror: %w", err)
+ }
+ log.Printf("console: api system: list fleets: %v — reporting the platform's own boxes only", err)
+ return boxes, true, nil
}
type systemUpdateRequest struct {
@@ -206,31 +281,67 @@ func (s *Server) handleAPISystemUpdate(w http.ResponseWriter, r *http.Request) {
}
spread = d
}
- id, job := s.newSystemJob()
+ id, job, busy := s.startSystemJob(in.Component, in.Channel)
+ if busy != "" {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": "a promote of " + in.Component + "/" + in.Channel + " is already running; watch it or wait for it to finish",
+ "job": busy,
+ })
+ return
+ }
go s.runSystemUpdate(context.WithoutCancel(r.Context()), job, addr, in, spread)
writeJSON(w, http.StatusOK, map[string]string{"job": id})
}
-// newSystemJob mints a job id and its record, sweeping jobs older than the
-// TTL on the way past — the registry is process memory, and an operator who
-// promoted an hour ago is not still polling.
-func (s *Server) newSystemJob() (string, *systemJob) {
- b := make([]byte, 8)
- rand.Read(b)
- id := hex.EncodeToString(b)
- job := &systemJob{at: time.Now()}
+// startSystemJob claims the ONE in-flight promote slot for a channel and
+// mints the job that holds it, or reports the id of the job already there.
+//
+// One at a time per channel, and the reason is memory as much as
+// correctness: a promote holds a whole release asset in the heap while it
+// verifies and mirrors it, so N concurrent promotes are N copies, and the
+// door is reachable by an ops owner with a script. Serializing per channel
+// also removes the CAS race two promotes of different versions into the same
+// channel would otherwise run — one of them would lose at PromoteSystem's
+// Put and report a conflict the operator did not cause.
+//
+// It sweeps FINISHED jobs past the TTL on the way past. Keyed on Done rather
+// than age: a promote that has legitimately been running for over an hour
+// (a slow feed, a large asset) must not have its record swept out from under
+// the operator watching it, which is exactly the moment they need it.
+func (s *Server) startSystemJob(component, channel string) (id string, job *systemJob, busy string) {
s.systemMu.Lock()
defer s.systemMu.Unlock()
+ if running, ok := s.systemRunning[component+"/"+channel]; ok {
+ return "", nil, running
+ }
+ b := make([]byte, 8)
+ rand.Read(b)
+ id = hex.EncodeToString(b)
+ job = &systemJob{at: time.Now()}
if s.systemJobs == nil {
s.systemJobs = map[string]*systemJob{}
}
+ if s.systemRunning == nil {
+ s.systemRunning = map[string]string{}
+ }
for k, j := range s.systemJobs {
- if time.Since(j.at) > systemJobTTL {
+ if j.done() && time.Since(j.at) > systemJobTTL {
delete(s.systemJobs, k)
}
}
s.systemJobs[id] = job
- return id, job
+ s.systemRunning[component+"/"+channel] = id
+ return id, job, ""
+}
+
+// releaseSystemChannel hands the slot back. It is called BEFORE the job is
+// marked done, so an operator who polls a job to completion and immediately
+// promotes again is never told the channel is busy by the job they just
+// watched finish.
+func (s *Server) releaseSystemChannel(component, channel string) {
+ s.systemMu.Lock()
+ delete(s.systemRunning, component+"/"+channel)
+ s.systemMu.Unlock()
}
// runSystemUpdate is the promote: resolve, ingest (or take the upload),
@@ -298,13 +409,18 @@ func (s *Server) runSystemUpdate(ctx context.Context, job *systemJob, actor stri
job.step("serial bumped: boxes on %s start their spread", in.Channel)
return nil
}()
+ // Hand the channel back BEFORE marking the job done — see
+ // releaseSystemChannel.
+ s.releaseSystemChannel(in.Component, in.Channel)
job.finish(err)
}
// systemPlatforms: the distinct platforms the fleet reports; linux-arm64
-// when nothing has reported the field yet.
+// when nothing has reported the field yet. STRICT — a fleet this console
+// cannot enumerate stops the promote, because a platform silently left out
+// of the mirror is a fleet of boxes that will never adopt.
func (s *Server) systemPlatforms(ctx context.Context) ([]string, error) {
- boxes, err := s.systemBoxes(ctx)
+ boxes, _, err := s.systemBoxes(ctx, true)
if err != nil {
return nil, err
}
@@ -329,29 +445,70 @@ func (s *Server) systemPlatforms(ctx context.Context) ([]string, error) {
// different sha → refuse (versions are immutable, spec §3).
func (s *Server) mirrorSystemArtifact(ctx context.Context, job *systemJob, version, platform string, data []byte, sha string) error {
key := release.SystemArtifactKey(version, platform)
- existing, _, err := s.cfg.Releases.Get(ctx, key)
- switch {
- case err == nil:
- sum := sha256.Sum256(existing)
- if hex.EncodeToString(sum[:]) == sha {
- job.step("%s already mirrored", key)
- return nil
- }
- return fmt.Errorf("%s exists with different bytes — versions are immutable; pick a new version", key)
- case errors.Is(err, os.ErrNotExist):
- default:
+ switch already, err := s.systemArtifactMatches(ctx, key, sha); {
+ case err != nil:
return err
+ case already:
+ job.step("%s already mirrored", key)
+ return nil
}
// Create-only: the empty ifMatch IS the CAS, so a concurrent promote of
// the same version loses rather than overwrites a byte a box may already
- // have fetched.
+ // have fetched. Losing that race is not a failure — the two channels'
+ // promotes of one version want the same bytes at the same key — so
+ // re-read and let the comparison above decide: identical is done, and
+ // different is the immutability refusal it always was.
if _, err := s.cfg.Releases.Put(ctx, key, data, ""); err != nil {
- return err
+ if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ // The re-read answers every half: identical bytes means the other
+ // promote wrote what this one would have, different bytes comes back
+ // as the immutability refusal, and absent means the key was taken and
+ // released while this promote looked away.
+ already, cerr := s.systemArtifactMatches(ctx, key, sha)
+ if cerr != nil {
+ return cerr
+ }
+ if !already {
+ return fmt.Errorf("%s: another writer holds the key but it is not there now — re-run, every step is idempotent", key)
+ }
+ job.step("%s mirrored concurrently", key)
+ return nil
}
job.step("mirrored %s", key)
return nil
}
+// systemArtifactMatches answers "is sha already the object at key?" —
+// absent is (false, nil), present and different is the immutability refusal,
+// present and equal is (true, nil).
+//
+// The comparison STREAMS the object already in the bucket through a hasher
+// rather than Getting it. A promote already holds one whole asset in the
+// heap (the verified download, waiting to be Put); buffering the existing
+// object beside it would make the peak two copies of a platform binary per
+// in-flight promote, for no purpose beyond computing one sha256.
+func (s *Server) systemArtifactMatches(ctx context.Context, key, sha string) (bool, error) {
+ rc, _, err := release.OpenObject(ctx, s.cfg.Releases, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ return false, nil
+ case err != nil:
+ return false, err
+ }
+ h := sha256.New()
+ _, err = io.Copy(h, rc)
+ rc.Close()
+ if err != nil {
+ return false, fmt.Errorf("%s: reading the mirrored object to compare it: %w", key, err)
+ }
+ if hex.EncodeToString(h.Sum(nil)) == sha {
+ return true, nil
+ }
+ return false, fmt.Errorf("%s exists with different bytes — versions are immutable; pick a new version", key)
+}
+
func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) {
if _, ok := s.apiSystemOwner(w, r); !ok {
return
@@ -363,9 +520,8 @@ func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, "no such job — the console may have restarted; re-run, every step is idempotent")
return
}
- job.mu.Lock()
- defer job.mu.Unlock()
- writeJSON(w, http.StatusOK, map[string]any{"steps": append([]string(nil), job.Steps...), "done": job.Done, "error": job.Err})
+ steps, done, errMsg := job.snapshot()
+ writeJSON(w, http.StatusOK, map[string]any{"steps": steps, "done": done, "error": errMsg})
}
// handleAPISystemUpload parks one local build in memory for the job that
@@ -457,9 +613,20 @@ func (s *Server) handleAPISystemRollback(w http.ResponseWriter, r *http.Request)
if !readJSONStrictLimit(w, r, &in, 1<<10) {
return
}
+ // The same two checks the update door makes, and for the same reason:
+ // both arguments become part of an object key, and a door that validates
+ // what it writes but not what it rewinds is a door with two standards.
if in.Component == "" {
in.Component = release.SystemComponentRouter
}
+ if in.Component != release.SystemComponentRouter {
+ jsonError(w, http.StatusBadRequest, "unknown component "+in.Component)
+ return
+ }
+ if in.Channel != release.RungCanary && in.Channel != release.RungStable {
+ jsonError(w, http.StatusBadRequest, "channel must be canary or stable")
+ return
+ }
p, err := release.RollbackSystem(r.Context(), s.cfg.Releases, release.RootSigner(s.cfg.Key), in.Component, in.Channel, addr)
switch {
case errors.Is(err, release.ErrNoSystemHistory):
@@ -491,15 +658,25 @@ func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) {
targets := map[string]string{}
for _, ch := range []string{release.RungCanary, release.RungStable} {
p, _, err := release.GetSystemPointer(ctx, s.cfg.Releases, s.cfg.ReleasePub, release.SystemComponentRouter, ch)
- if err != nil {
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ // null means exactly one thing: this channel has never been
+ // promoted. It must not also mean "there IS a pointer and this
+ // console cannot verify it" — a tampered or wrongly-signed
+ // pointer reading as "never promoted" is the one failure an
+ // operator most needs to see, shown as the one thing they would
+ // ignore.
pointers[ch] = nil
- continue
+ case err != nil:
+ log.Printf("console: api system status: %s pointer: %v", ch, err)
+ pointers[ch] = map[string]any{"error": err.Error()}
+ default:
+ pointers[ch] = map[string]any{"version": p.Current.Version, "promoted_at": p.PromotedAt, "spread": p.Spread, "source": p.Current.Source,
+ "history": len(p.History)}
+ targets[ch] = p.Current.Version
}
- pointers[ch] = map[string]any{"version": p.Current.Version, "promoted_at": p.PromotedAt, "spread": p.Spread, "source": p.Current.Source,
- "history": len(p.History)}
- targets[ch] = p.Current.Version
}
- boxes, err := s.systemBoxes(ctx)
+ boxes, partial, err := s.systemBoxes(ctx, false)
if err != nil {
jsonError(w, http.StatusBadGateway, "box heartbeats unreadable: "+err.Error())
return
@@ -523,5 +700,9 @@ func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool { return rows[i]["label"].(string) < rows[j]["label"].(string) })
- writeJSON(w, http.StatusOK, map[string]any{"pointers": pointers, "boxes": rows})
+ // partial says the box list is INCOMPLETE — some fleet's heartbeats
+ // could not be read. Always present, so a reader never has to tell a
+ // missing key from a false one, and so "no boxes on that fleet" and
+ // "could not ask that fleet" are different answers here.
+ writeJSON(w, http.StatusOK, map[string]any{"pointers": pointers, "boxes": rows, "partial": partial})
}
diff --git a/internal/console/apisystem_test.go b/internal/console/apisystem_test.go
index 308de5e1e..c78e1a90b 100644
--- a/internal/console/apisystem_test.go
+++ b/internal/console/apisystem_test.go
@@ -12,7 +12,9 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
+ "path/filepath"
"strings"
+ "sync"
"testing"
"time"
@@ -24,6 +26,14 @@ import (
// systemFeed serves a signed fake release from an httptest server and
// points the fixture's console at it.
func systemFeed(t *testing.T, fx *consoleFixture, assets map[string][]byte) {
+ t.Helper()
+ systemFeedHeld(t, fx, assets, nil)
+}
+
+// systemFeedHeld is systemFeed with a gate on /latest.txt: while hold is open
+// the feed never answers, so a promote that has to resolve "latest" stays in
+// flight for as long as the test needs it to.
+func systemFeedHeld(t *testing.T, fx *consoleFixture, assets map[string][]byte, hold <-chan struct{}) {
t.Helper()
pub, priv, _ := ed25519.GenerateKey(nil)
var sums strings.Builder
@@ -33,7 +43,12 @@ func systemFeed(t *testing.T, fx *consoleFixture, assets map[string][]byte) {
}
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(sums.String())))
mux := http.NewServeMux()
- mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "v0.18.0") })
+ mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) {
+ if hold != nil {
+ <-hold
+ }
+ fmt.Fprintln(w, "v0.18.0")
+ })
mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) })
mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, sig) })
for name, body := range assets {
@@ -187,6 +202,129 @@ func TestSystemUpdateMirrorsSignsAndBumps(t *testing.T) {
}
}
+// TestSystemUpdateIsOnePromotePerChannel: a promote holds a whole platform
+// binary in the heap, and the door is reachable by a script. One at a time
+// per channel, and the second caller is told which job to watch rather than
+// being handed a parallel one.
+func TestSystemUpdateIsOnePromotePerChannel(t *testing.T) {
+ fx := newConsoleFixture(t)
+ hold := make(chan struct{})
+ var once sync.Once
+ unhold := func() { once.Do(func() { close(hold) }) }
+ defer unhold() // the feed server's Close waits on in-flight requests
+ systemFeedHeld(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}, hold)
+ seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-one")
+
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("first update = %d %v", st, out)
+ }
+ first := str(t, out, "job")
+
+ // The slot is claimed by the handler, before the goroutine runs, so this
+ // needs no sleep to be deterministic.
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
+ if st != http.StatusConflict {
+ t.Fatalf("second update on a busy channel = %d %v, want 409", st, out)
+ }
+ if str(t, out, "job") != first {
+ t.Errorf("409 named job %q, want the running one %q", str(t, out, "job"), first)
+ }
+ // A DIFFERENT channel is not blocked by canary's promote.
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("update on the other channel = %d %v, want 200", st, out)
+ }
+ stable := str(t, out, "job")
+
+ unhold()
+ // Both jobs mirror the SAME artifact key concurrently — one wins the
+ // create-only Put and the other must read it back and agree, not report
+ // a conflict the operator did not cause.
+ for _, id := range []string{first, stable} {
+ if job := waitJob(t, fx, owner, id); job["error"] != "" {
+ t.Fatalf("job %s: %v", id, job)
+ }
+ }
+ // The slot is handed back before the job is marked done, so an operator
+ // who watched it finish can promote again immediately.
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("update after the first finished = %d %v, want 200", st, out)
+ }
+ if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
+ t.Fatalf("re-promote after the slot was handed back: %v", job)
+ }
+}
+
+// TestSystemUpdateRefusesWhenFleetsAreUnreadable: which platforms to mirror
+// is derived from what the fleet reports, so a fleet this console cannot
+// enumerate is not a fleet to quietly leave out — those boxes would never
+// find a binary to adopt. The promote refuses and says why.
+func TestSystemUpdateRefusesWhenFleetsAreUnreadable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")})
+ seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-fleeterr")
+ fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "gone.db")
+
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("update = %d %v", st, out)
+ }
+ job := waitJob(t, fx, owner, str(t, out, "job"))
+ e, _ := job["error"].(string)
+ if !strings.Contains(e, "cannot list fleets") {
+ t.Fatalf("job error = %q, want a refusal naming the fleet listing", e)
+ }
+ if _, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary"); err == nil {
+ t.Error("the pointer moved even though the platform set was unknown")
+ }
+
+ // The STATUS read degrades instead: the boxes that did answer, plus the
+ // flag that says the list is short.
+ st, out = getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
+ if st != http.StatusOK {
+ t.Fatalf("status = %d %v", st, out)
+ }
+ if out["partial"] != true {
+ t.Errorf("status partial = %v, want true when a fleet could not be listed", out["partial"])
+ }
+ if boxes, _ := out["boxes"].([]any); len(boxes) != 1 {
+ t.Errorf("status boxes = %v, want the platform's own box still shown", out["boxes"])
+ }
+}
+
+// TestSystemStatusNamesAnUnverifiablePointer: null means "never promoted"
+// and nothing else. A pointer that exists but does not verify is the one
+// thing an operator most needs to see, so it must not render as the one
+// thing they would scroll past.
+func TestSystemStatusNamesAnUnverifiablePointer(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-tampered")
+ key := release.SystemPointerKey("router", "canary")
+ if _, err := fx.rel.Put(context.Background(), key, []byte(`{"payload":"bm90LWEtcG9pbnRlcg==","sig":"AAAA"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
+ if st != http.StatusOK {
+ t.Fatalf("status = %d %v", st, out)
+ }
+ p, _ := out["pointers"].(map[string]any)
+ canary, ok := p["canary"].(map[string]any)
+ if !ok {
+ t.Fatalf("canary pointer = %v, want an object carrying the error (null would read as never promoted)", p["canary"])
+ }
+ if e, _ := canary["error"].(string); e == "" {
+ t.Errorf("canary pointer = %v, want an error naming the refusal", canary)
+ }
+ // stable really has never been promoted, and that IS null.
+ if p["stable"] != nil {
+ t.Errorf("stable pointer = %v, want null", p["stable"])
+ }
+}
+
func TestSystemUpdateFromAnUpload(t *testing.T) {
fx := newConsoleFixture(t)
owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-upload")
@@ -270,4 +408,37 @@ func TestSystemStatusAndRollback(t *testing.T) {
if st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusConflict {
t.Fatalf("second rollback = %d %v (nothing left in history)", st, out)
}
+ // Rollback validates what it is asked to rewind, exactly as update
+ // validates what it is asked to write — both arguments become part of an
+ // object key.
+ for _, body := range []map[string]string{{"channel": "edge"}, {"channel": ""}, {"channel": "canary", "component": "../etc"}} {
+ if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", body, owner); st != http.StatusBadRequest {
+ t.Errorf("rollback %v = %d %v, want 400", body, st, out)
+ }
+ }
+}
+
+// TestSystemFeedIsAllThreeFieldsOrNone: a half-configured feed would fetch
+// one publisher's binaries and check them against another's key, which fails
+// as "the release is corrupt" rather than as "this console is misconfigured".
+func TestSystemFeedIsAllThreeFieldsOrNone(t *testing.T) {
+ fx := newConsoleFixture(t)
+ def := publicrelease.Default()
+ full := publicrelease.Feed{LatestURL: "https://x/latest.txt", DownloadBase: "https://x/dl", PubKey: "k"}
+ for _, c := range []struct {
+ name string
+ cfg publicrelease.Feed
+ want publicrelease.Feed
+ }{
+ {"unset", publicrelease.Feed{}, def},
+ {"all three", full, full},
+ {"base only", publicrelease.Feed{DownloadBase: full.DownloadBase}, def},
+ {"base and key, no latest", publicrelease.Feed{DownloadBase: full.DownloadBase, PubKey: full.PubKey}, def},
+ {"key only", publicrelease.Feed{PubKey: full.PubKey}, def},
+ } {
+ fx.srv.cfg.SystemFeed = c.cfg
+ if got := fx.srv.systemFeed(); got != c.want {
+ t.Errorf("%s: systemFeed() = %+v, want %+v", c.name, got, c.want)
+ }
+ }
}
diff --git a/internal/console/server.go b/internal/console/server.go
index fc37ef855..b4379e379 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -731,15 +731,18 @@ type Server struct {
// TTL can be proven without a bucket, the same way storeSizer and
// ceCosts stand in for AWS above.
foldEdgeIPs func(ctx context.Context) []string
- // The `carlos system` promote's two in-memory registries (apisystem.go):
- // systemJobs is the running/finished update jobs an operator polls, and
- // systemUploads is the single upload slot a `--file` promote parks its
- // bytes in between the PUT that carries them and the job that consumes
- // them. Both are process-lifetime and deliberately NOT durable: a
- // console restart loses a job's step list, and re-running the promote is
- // the recovery, because every step of it is idempotent.
+ // The `carlos system` promote's in-memory registries (apisystem.go):
+ // systemJobs is the running/finished update jobs an operator polls,
+ // systemRunning is "<component>/<channel>" -> the id of the job holding
+ // that channel's one in-flight slot, and systemUploads is the single
+ // upload slot a `--file` promote parks its bytes in between the PUT that
+ // carries them and the job that consumes them. All three are
+ // process-lifetime and deliberately NOT durable: a console restart loses
+ // a job's step list, and re-running the promote is the recovery, because
+ // every step of it is idempotent.
systemMu sync.Mutex
systemJobs map[string]*systemJob
+ systemRunning map[string]string
systemUploads map[string]systemUpload
}