| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "crypto/sha256" |
| 8 | "encoding/base64" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "fmt" |
| 12 | "mime/multipart" |
| 13 | "net/http" |
| 14 | "net/http/httptest" |
| 15 | "os" |
| 16 | "path/filepath" |
| 17 | "reflect" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/publicrelease" |
| 24 | "github.com/carlosframework/platform/internal/release" |
| 25 | "github.com/carlosframework/platform/internal/status" |
| 26 | ) |
| 27 | |
| 28 | // systemFeed serves a signed fake release from an httptest server and |
| 29 | // points the fixture's console at it. |
| 30 | func systemFeed(t *testing.T, fx *consoleFixture, assets map[string][]byte) { |
| 31 | t.Helper() |
| 32 | systemFeedHeld(t, fx, assets, nil) |
| 33 | } |
| 34 | |
| 35 | // systemFeedHeld is systemFeed with a gate on /latest.txt: while hold is open |
| 36 | // the feed never answers, so a promote that has to resolve "latest" stays in |
| 37 | // flight for as long as the test needs it to. |
| 38 | func systemFeedHeld(t *testing.T, fx *consoleFixture, assets map[string][]byte, hold <-chan struct{}) { |
| 39 | t.Helper() |
| 40 | pub, priv, _ := ed25519.GenerateKey(nil) |
| 41 | var sums strings.Builder |
| 42 | for name, body := range assets { |
| 43 | s := sha256.Sum256(body) |
| 44 | fmt.Fprintf(&sums, "%s %s\n", hex.EncodeToString(s[:]), name) |
| 45 | } |
| 46 | sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(sums.String()))) |
| 47 | mux := http.NewServeMux() |
| 48 | mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) { |
| 49 | if hold != nil { |
| 50 | <-hold |
| 51 | } |
| 52 | fmt.Fprintln(w, "v0.18.0") |
| 53 | }) |
| 54 | mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) }) |
| 55 | mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, sig) }) |
| 56 | for name, body := range assets { |
| 57 | body := body |
| 58 | mux.HandleFunc("/dl/v0.18.0/"+name, func(w http.ResponseWriter, r *http.Request) { w.Write(body) }) |
| 59 | } |
| 60 | ts := httptest.NewServer(mux) |
| 61 | t.Cleanup(ts.Close) |
| 62 | fx.srv.cfg.SystemFeed = publicrelease.Feed{LatestURL: ts.URL + "/latest.txt", DownloadBase: ts.URL + "/dl", PubKey: base64.StdEncoding.EncodeToString(pub)} |
| 63 | } |
| 64 | |
| 65 | func waitJob(t *testing.T, fx *consoleFixture, token, id string) map[string]any { |
| 66 | t.Helper() |
| 67 | for i := 0; i < 100; i++ { |
| 68 | st, out := getJSON(t, fx.ts.URL+"/api/cli/system/jobs/"+id, token) |
| 69 | if st != http.StatusOK { |
| 70 | t.Fatalf("job = %d %v", st, out) |
| 71 | } |
| 72 | if out["done"] == true { |
| 73 | return out |
| 74 | } |
| 75 | time.Sleep(20 * time.Millisecond) |
| 76 | } |
| 77 | t.Fatal("job never finished") |
| 78 | return nil |
| 79 | } |
| 80 | |
| 81 | func seedBox(t *testing.T, fx *consoleFixture, label, platform, channel, rel string) { |
| 82 | t.Helper() |
| 83 | b := status.CollectBox(nil, status.Probes{Release: rel, Platform: platform, SystemChannel: channel}, time.Now()) |
| 84 | b.Label = label |
| 85 | if err := status.WriteBox(context.Background(), fx.rel, b); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestSystemDoorsAreForOpsOwnersOnly(t *testing.T) { |
| 91 | fx := newConsoleFixture(t) |
| 92 | if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | member := cliToken(t, fx, "member@keymail.dev", "code-sys-member") |
| 96 | // The refusal is the BARE 404 the operator doors give — raw, not |
| 97 | // jsonError'd — so it is byte-identical to what a path that does not |
| 98 | // exist answers. That identity is the point: a member must not be able |
| 99 | // to learn that this deployment has a system surface at all, and the |
| 100 | // nonexistent path below is the baseline every refusal is measured |
| 101 | // against, which is why the assertions read the body and not just the |
| 102 | // status. |
| 103 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| 104 | for _, u := range []string{"/api/cli/system/status", "/api/cli/system/jobs/deadbeef"} { |
| 105 | st, body := getRaw(t, fx.ts.URL+u, member) |
| 106 | if st != http.StatusNotFound || body != want { |
| 107 | t.Errorf("GET %s as a member = %d %q, want 404 %q", u, st, body, want) |
| 108 | } |
| 109 | } |
| 110 | for _, u := range []string{"/api/cli/system/update", "/api/cli/system/rollback"} { |
| 111 | st, body := postRaw(t, fx.ts.URL+u, map[string]string{"channel": "canary"}, member) |
| 112 | if st != http.StatusNotFound || body != want { |
| 113 | t.Errorf("POST %s as a member = %d %q, want 404 %q", u, st, body, want) |
| 114 | } |
| 115 | } |
| 116 | if st, _ := getRaw(t, fx.ts.URL+"/api/cli/system/status", "not-a-token"); st == http.StatusOK { |
| 117 | t.Error("unauthenticated status answered 200") |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestSystemDoorsWithoutABucketRefuse: Config.Releases == nil is a legal |
| 122 | // deployment shape, and every door here reads or writes the bucket. An owner |
| 123 | // on such a console must get a sentence, not the nil-interface panic |
| 124 | // status.LoadBoxes and release.GetSystemPointer would otherwise give. |
| 125 | func TestSystemDoorsWithoutABucketRefuse(t *testing.T) { |
| 126 | fx := newConsoleFixture(t) |
| 127 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-nobucket") |
| 128 | fx.srv.cfg.Releases = nil |
| 129 | if st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner); st != http.StatusServiceUnavailable { |
| 130 | t.Errorf("status without a bucket = %d %v, want 503", st, out) |
| 131 | } |
| 132 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner); st != http.StatusServiceUnavailable { |
| 133 | t.Errorf("update without a bucket = %d %v, want 503", st, out) |
| 134 | } |
| 135 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusServiceUnavailable { |
| 136 | t.Errorf("rollback without a bucket = %d %v, want 503", st, out) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestSystemUpdateMirrorsSignsAndBumps(t *testing.T) { |
| 141 | fx := newConsoleFixture(t) |
| 142 | arm := []byte("ELF arm64") |
| 143 | systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm, "carlos-linux-amd64": []byte("ELF amd64")}) |
| 144 | seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") |
| 145 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-owner") |
| 146 | before, _, _ := release.ReadSerial(context.Background(), fx.rel) |
| 147 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "spread": "2m"}, owner) |
| 148 | if st != http.StatusOK { |
| 149 | t.Fatalf("update = %d %v", st, out) |
| 150 | } |
| 151 | job := waitJob(t, fx, owner, str(t, out, "job")) |
| 152 | if job["error"] != "" { |
| 153 | t.Fatalf("job failed: %v", job) |
| 154 | } |
| 155 | p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | if p.Current.Version != "v0.18.0" || p.Spread != "2m0s" || p.Current.Source != "release:v0.18.0" { |
| 160 | t.Fatalf("pointer = %+v", p) |
| 161 | } |
| 162 | a, ok := p.Current.Artifacts["linux-arm64"] |
| 163 | if !ok { |
| 164 | t.Fatalf("no arm64 artifact: %+v", p.Current.Artifacts) |
| 165 | } |
| 166 | if _, ok := p.Current.Artifacts["linux-amd64"]; ok { |
| 167 | t.Fatal("mirrored a platform no box reports") |
| 168 | } |
| 169 | body, etag, err := fx.rel.Get(context.Background(), a.Key) |
| 170 | if err != nil || !bytes.Equal(body, arm) { |
| 171 | t.Fatalf("artifact not mirrored: %v", err) |
| 172 | } |
| 173 | s := sha256.Sum256(arm) |
| 174 | if a.SHA256 != hex.EncodeToString(s[:]) { |
| 175 | t.Fatalf("sha = %s", a.SHA256) |
| 176 | } |
| 177 | after, _, _ := release.ReadSerial(context.Background(), fx.rel) |
| 178 | if after.Serial == before.Serial { |
| 179 | t.Fatal("serial not bumped") |
| 180 | } |
| 181 | // A second promote of the same version to stable copies nothing and |
| 182 | // refuses different bytes under the same version. |
| 183 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0"}, owner) |
| 184 | if st != http.StatusOK { |
| 185 | t.Fatalf("second update = %d %v", st, out) |
| 186 | } |
| 187 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 188 | t.Fatalf("second job: %v", job) |
| 189 | } |
| 190 | // Overwrite the object out from under the pointer — a CAS-guarded Put |
| 191 | // with the etag we just read, because an empty ifMatch is create-only |
| 192 | // and would refuse an existing key. A re-promote of that version must |
| 193 | // then refuse: versions are immutable. |
| 194 | if _, err := fx.rel.Put(context.Background(), a.Key, []byte("different"), etag); err != nil { |
| 195 | t.Fatalf("overwrite the mirrored artifact: %v", err) |
| 196 | } |
| 197 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "version": "v0.18.0"}, owner) |
| 198 | if st != http.StatusOK { |
| 199 | t.Fatalf("re-promote = %d %v", st, out) |
| 200 | } |
| 201 | job = waitJob(t, fx, owner, str(t, out, "job")) |
| 202 | if e, _ := job["error"].(string); e == "" || !strings.Contains(e, "immutable") { |
| 203 | t.Fatalf("re-promote over different bytes: %v", job) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // TestSystemUpdateIsOnePromotePerChannel: a promote holds a whole platform |
| 208 | // binary in the heap, and the door is reachable by a script. One at a time |
| 209 | // per channel, and the second caller is told which job to watch rather than |
| 210 | // being handed a parallel one. |
| 211 | func TestSystemUpdateIsOnePromotePerChannel(t *testing.T) { |
| 212 | fx := newConsoleFixture(t) |
| 213 | hold := make(chan struct{}) |
| 214 | var once sync.Once |
| 215 | unhold := func() { once.Do(func() { close(hold) }) } |
| 216 | defer unhold() // the feed server's Close waits on in-flight requests |
| 217 | systemFeedHeld(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}, hold) |
| 218 | seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") |
| 219 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-one") |
| 220 | |
| 221 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner) |
| 222 | if st != http.StatusOK { |
| 223 | t.Fatalf("first update = %d %v", st, out) |
| 224 | } |
| 225 | first := str(t, out, "job") |
| 226 | |
| 227 | // The slot is claimed by the handler, before the goroutine runs, so this |
| 228 | // needs no sleep to be deterministic. |
| 229 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner) |
| 230 | if st != http.StatusConflict { |
| 231 | t.Fatalf("second update on a busy channel = %d %v, want 409", st, out) |
| 232 | } |
| 233 | if str(t, out, "job") != first { |
| 234 | t.Errorf("409 named job %q, want the running one %q", str(t, out, "job"), first) |
| 235 | } |
| 236 | // A DIFFERENT channel is not blocked by canary's promote. |
| 237 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable"}, owner) |
| 238 | if st != http.StatusOK { |
| 239 | t.Fatalf("update on the other channel = %d %v, want 200", st, out) |
| 240 | } |
| 241 | stable := str(t, out, "job") |
| 242 | |
| 243 | unhold() |
| 244 | // Both jobs mirror the SAME artifact key concurrently — one wins the |
| 245 | // create-only Put and the other must read it back and agree, not report |
| 246 | // a conflict the operator did not cause. |
| 247 | for _, id := range []string{first, stable} { |
| 248 | if job := waitJob(t, fx, owner, id); job["error"] != "" { |
| 249 | t.Fatalf("job %s: %v", id, job) |
| 250 | } |
| 251 | } |
| 252 | // The slot is handed back before the job is marked done, so an operator |
| 253 | // who watched it finish can promote again immediately. |
| 254 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner) |
| 255 | if st != http.StatusOK { |
| 256 | t.Fatalf("update after the first finished = %d %v, want 200", st, out) |
| 257 | } |
| 258 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 259 | t.Fatalf("re-promote after the slot was handed back: %v", job) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestSystemUpdateRefusesWhenFleetsAreUnreadable: which platforms to mirror |
| 264 | // is derived from what the fleet reports, so a fleet this console cannot |
| 265 | // enumerate is not a fleet to quietly leave out — those boxes would never |
| 266 | // find a binary to adopt. The promote refuses and says why. |
| 267 | func TestSystemUpdateRefusesWhenFleetsAreUnreadable(t *testing.T) { |
| 268 | fx := newConsoleFixture(t) |
| 269 | systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}) |
| 270 | seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0") |
| 271 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-fleeterr") |
| 272 | // A registry that EXISTS and cannot be opened — a directory where the |
| 273 | // database should be. Not a missing path: a path that is not there yet is |
| 274 | // an ordinary console with no fleets, which the sibling test covers. |
| 275 | broken := filepath.Join(t.TempDir(), "registry.db") |
| 276 | if err := os.Mkdir(broken, 0o755); err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | fx.srv.cfg.Registry = broken |
| 280 | |
| 281 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner) |
| 282 | if st != http.StatusOK { |
| 283 | t.Fatalf("update = %d %v", st, out) |
| 284 | } |
| 285 | job := waitJob(t, fx, owner, str(t, out, "job")) |
| 286 | e, _ := job["error"].(string) |
| 287 | if !strings.Contains(e, "cannot list fleets") { |
| 288 | t.Fatalf("job error = %q, want a refusal naming the fleet listing", e) |
| 289 | } |
| 290 | if _, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary"); err == nil { |
| 291 | t.Error("the pointer moved even though the platform set was unknown") |
| 292 | } |
| 293 | |
| 294 | // The STATUS read degrades instead: the boxes that did answer, plus the |
| 295 | // flag that says the list is short. |
| 296 | st, out = getJSON(t, fx.ts.URL+"/api/cli/system/status", owner) |
| 297 | if st != http.StatusOK { |
| 298 | t.Fatalf("status = %d %v", st, out) |
| 299 | } |
| 300 | if out["partial"] != true { |
| 301 | t.Errorf("status partial = %v, want true when a fleet could not be listed", out["partial"]) |
| 302 | } |
| 303 | if boxes, _ := out["boxes"].([]any); len(boxes) != 1 { |
| 304 | t.Errorf("status boxes = %v, want the platform's own box still shown", out["boxes"]) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // TestSystemUpdateWithNoRegistryHasNoFleets: a console with no route registry |
| 309 | // — cfg.Registry unset, or the file not created yet — is an ordinary console |
| 310 | // with no fleets, not one whose fleets are unreadable. It must be able to |
| 311 | // promote, and its status must not claim to be short of anything. |
| 312 | func TestSystemUpdateWithNoRegistryHasNoFleets(t *testing.T) { |
| 313 | for _, c := range []struct{ name, registry string }{ |
| 314 | {"unset", ""}, |
| 315 | {"not created yet", filepath.Join(t.TempDir(), "never-made.db")}, |
| 316 | } { |
| 317 | t.Run(c.name, func(t *testing.T) { |
| 318 | fx := newConsoleFixture(t) |
| 319 | systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": []byte("ELF arm64")}) |
| 320 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-noreg-"+strings.ReplaceAll(c.name, " ", "-")) |
| 321 | fx.srv.cfg.Registry = c.registry |
| 322 | |
| 323 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary"}, owner) |
| 324 | if st != http.StatusOK { |
| 325 | t.Fatalf("update = %d %v", st, out) |
| 326 | } |
| 327 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 328 | t.Fatalf("job: %v", job) |
| 329 | } |
| 330 | p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | if _, ok := p.Current.Artifacts["linux-arm64"]; !ok { |
| 335 | t.Fatalf("artifacts = %+v, want linux-arm64 mirrored", p.Current.Artifacts) |
| 336 | } |
| 337 | st, out = getJSON(t, fx.ts.URL+"/api/cli/system/status", owner) |
| 338 | if st != http.StatusOK { |
| 339 | t.Fatalf("status = %d %v", st, out) |
| 340 | } |
| 341 | if out["partial"] != false { |
| 342 | t.Errorf("status partial = %v, want false — no registry is no fleets, not unreadable ones", out["partial"]) |
| 343 | } |
| 344 | }) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // TestSystemStatusNamesAnUnverifiablePointer: null means "never promoted" |
| 349 | // and nothing else. A pointer that exists but does not verify is the one |
| 350 | // thing an operator most needs to see, so it must not render as the one |
| 351 | // thing they would scroll past. |
| 352 | func TestSystemStatusNamesAnUnverifiablePointer(t *testing.T) { |
| 353 | fx := newConsoleFixture(t) |
| 354 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-tampered") |
| 355 | key := release.SystemPointerKey("router", "canary") |
| 356 | if _, err := fx.rel.Put(context.Background(), key, []byte(`{"payload":"bm90LWEtcG9pbnRlcg==","sig":"AAAA"}`), ""); err != nil { |
| 357 | t.Fatal(err) |
| 358 | } |
| 359 | st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner) |
| 360 | if st != http.StatusOK { |
| 361 | t.Fatalf("status = %d %v", st, out) |
| 362 | } |
| 363 | p, _ := out["pointers"].(map[string]any) |
| 364 | canary, ok := p["canary"].(map[string]any) |
| 365 | if !ok { |
| 366 | t.Fatalf("canary pointer = %v, want an object carrying the error (null would read as never promoted)", p["canary"]) |
| 367 | } |
| 368 | if e, _ := canary["error"].(string); e == "" { |
| 369 | t.Errorf("canary pointer = %v, want an error naming the refusal", canary) |
| 370 | } |
| 371 | // stable really has never been promoted, and that IS null. |
| 372 | if p["stable"] != nil { |
| 373 | t.Errorf("stable pointer = %v, want null", p["stable"]) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | func TestSystemUpdateFromAnUpload(t *testing.T) { |
| 378 | fx := newConsoleFixture(t) |
| 379 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-upload") |
| 380 | var buf bytes.Buffer |
| 381 | mw := multipart.NewWriter(&buf) |
| 382 | meta, _ := mw.CreateFormField("meta") |
| 383 | meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`)) |
| 384 | file, _ := mw.CreateFormFile("file", "carlos-linux-arm64") |
| 385 | file.Write([]byte("ELF local")) |
| 386 | mw.Close() |
| 387 | req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf) |
| 388 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 389 | req.Header.Set("Authorization", "Bearer "+owner) |
| 390 | res, err := http.DefaultClient.Do(req) |
| 391 | if err != nil { |
| 392 | t.Fatal(err) |
| 393 | } |
| 394 | var up map[string]any |
| 395 | if res.StatusCode != http.StatusOK { |
| 396 | t.Fatalf("upload = %d", res.StatusCode) |
| 397 | } |
| 398 | if err := json.NewDecoder(res.Body).Decode(&up); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": up["upload"]}, owner) |
| 402 | if st != http.StatusOK { |
| 403 | t.Fatalf("update = %d %v", st, out) |
| 404 | } |
| 405 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 406 | t.Fatalf("job: %v", job) |
| 407 | } |
| 408 | p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary") |
| 409 | if err != nil || p.Current.Version != "66587fa" || p.Current.Source != "file" { |
| 410 | t.Fatalf("pointer = %+v %v", p, err) |
| 411 | } |
| 412 | // Upload without a platform is refused at the door. |
| 413 | var buf2 bytes.Buffer |
| 414 | mw2 := multipart.NewWriter(&buf2) |
| 415 | m2, _ := mw2.CreateFormField("meta") |
| 416 | m2.Write([]byte(`{"version":"abc"}`)) |
| 417 | f2, _ := mw2.CreateFormFile("file", "x") |
| 418 | f2.Write([]byte("x")) |
| 419 | mw2.Close() |
| 420 | req2, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf2) |
| 421 | req2.Header.Set("Content-Type", mw2.FormDataContentType()) |
| 422 | req2.Header.Set("Authorization", "Bearer "+owner) |
| 423 | res2, _ := http.DefaultClient.Do(req2) |
| 424 | if res2.StatusCode != http.StatusBadRequest { |
| 425 | t.Fatalf("upload without platform = %d", res2.StatusCode) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // Versions here are at or above release.LayoutFloor: since plan 5 the |
| 430 | // rollback door judges the release it would move back to, and a version |
| 431 | // that cannot be placed against the floor is refused while any box reports |
| 432 | // no layout (layoutguard_test.go covers that side). |
| 433 | func TestSystemStatusAndRollback(t *testing.T) { |
| 434 | fx := newConsoleFixture(t) |
| 435 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-status") |
| 436 | seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.25.0") |
| 437 | sg := release.RootSigner(fx.priv) |
| 438 | rel := func(v string) release.SystemRelease { |
| 439 | return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}} |
| 440 | } |
| 441 | release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v0.25.0"), release.SystemPromoteOptions{}) |
| 442 | time.Sleep(1100 * time.Millisecond) |
| 443 | release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v0.26.0"), release.SystemPromoteOptions{}) |
| 444 | st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner) |
| 445 | if st != http.StatusOK { |
| 446 | t.Fatalf("status = %d %v", st, out) |
| 447 | } |
| 448 | boxes := out["boxes"].([]any) |
| 449 | if len(boxes) != 1 { |
| 450 | t.Fatalf("boxes = %v", boxes) |
| 451 | } |
| 452 | b := boxes[0].(map[string]any) |
| 453 | if b["label"] != "ie" || b["release"] != "v0.25.0" || b["target"] != "v0.26.0" || b["channel"] != "canary" { |
| 454 | t.Fatalf("box row = %v", b) |
| 455 | } |
| 456 | time.Sleep(1100 * time.Millisecond) |
| 457 | st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner) |
| 458 | if st != http.StatusOK || str(t, out, "version") != "v0.25.0" { |
| 459 | t.Fatalf("rollback = %d %v", st, out) |
| 460 | } |
| 461 | if st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusConflict { |
| 462 | t.Fatalf("second rollback = %d %v (nothing left in history)", st, out) |
| 463 | } |
| 464 | // Rollback validates what it is asked to rewind, exactly as update |
| 465 | // validates what it is asked to write — both arguments become part of an |
| 466 | // object key. |
| 467 | for _, body := range []map[string]string{{"channel": "edge"}, {"channel": ""}, {"channel": "canary", "component": "../etc"}} { |
| 468 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", body, owner); st != http.StatusBadRequest { |
| 469 | t.Errorf("rollback %v = %d %v, want 400", body, st, out) |
| 470 | } |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | // TestSystemFeedIsAllThreeFieldsOrNone: a half-configured feed would fetch |
| 475 | // one publisher's binaries and check them against another's key, which fails |
| 476 | // as "the release is corrupt" rather than as "this console is misconfigured". |
| 477 | func TestSystemFeedIsAllThreeFieldsOrNone(t *testing.T) { |
| 478 | fx := newConsoleFixture(t) |
| 479 | def := publicrelease.Default() |
| 480 | full := publicrelease.Feed{LatestURL: "https://x/latest.txt", DownloadBase: "https://x/dl", PubKey: "k"} |
| 481 | for _, c := range []struct { |
| 482 | name string |
| 483 | cfg publicrelease.Feed |
| 484 | want publicrelease.Feed |
| 485 | }{ |
| 486 | {"unset", publicrelease.Feed{}, def}, |
| 487 | {"all three", full, full}, |
| 488 | {"base only", publicrelease.Feed{DownloadBase: full.DownloadBase}, def}, |
| 489 | {"base and key, no latest", publicrelease.Feed{DownloadBase: full.DownloadBase, PubKey: full.PubKey}, def}, |
| 490 | {"key only", publicrelease.Feed{PubKey: full.PubKey}, def}, |
| 491 | } { |
| 492 | fx.srv.cfg.SystemFeed = c.cfg |
| 493 | if got := fx.srv.systemFeed(); got != c.want { |
| 494 | t.Errorf("%s: systemFeed() = %+v, want %+v", c.name, got, c.want) |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor pins the channel |
| 500 | // pointer `carlos system update --version <tag>` writes while no host floor |
| 501 | // is published (the claim trial's dev pointers on v0.28.3, untagged-pointer |
| 502 | // ruling R2): the release as the door resolved it, {Version, Source |
| 503 | // "release:<tag>", Artifacts}, and nothing else. No release tag, no moves |
| 504 | // protocol and no console artifacts, because reserveHostUpdate tags a |
| 505 | // release only once a floor is published. internal/host's install-channel |
| 506 | // tests and the root-runner activation witness promote exactly this shape. |
| 507 | func TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor(t *testing.T) { |
| 508 | fx := newConsoleFixture(t) |
| 509 | arm := []byte("ELF arm64") |
| 510 | systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm}) |
| 511 | seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.17.0") |
| 512 | owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-untagged") |
| 513 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0", "spread": "0s"}, owner) |
| 514 | if st != http.StatusOK { |
| 515 | t.Fatalf("update = %d %v", st, out) |
| 516 | } |
| 517 | if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" { |
| 518 | t.Fatalf("job failed: %v", job) |
| 519 | } |
| 520 | p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "stable") |
| 521 | if err != nil { |
| 522 | t.Fatal(err) |
| 523 | } |
| 524 | s := sha256.Sum256(arm) |
| 525 | want := release.SystemRelease{Version: "v0.18.0", Source: "release:v0.18.0", ReleasedAt: p.Current.ReleasedAt, |
| 526 | Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v0.18.0", "linux-arm64"), SHA256: hex.EncodeToString(s[:])}}} |
| 527 | if !reflect.DeepEqual(p.Current, want) || p.Spread != "0s" { |
| 528 | t.Fatalf("pointer current = %+v spread %s, want %+v", p.Current, p.Spread, want) |
| 529 | } |
| 530 | if _, err := release.VerifySystemIdentity(fx.pub, mustGetObject(t, fx, release.SystemPointerKey("router", "stable"))); err == nil { |
| 531 | t.Fatal("an untagged pointer verified as a release identity") |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | func mustGetObject(t *testing.T, fx *consoleFixture, key string) []byte { |
| 536 | t.Helper() |
| 537 | raw, _, err := fx.rel.Get(context.Background(), key) |
| 538 | if err != nil { |
| 539 | t.Fatal(err) |
| 540 | } |
| 541 | return raw |
| 542 | } |
| 543 | |