carlos / platform Public

system-operators-176

Squashed from system-operators-176 into main.
claude agent, operated by paul@keymail.dev a7a2aaa2eea6a8ea17b39563a2236f5eb37aaa52 parent da866a9
8 files changed, +313 −32
  • cmd/console/main.go +22 −1
  • docs/site/cli.md +6 −2
  • internal/console/apirelay.go +2 −2
  • internal/console/apisystemoperators_test.go +192 −0
  • internal/console/apisystemupdate.go +67 −21
  • internal/console/authcli.go +4 −3
  • internal/console/server.go +16 −0
  • system.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..5215a2b7f
--- /dev/null
+++ b/internal/console/apisystemoperators_test.go
@@ -0,0 +1,192 @@
+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)
+ }
+ // The owner-only 403 is for callers the gate has ADMITTED. Off the
+ // list, the two upload paths must answer the same 404 as every other
+ // door, or the 403's sentence confirms the surface exists.
+ if st, _ := uploadBuild(t, fx, bill); st != http.StatusNotFound {
+ t.Errorf("upload with SystemOperators=%q = %d, want 404", list, st)
+ }
+ if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/update", map[string]string{"channel": "canary", "upload": "deadbeef"}, bill); st != http.StatusNotFound || body != want {
+ t.Errorf("update naming an upload 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)
+ }
+}
+
+// TestSystemRollerStoreErrorAdmitsNobody: a failed ownership lookup is
+// logged and the list is still consulted, so a listed operator keeps working
+// through a store fault. The fault itself must never admit anyone: an owner
+// the console cannot see is refused like a stranger, and the listed operator
+// gets in as an operator, never as an owner.
+func TestSystemRollerStoreErrorAdmitsNobody(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"}
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sysop-err-owner")
+ bill := cliToken(t, fx, "bill@keymail.dev", "code-sysop-err-bill")
+ _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", owner)
+ if _, err := fx.st.db.Exec(`ALTER TABLE memberships RENAME TO memberships_gone`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.Role(OpsAccountID, "paul@keymail.dev"); err == nil {
+ t.Fatal("Role still answers; the fault this test needs did not happen")
+ }
+ if st, body := getRaw(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusNotFound || body != want {
+ t.Errorf("status as an unlisted owner during a store fault = %d %q, want 404 %q", st, body, want)
+ }
+ if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", bill); st != http.StatusOK {
+ t.Errorf("status as a listed operator during a store fault = %d %v, want 200", st, out)
+ }
+ if st, _ := uploadBuild(t, fx, bill); st != http.StatusForbidden {
+ t.Errorf("upload as a listed operator during a store fault = %d, want 403 (an operator, not an owner)", st)
+ }
+}
diff --git a/internal/console/apisystemupdate.go b/internal/console/apisystemupdate.go
index ee8bfde86..9640e7655 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,11 @@ 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, and an admitted operator who asks for it gets a
+// 403 (systemUploadOwnerOnly). A caller the gate refuses gets 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 +92,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 +142,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 +292,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 +307,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 +573,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 +592,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 +659,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 +724,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..4da840494 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 and ownership
+ // transfer. 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")