console: system operators can roll signed platform releases (#176)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Did platform#176 get done? [asked whether to claim #176 and start] Yes. [asked whether system operators may use --file] Agreed Catch up with main, go ahead.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Adds CARLOS_SYSTEM_OPERATORS. A listed person can run carlos system status, update from the signed release feed, watch the job, and rollback without owning the ops account. Uploading a local build (PUT system/upload, or an update naming an upload handle) stays the ops owner's and answers a 403 to an operator; the handle check runs before a job starts, so an operator cannot consume an owner's parked build. Everyone else still gets the bare 404. Copy (403, usage heading, --file help, cli.md paragraph) approved unchanged in copy review.
Agent notes
apiSystemOwner is now apiSystemRoller (addr, owner, ok). A Role() store error no longer ends the check: it is logged and the operator list is still consulted, but an error never admits anyone. The list match is relayOperator's (entry trimmed+lowercased, address compared exactly). Routes keep svcrole.Set{}, so service credentials stay shut out by closedDoors. Tito delivery is #201 (its console is on cf02a4d5-tito, 314 commits behind). Locally, TestControlPlane* fail only from Go VCS stamping in this nested worktree; they pass with -buildvcs=false.
8 files changed,
+275
−32
cmd/console/main.go+22 −1docs/site/cli.md+6 −2internal/console/apirelay.go+2 −2internal/console/apisystemoperators_test.go+155 −0internal/console/apisystemupdate.go+66 −21internal/console/authcli.go+4 −3internal/console/server.go+16 −0system.go+4 −3
CI passed — run details
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 4a3b04776..b83594daf 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -336,7 +336,10 @@ func serve() { |
| // it leaves the relay doors refusing and no background loop running. |
| RelayRegions: relayRegions(), |
| RelayOperators: relayOperators(), |
| - PlatformDomain: platformDomain(), |
| + // Who may roll the platform's own binary besides the ops account's |
| + // owners (discussion #176). Unset is the owner-only console. |
| + SystemOperators: systemOperators(), |
| + PlatformDomain: platformDomain(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -661,6 +664,24 @@ func relayOperators() []string { |
| return out |
| } |
| +// systemOperators parses CARLOS_SYSTEM_OPERATORS (comma-separated addresses): |
| +// who may run `carlos system status`, `update` from the signed release feed |
| +// and `rollback` without owning the ops account (discussion #176). |
| +// |
| +// The same shape as relayOperators, and unset is the same direction: the |
| +// doors stay the owners' alone, exactly as before the list existed. Its own |
| +// list rather than CARLOS_STORE_OPERATORS, because that one reads tenant bytes |
| +// through store grants and scanning and rolling releases should not. |
| +func systemOperators() []string { |
| + var out []string |
| + for _, a := range strings.Split(os.Getenv("CARLOS_SYSTEM_OPERATORS"), ",") { |
| + if a = strings.TrimSpace(a); a != "" { |
| + out = append(out, a) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // platformDomain reads CARLOS_PLATFORM_DOMAIN — the domain the relay's own |
| // hostnames hang off (relay.<region>.<domain>), which an enabled app is |
| // handed as its ICE server URLs. |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 6a75143cc..25ee987ec 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1585,8 +1585,12 @@ and rolls back to the previous binary if the new one does not come up and stay |
| up. `status` shows each box's platform, channel, running build, the channel's |
| target, and the last apply outcome with its reason. `rollback` points a channel |
| back at the release before this one, and every box on it rolls back the same |
| -way it rolled forward. All three talk to your console; you need to be an owner |
| -of the ops account. |
| +way it rolled forward. All three talk to your console. You need to be an owner |
| +of the ops account, or on the console's `CARLOS_SYSTEM_OPERATORS` list. That |
| +list is for people who roll releases without owning the account. It covers |
| +`status`, `update` from a public release, and `rollback`, but not `--file`: the |
| +console signs an uploaded build with the deployment key and every box runs it, |
| +so only an owner can ship one. |
| ```sh |
| carlos system update --channel canary |
diff --git a/internal/console/apirelay.go b/internal/console/apirelay.go| index b2d40fbb0..b0657e949 100644 |
| --- a/internal/console/apirelay.go |
| +++ b/internal/console/apirelay.go |
| @@ -20,7 +20,7 @@ |
| // named, so it teaches them only what THEY lack. rotate proves no membership |
| // at all before answering, so a 403 here would confirm to any valid token |
| // that this deployment runs a relay a caller might rotate — the same |
| -// reasoning apiSystemOwner's family already applies to `carlos system`. |
| +// reasoning apiSystemRoller's family already applies to `carlos system`. |
| // |
| // DELIVERY READS THE PER-ROUTE CONFIG-ACK. Spec §5 describes five delivery |
| // states a route can be in; most of them come from a per-route config-ack |
| @@ -688,7 +688,7 @@ func (s *Server) handleAPIRelayAdopted(w http.ResponseWriter, r *http.Request) { |
| // deployment-wide authority (RelayOperators' own doc comment, server.go) with |
| // no account or app to check membership against — POST /api/cli/relay/rotate |
| // carries neither segment, the same shape `carlos system`'s doors answer for |
| -// (apiSystemOwner, apisystemupdate.go; apiSystemOperator, apisystem.go). |
| +// (apiSystemRoller, apisystemupdate.go; apiSystemOperator, apisystem.go). |
| // |
| // Unlike those two — and unlike the store's grant door, which gives a |
| // member-but-not-operator caller a 403 (apistore.go's storeOperator) — a |
diff --git a/internal/console/apisystemoperators_test.go b/internal/console/apisystemoperators_test.go| new file mode 100644 |
| index 000000000..f36b3c1f1 |
| --- /dev/null |
| +++ b/internal/console/apisystemoperators_test.go |
| @@ -0,0 +1,155 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "mime/multipart" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// uploadBuild parks a local build through the upload door and returns the |
| +// status and, on a 200, the handle. |
| +func uploadBuild(t *testing.T, fx *consoleFixture, token string) (int, string) { |
| + t.Helper() |
| + var buf bytes.Buffer |
| + mw := multipart.NewWriter(&buf) |
| + meta, _ := mw.CreateFormField("meta") |
| + meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`)) |
| + file, _ := mw.CreateFormFile("file", "carlos-linux-arm64") |
| + file.Write([]byte("ELF local")) |
| + mw.Close() |
| + req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf) |
| + req.Header.Set("Content-Type", mw.FormDataContentType()) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return res.StatusCode, "" |
| + } |
| + var up map[string]any |
| + if err := json.NewDecoder(res.Body).Decode(&up); err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, str(t, up, "upload") |
| +} |
| + |
| +// TestSystemOperatorRollsSignedReleases: an address on SystemOperators who |
| +// owns nothing in the ops account can do the whole of a roll — status, an |
| +// update from the signed feed, watching its job, and a rollback — and every |
| +// move is recorded against that address, not against an owner. |
| +func TestSystemOperatorRollsSignedReleases(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| + systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}) |
| + seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") |
| + sg := release.RootSigner(fx.priv) |
| + release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", |
| + release.SystemRelease{Version: "v0.17.0", Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v0.17.0", "linux-arm64"), SHA256: strings.Repeat("a", 64)}}}, |
| + release.SystemPromoteOptions{}) |
| + time.Sleep(1100 * time.Millisecond) |
| + bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-bill") |
| + |
| + if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { |
| + t.Fatalf("status as an operator = %d %v, want 200", st, out) |
| + } |
| + st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, bill) |
| + if st != http.StatusOK { |
| + t.Fatalf("update as an operator = %d %v, want 200", st, out) |
| + } |
| + if job := waitJob(t, fx, bill, str(t, out, "job")); job["error"] != "" { |
| + t.Fatalf("operator's job: %v", job) |
| + } |
| + p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") |
| + if err != nil || p.Current.Version != "v0.18.0" { |
| + t.Fatalf("pointer after the operator's update = %+v %v", p, err) |
| + } |
| + if len(p.Audit) == 0 || p.Audit[0].Actor != "bill@keymail.dev" { |
| + t.Fatalf("pointer audit = %+v, want the operator as the actor", p.Audit) |
| + } |
| + time.Sleep(1100 * time.Millisecond) |
| + st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill) |
| + if st != http.StatusOK || str(t, out, "version") != "v0.17.0" { |
| + t.Fatalf("rollback as an operator = %d %v, want 200 onto v0.17.0", st, out) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + actions := map[string]string{} |
| + for _, e := range events { |
| + actions[e.Action] = e.Address |
| + } |
| + for _, a := range []string{"system-update", "system-rollback"} { |
| + if actions[a] != "bill@keymail.dev" { |
| + t.Errorf("%s audit row by %q, want the operator", a, actions[a]) |
| + } |
| + } |
| +} |
| + |
| +// TestSystemOperatorCannotShipALocalBuild: the console signs whatever it is |
| +// uploaded with the deployment key and every box runs it, so a local build |
| +// stays the ops owner's. An operator is refused at the upload door, and |
| +// cannot spend an owner's parked upload by naming its handle either: the |
| +// refusal comes before the handle is consumed, so the owner's promote still |
| +// finds it. |
| +func TestSystemOperatorCannotShipALocalBuild(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"} |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-owner") |
| + bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-nofile") |
| + |
| + if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden { |
| + t.Fatalf("upload as an operator = %d, want 403", st) |
| + } |
| + st, handle := uploadBuild(t, fx, owner) |
| + if st != http.StatusOK { |
| + t.Fatalf("upload as the owner = %d", st) |
| + } |
| + st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, bill) |
| + if st != http.StatusForbidden { |
| + t.Fatalf("update naming an upload as an operator = %d %v, want 403", st, out) |
| + } |
| + if str(t, out, "error") == "" { |
| + t.Fatalf("operator's refusal = %v, want a sentence", out) |
| + } |
| + st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": handle}, owner) |
| + if st != http.StatusOK { |
| + t.Fatalf("owner's update = %d %v", st, out) |
| + } |
| + if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| + t.Fatalf("owner's upload was consumed by the operator's refused request: %v", job) |
| + } |
| +} |
| + |
| +// TestSystemOperatorListGatesWhoGetsIn: the list is the whole grant. Off it, |
| +// a person gets the same bare 404 a nonexistent path gives; an empty list is |
| +// exactly the owner-only console that existed before it. Entries are |
| +// trimmed and lowercased the way the other operator lists are, because the |
| +// list is typed by a person into an env file. |
| +func TestSystemOperatorListGatesWhoGetsIn(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-list") |
| + _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", bill) |
| + for _, list := range [][]string{nil, {"alice@keymail.dev"}, {"bill@keymail.dev.evil"}} { |
| + fx.srv.cfg.SystemOperators = list |
| + if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusNotFound || body != want { |
| + t.Errorf("status with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) |
| + } |
| + if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, bill); st != http.StatusNotFound || body != want { |
| + t.Errorf("rollback with SystemOperators=%q = %d %q, want 404 %q", list, st, body, want) |
| + } |
| + } |
| + fx.srv.cfg.SystemOperators = []string{" Bill@Keymail.dev "} |
| + if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK { |
| + t.Fatalf("status with a padded, mixed-case entry = %d %v, want 200", st, out) |
| + } |
| +} |
diff --git a/internal/console/apisystemupdate.go b/internal/console/apisystemupdate.go| index ee8bfde86..161a60882 100644 |
| --- a/internal/console/apisystemupdate.go |
| +++ b/internal/console/apisystemupdate.go |
| @@ -13,6 +13,7 @@ import ( |
| "net/http" |
| "os" |
| "sort" |
| + "strings" |
| "sync" |
| "time" |
| @@ -22,9 +23,10 @@ import ( |
| ) |
| // The console half of `carlos system` (spec §5). Every door here is for an |
| -// OWNER of the ops account on a PERSON session, checked on every call; |
| -// refusal is a bare 404 like the operator doors, so a non-owner learns |
| -// nothing. The console holds the bucket, the signing key and the egress; |
| +// OWNER of the ops account or an address on CARLOS_SYSTEM_OPERATORS, on a |
| +// PERSON session, checked on every call (apiSystemRoller); uploading a local |
| +// build is the owner's alone. Refusal is a bare 404 like the operator doors, |
| +// so anyone else learns nothing. The console holds the bucket, the signing key and the egress; |
| // the operator's laptop holds none of them. |
| const ( |
| @@ -89,27 +91,48 @@ type systemUpload struct { |
| data []byte |
| } |
| -// apiSystemOwner is the gate every door in this file opens with: a person, |
| -// and an OWNER of the ops account. A refusal is a bare 404 — the same answer |
| -// a nonexistent path gives — because whether this deployment has a system |
| -// surface at all is not something a member needs to learn. |
| -func (s *Server) apiSystemOwner(w http.ResponseWriter, r *http.Request) (string, bool) { |
| +// apiSystemRoller is the gate every door in this file opens with: a person |
| +// who is either an OWNER of the ops account or on SystemOperators |
| +// (CARLOS_SYSTEM_OPERATORS, discussion #176). owner reports which, because |
| +// the upload door and an update naming an upload are the owner's alone |
| +// (SystemOperators' doc comment, server.go). |
| +// |
| +// A refusal is a bare 404, the same answer a nonexistent path gives, because |
| +// whether this deployment has a system surface at all is not something a |
| +// member needs to learn. The owner check comes first and a store error is |
| +// not the end: a listed operator must still get in on a console whose role |
| +// lookup failed, and a store error must never be what admits anyone. |
| +func (s *Server) apiSystemRoller(w http.ResponseWriter, r *http.Request) (addr string, owner, ok bool) { |
| p, ok := principalFrom(r.Context()) |
| if !ok || p.Kind != PrincipalUser { |
| http.NotFound(w, r) |
| - return "", false |
| + return "", false, false |
| } |
| role, err := s.store.Role(OpsAccountID, p.Address) |
| if err != nil { |
| log.Printf("console: api system: role: %v", err) |
| - http.NotFound(w, r) |
| - return "", false |
| } |
| - if role != RoleOwner { |
| - http.NotFound(w, r) |
| - return "", false |
| + if err == nil && role == RoleOwner { |
| + return p.Address, true, true |
| + } |
| + if s.systemOperator(p.Address) { |
| + return p.Address, false, true |
| + } |
| + http.NotFound(w, r) |
| + return "", false, false |
| +} |
| + |
| +// systemOperator reports whether addr is on SystemOperators. It is |
| +// relayOperator's exact-match rule (apirelay.go): the configured entry is |
| +// trimmed and lowercased, the signed-in address is compared as it is, and |
| +// never with EqualFold, whose Unicode folding admits homoglyphs. |
| +func (s *Server) systemOperator(addr string) bool { |
| + for _, op := range s.cfg.SystemOperators { |
| + if strings.ToLower(strings.TrimSpace(op)) == addr { |
| + return true |
| + } |
| } |
| - return p.Address, true |
| + return false |
| } |
| // auditSystem records an operator's platform action against the OPS account. |
| @@ -118,7 +141,8 @@ func (s *Server) apiSystemOwner(w http.ResponseWriter, r *http.Request) (string, |
| // dereferences it: these actions have no tenant account behind them — their |
| // 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. |
| +// and the deployment's system operators (SystemOperators) are the only |
| +// callers that ever reach these doors, and the row names which person it was. |
| // |
| // 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 |
| @@ -267,7 +291,7 @@ type systemUpdateRequest struct { |
| // downloading and mirroring, and an operator watching a spinner over a |
| // connection that may drop is not a place to hold it. |
| func (s *Server) handleAPISystemUpdate(w http.ResponseWriter, r *http.Request) { |
| - addr, ok := s.apiSystemOwner(w, r) |
| + addr, owner, ok := s.apiSystemRoller(w, r) |
| if !ok { |
| return |
| } |
| @@ -282,6 +306,13 @@ func (s *Server) handleAPISystemUpdate(w http.ResponseWriter, r *http.Request) { |
| if !readJSONStrictLimit(w, r, &in, 4<<10) { |
| return |
| } |
| + // An upload handle is refused BEFORE a job starts: the job is what |
| + // consumes the handle, so a check inside it would let an operator spend |
| + // an owner's parked build on a refused request. |
| + if in.Upload != "" && !owner { |
| + systemUploadOwnerOnly(w) |
| + return |
| + } |
| if in.Component == "" { |
| in.Component = release.SystemComponentRouter |
| } |
| @@ -541,7 +572,7 @@ func (s *Server) systemArtifactMatches(ctx context.Context, key, sha string) (bo |
| } |
| func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) { |
| - if _, ok := s.apiSystemOwner(w, r); !ok { |
| + if _, _, ok := s.apiSystemRoller(w, r); !ok { |
| return |
| } |
| s.systemMu.Lock() |
| @@ -560,7 +591,12 @@ func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) { |
| // because the payload is a binary; the meta part comes FIRST so the version |
| // and platform are known — and refusable — before any bytes are buffered. |
| func (s *Server) handleAPISystemUpload(w http.ResponseWriter, r *http.Request) { |
| - if _, ok := s.apiSystemOwner(w, r); !ok { |
| + _, owner, ok := s.apiSystemRoller(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !owner { |
| + systemUploadOwnerOnly(w) |
| return |
| } |
| if !s.systemBucket(w) { |
| @@ -622,11 +658,20 @@ func (s *Server) handleAPISystemUpload(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"upload": handle, "size": len(data)}) |
| } |
| +// systemUploadOwnerOnly is the one refusal a system operator meets: a local |
| +// build is the ops owner's (SystemOperators' doc comment, server.go). A 403 |
| +// rather than the gate's 404, because apiSystemRoller has already admitted |
| +// the caller to these doors, so it teaches them only what THEY lack. |
| +func systemUploadOwnerOnly(w http.ResponseWriter) { |
| + jsonError(w, http.StatusForbidden, |
| + "shipping a local build needs an owner of the ops account. as a system operator you can update to a signed release") |
| +} |
| + |
| // handleAPISystemRollback points a channel back at what it ran before. It |
| // answers inline rather than as a job: nothing is fetched or mirrored, |
| // because the previous release travels whole inside the pointer's history. |
| func (s *Server) handleAPISystemRollback(w http.ResponseWriter, r *http.Request) { |
| - addr, ok := s.apiSystemOwner(w, r) |
| + addr, _, ok := s.apiSystemRoller(w, r) |
| if !ok { |
| return |
| } |
| @@ -678,7 +723,7 @@ func (s *Server) handleAPISystemRollback(w http.ResponseWriter, r *http.Request) |
| // handleAPISystemStatus is the whole picture in one read: what each channel |
| // points at, and what every box actually runs against it. |
| func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) { |
| - if _, ok := s.apiSystemOwner(w, r); !ok { |
| + if _, _, ok := s.apiSystemRoller(w, r); !ok { |
| return |
| } |
| if !s.systemBucket(w) { |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 7d09d555c..bbd5d466b 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -863,9 +863,10 @@ func (s *Server) registerCLIRoutes() { |
| // |
| // The person check itself is in the handlers, because cliRoute's Set can |
| // only narrow SERVICE principals and a person always passes through it. |
| - // The update doors want the deployment OWNER (apiSystemOwner); the |
| - // antivirus doors want an address on CARLOS_STORE_OPERATORS |
| - // (apiSystemOperator). |
| + // The update doors want an OWNER of the ops account or an address on |
| + // CARLOS_SYSTEM_OPERATORS, with upload the owner's alone |
| + // (apiSystemRoller); the antivirus doors want an address on |
| + // CARLOS_STORE_OPERATORS (apiSystemOperator). |
| s.cliRoute("GET /api/cli/system/status", svcrole.Set{}, s.handleAPISystemStatus) |
| s.cliRoute("POST /api/cli/system/update", svcrole.Set{}, s.handleAPISystemUpdate) |
| s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob) |
diff --git a/internal/console/server.go b/internal/console/server.go| index e1902a10e..e666e1452 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -699,6 +699,22 @@ type Config struct { |
| // deployment may want to grant it to different people. Empty is |
| // fail-closed — nobody may rotate. |
| RelayOperators []string |
| + // SystemOperators are the addresses allowed to roll the platform's own |
| + // binary (CARLOS_SYSTEM_OPERATORS, discussion #176): `carlos system |
| + // status`, `update` from the signed release feed, its job, and |
| + // `rollback`. Owners of the ops account have all of that already; this |
| + // list is for a person who should roll releases without owning the |
| + // account, which would also hand them member removal, ownership transfer |
| + // and account deletion. Its own list rather than StoreOperators', |
| + // because that one reads tenant bytes through grants and scanning, and |
| + // rolling releases should not. |
| + // |
| + // The upload door stays the owner's. The console signs whatever it is |
| + // uploaded with the deployment key and every box runs it, so a listed |
| + // operator chooses WHICH upstream-signed release the fleet runs, and |
| + // only an owner can put an unreleased build on it. Empty is the |
| + // owner-only console that existed before the list. |
| + SystemOperators []string |
| // PlatformDomain is the domain relay hostnames are built under |
| // (CARLOS_PLATFORM_DOMAIN; flagship: "oncarlos.com"), so an enabled |
| // app is handed stun:relay.<region>.<PlatformDomain>:3478 and its two |
diff --git a/system.go b/system.go| index 1eb6ca91c..8253c34ab 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -30,7 +30,8 @@ import ( |
| // |
| // They are one command because they answer the same shape of question — what |
| // is this deployment doing — and they keep separate verbs because the |
| -// authority differs: the update family wants the deployment OWNER, antivirus |
| +// authority differs: the update family wants an ops account OWNER or an |
| +// address on CARLOS_SYSTEM_OPERATORS (local builds the owner's alone), antivirus |
| // wants an address on CARLOS_STORE_OPERATORS. |
| const systemComponentRouter = "router" |
| @@ -97,7 +98,7 @@ func systemUsage() { |
| carlos system router <verb> [flags] |
| carlos system antivirus <status|enable|disable> [flags] |
| -The platform's own binary (deployment owner): |
| +The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| update promote a platform release to a channel; every box on it rolls itself |
| status every box: platform, channel, running build, channel target, last apply outcome |
| @@ -178,7 +179,7 @@ func runSystemUpdate(args []string) { |
| consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| channel := fs.String("channel", "stable", "canary or stable") |
| version := fs.String("version", "", "release tag to promote (default: the latest public release); with --file, the version the binary reports") |
| - file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release)") |
| + file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release; ops account owners only)") |
| platform := fs.String("platform", "linux-arm64", "with --file: the binary's <goos>-<goarch>") |
| spread := fs.String("spread", "5m", "boxes jitter their restarts over this; 0 for a hotfix") |
| yes := fs.Bool("yes", false, "do not ask") |