bootstrap-carlos-follows-316
Squashed from bootstrap-carlos-follows-316 into main.
6 files changed,
+214
−12
internal/console/apisystembootstrap_test.go+70 −0internal/console/apisystemupdate.go+75 −9internal/provision/plan14_root_reads_test.go+7 −0internal/provision/testdata/plan14-root-reads-bootstrap-carlos-follows-316.tsv+3 −0system.go+24 −3system_test.go+35 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 302.028s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.550s
ok github.com/carlosframework/platform/cmd/moveprobe 93.229s
ok github.com/carlosframework/platform/cmd/zddprobe 0.709s
ok github.com/carlosframework/platform/internal/activator 251.289s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.021s
ok github.com/carlosframework/platform/internal/appconfig 2.458s
ok github.com/carlosframework/platform/internal/apperr 0.073s
ok github.com/carlosframework/platform/internal/applog 0.131s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.802s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.690s
ok github.com/carlosframework/platform/internal/changelog 0.325s
ok github.com/carlosframework/platform/internal/claimactivation 0.801s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 402.363s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.298s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.344s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 44.880s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.130s
ok github.com/carlosframework/platform/internal/gate 1.665s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.017s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/historyreadback 0.151s
ok github.com/carlosframework/platform/internal/host 179.971s
ok github.com/carlosframework/platform/internal/hostactors 5.445s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.013s
ok github.com/carlosframework/platform/internal/hostbox 2.601s
ok github.com/carlosframework/platform/internal/hostclaim 0.552s
ok github.com/carlosframework/platform/internal/hostenv 0.013s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 4.904s
ok github.com/carlosframework/platform/internal/hostfence 0.030s
ok github.com/carlosframework/platform/internal/hostfiles 10.586s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.032s
ok github.com/carlosframework/platform/internal/hostops 1.562s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.014s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.459s
ok github.com/carlosframework/platform/internal/hostresolve 0.138s
ok github.com/carlosframework/platform/internal/hostwrite 0.015s
ok github.com/carlosframework/platform/internal/infraview 0.013s
ok github.com/carlosframework/platform/internal/instancectl 5.218s
ok github.com/carlosframework/platform/internal/instanceexec 2.739s
ok github.com/carlosframework/platform/internal/instancefs 0.017s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.541s
ok github.com/carlosframework/platform/internal/lbhealth 0.015s
ok github.com/carlosframework/platform/internal/lease 9.804s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.007s
ok github.com/carlosframework/platform/internal/ledger 0.016s
ok github.com/carlosframework/platform/internal/mail 0.165s
ok github.com/carlosframework/platform/internal/maintenance 0.081s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.506s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.068s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 21.051s
ok github.com/carlosframework/platform/internal/moves/archive 0.750s
ok github.com/carlosframework/platform/internal/objstore 0.174s
ok github.com/carlosframework/platform/internal/ops 0.174s
ok github.com/carlosframework/platform/internal/park 0.201s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 35.833s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.466s
ok github.com/carlosframework/platform/internal/relay 7.287s
ok github.com/carlosframework/platform/internal/release 24.029s
ok github.com/carlosframework/platform/internal/rollout 3.810s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.063s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.562s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.475s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.604s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.681s
ok github.com/carlosframework/platform/internal/status 0.061s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.290s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.003s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.574s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49414306 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/20 12:02:18 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E03B3233E9F006, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/internal/console/apisystembootstrap_test.go b/internal/console/apisystembootstrap_test.go| new file mode 100644 |
| index 000000000..4221b8f25 |
| --- /dev/null |
| +++ b/internal/console/apisystembootstrap_test.go |
| @@ -0,0 +1,70 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Issue #316: system status compares bootstrap/carlos, the binary a new box |
| +// boots, with stable's signed linux-arm64 artifact. |
| +func TestSystemStatusReportsWhetherBootstrapIsStablesBuild(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-bootstrap") |
| + seedBox(t, fx, "ie", "linux-arm64", "stable", "v0.28.0") |
| + ctx := context.Background() |
| + build := []byte("stable build") |
| + sum := sha256.Sum256(build) |
| + if _, err := release.PromoteSystem(ctx, fx.rel, release.RootSigner(fx.priv), "router", "stable", release.SystemRelease{ |
| + Version: "v0.28.0", Artifacts: map[string]release.SystemArtifact{"linux-arm64": { |
| + Key: release.SystemArtifactKey("v0.28.0", "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}, |
| + }, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + bootstrap := func() any { |
| + t.Helper() |
| + st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner) |
| + if st != http.StatusOK { |
| + t.Fatalf("status = %d %v", st, out) |
| + } |
| + v, ok := out["bootstrap"] |
| + if !ok { |
| + t.Fatalf("no bootstrap field in %v", out) |
| + } |
| + return v |
| + } |
| + if got := bootstrap(); got != nil { |
| + t.Fatalf("absent bootstrap object: bootstrap = %v, want null", got) |
| + } |
| + put := func(data []byte) { |
| + t.Helper() |
| + _, etag, err := fx.rel.Get(ctx, bootstrapKey) |
| + if err != nil { |
| + etag = "" |
| + } |
| + if _, err := fx.rel.Put(ctx, bootstrapKey, data, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put([]byte("an older build")) |
| + row, _ := bootstrap().(map[string]any) |
| + if row["state"] != "behind" || row["version"] != "v0.28.0" || row["key"] != "bootstrap/router/v0.28.0/carlos-linux-arm64" { |
| + t.Fatalf("stale bootstrap: row = %v, want behind v0.28.0 with the mirror key", row) |
| + } |
| + put(build) |
| + if row, _ := bootstrap().(map[string]any); row["state"] != "current" { |
| + t.Fatalf("matching bootstrap: row = %v, want current", row) |
| + } |
| + // Past its budget the comparison reads unreadable, and the status read |
| + // still answers (Astra review: a slow store must not time the CLI out). |
| + was := bootstrapReadBudget |
| + bootstrapReadBudget = 0 |
| + t.Cleanup(func() { bootstrapReadBudget = was }) |
| + if row, _ := bootstrap().(map[string]any); row["state"] != "unreadable" || row["error"] == "" { |
| + t.Fatalf("over budget: row = %v, want unreadable with the error", row) |
| + } |
| +} |
diff --git a/internal/console/apisystemupdate.go b/internal/console/apisystemupdate.go| index 80e0d3385..f3f3dd9b5 100644 |
| --- a/internal/console/apisystemupdate.go |
| +++ b/internal/console/apisystemupdate.go |
| @@ -601,25 +601,84 @@ func (s *Server) mirrorSystemArtifact(ctx context.Context, job *systemJob, versi |
| // 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) |
| + got, err := s.objectSHA256(ctx, 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 { |
| + if got == sha { |
| return true, nil |
| } |
| return false, fmt.Errorf("%s exists with different bytes — versions are immutable; pick a new version", key) |
| } |
| +// objectSHA256 streams the object at key through a hasher; absent is |
| +// os.ErrNotExist. A ctx that ended during the read is its error, even on a |
| +// store that read on regardless. |
| +func (s *Server) objectSHA256(ctx context.Context, key string) (string, error) { |
| + rc, _, err := release.OpenObject(ctx, s.cfg.Releases, key) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer rc.Close() |
| + h := sha256.New() |
| + if _, err := io.Copy(h, rc); err != nil { |
| + return "", fmt.Errorf("%s: reading the object to compare it: %w", key, err) |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return "", fmt.Errorf("%s: reading the object to compare it: %w", key, err) |
| + } |
| + return hex.EncodeToString(h.Sum(nil)), nil |
| +} |
| + |
| +// bootstrapKey is the binary a new, rebuilt or failover box downloads at |
| +// first boot. `carlos system update` mirrors each release under |
| +// bootstrap/router/ and never writes this key, so it falls behind the fleet |
| +// unless an operator copies stable's build over it (issue #316). |
| +const bootstrapKey = "bootstrap/carlos" |
| + |
| +// bootstrapPlatform is the one platform bootstrap/carlos serves. |
| +const bootstrapPlatform = "linux-arm64" |
| + |
| +// bootstrapReadBudget bounds the comparison. It reads the whole binary, and |
| +// the status read it rides (also `carlos system update`'s preview) must |
| +// answer inside the CLI's 30-second timeout on slow storage: past the budget |
| +// the row reads unreadable and the rest of the response stands. |
| +var bootstrapReadBudget = 10 * time.Second |
| + |
| +// bootstrapState is the status row for bootstrapKey against stable's signed |
| +// linux-arm64 artifact, or nil when there is nothing to compare: no verified |
| +// stable pointer, no linux-arm64 artifact in it, or no bootstrap object (a |
| +// deployment that bootstraps another way). A read failure is reported, never |
| +// fatal to the status read. |
| +func (s *Server) bootstrapState(ctx context.Context, stable *release.SystemPointer) map[string]any { |
| + if stable == nil { |
| + return nil |
| + } |
| + art, ok := stable.Current.Artifacts[bootstrapPlatform] |
| + if !ok || art.SHA256 == "" { |
| + return nil |
| + } |
| + row := map[string]any{"version": stable.Current.Version, "key": art.Key} |
| + ctx, cancel := context.WithTimeout(ctx, bootstrapReadBudget) |
| + defer cancel() |
| + got, err := s.objectSHA256(ctx, bootstrapKey) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + return nil |
| + case err != nil: |
| + log.Printf("console: api system status: %s: %v", bootstrapKey, err) |
| + row["state"], row["error"] = "unreadable", err.Error() |
| + case strings.EqualFold(got, art.SHA256): |
| + row["state"] = "current" |
| + default: |
| + row["state"] = "behind" |
| + } |
| + return row |
| +} |
| + |
| func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) { |
| if _, _, ok := s.apiSystemRoller(w, r); !ok { |
| return |
| @@ -806,6 +865,7 @@ func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) { |
| ctx := r.Context() |
| pointers := map[string]any{} |
| targets := map[string]string{} |
| + var stable *release.SystemPointer |
| for _, ch := range []string{release.RungCanary, release.RungStable} { |
| p, _, err := release.GetSystemPointer(ctx, s.cfg.Releases, s.cfg.ReleasePub, release.SystemComponentRouter, ch) |
| switch { |
| @@ -824,6 +884,9 @@ func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) { |
| 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 |
| + if ch == release.RungStable { |
| + stable = p |
| + } |
| } |
| } |
| boxes, partial, err := s.systemBoxes(ctx, false) |
| @@ -854,5 +917,8 @@ func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) { |
| // 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}) |
| + // |
| + // bootstrap is null when there is nothing to compare (bootstrapState). |
| + writeJSON(w, http.StatusOK, map[string]any{"pointers": pointers, "boxes": rows, "partial": partial, |
| + "bootstrap": s.bootstrapState(ctx, stable)}) |
| } |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 1cb3fd69e..1175ecad9 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -942,6 +942,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-adopt-unit-root-pass-315.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay bootstrap-carlos-follows-316 (amadan #316: system status |
| + // compares bootstrap/carlos with stable's build), loaded last. Envelope |
| + // only, proposed; ruling asked of the census owner on #280. The seal |
| + // stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-bootstrap-carlos-follows-316.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| seal = "53b3cbb6f46d657ce32f49dad84dae79316c80da949ccf37273833eef5c5b2d7" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
diff --git a/internal/provision/testdata/plan14-root-reads-bootstrap-carlos-follows-316.tsv b/internal/provision/testdata/plan14-root-reads-bootstrap-carlos-follows-316.tsv| new file mode 100644 |
| index 000000000..d92e2784f |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-bootstrap-carlos-follows-316.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay bootstrap-carlos-follows-316 (amadan #316: carlos system status compares bootstrap/carlos with stable's signed linux-arm64 artifact), loaded LAST. Envelope only: no root-read unit is added or changed; the console's status door gains a store read of bootstrap/carlos and the CLI prints the result. Proposed; ruling asked of the census owner on #280. The seal stands. |
| +@coverage internal/console f697865970fd453298b73b42dc31b7c3b4ae2fd97e27a4837522eb8bc9b604b0 |
| +@coverage . ea902ca81c5eff7e3eb4b6b36afe1e0557101e8b27e27b6821f7b2073534c0a3 |
diff --git a/system.go b/system.go| index 1828c1f5a..839a2c9c6 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -248,9 +248,20 @@ type systemBoxRow struct { |
| // list is INCOMPLETE — a fleet's heartbeats could not be read — so a short |
| // list is never mistaken for a small fleet. |
| type systemStatusResponse struct { |
| - Pointers map[string]*systemPointerRow `json:"pointers"` |
| - Boxes []systemBoxRow `json:"boxes"` |
| - Partial bool `json:"partial"` |
| + Pointers map[string]*systemPointerRow `json:"pointers"` |
| + Boxes []systemBoxRow `json:"boxes"` |
| + Partial bool `json:"partial"` |
| + Bootstrap *systemBootstrapRow `json:"bootstrap"` |
| +} |
| + |
| +// systemBootstrapRow is bootstrap/carlos, the binary a new box boots, |
| +// against stable's signed linux-arm64 artifact (issue #316). nil when there |
| +// is nothing to compare, or the console predates the field. |
| +type systemBootstrapRow struct { |
| + Version string `json:"version"` // stable's version |
| + Key string `json:"key"` // stable's mirrored linux-arm64 artifact |
| + State string `json:"state"` // current, behind or unreadable |
| + Error string `json:"error"` |
| } |
| // ---- carlos system update ---------------------------------------------- |
| @@ -495,6 +506,16 @@ func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string) { |
| fmt.Fprintln(w, strings.Join(parts, " ")) |
| } |
| } |
| + // What a new box would boot (issue #316): quiet unless it is not stable's |
| + // build, and only where stable is shown. |
| + if b := resp.Bootstrap; b != nil && (channel == "" || channel == "stable") { |
| + switch b.State { |
| + case "behind": |
| + fmt.Fprintln(w, c.warn(fmt.Sprintf("bootstrap/carlos is not stable's build (%s), so a new box would boot a different one. Copy %s over it.", b.Version, b.Key))) |
| + case "unreadable": |
| + fmt.Fprintln(w, c.warn("bootstrap/carlos could not be read, so this can't say what a new box would boot: "+b.Error)) |
| + } |
| + } |
| fmt.Fprintln(w) |
| if resp.Partial { |
| fmt.Fprintln(w, c.warn("fleet boxes could not all be read; rows may be missing")) |
diff --git a/system_test.go b/system_test.go| index 9ae34db54..68d95bc74 100644 |
| --- a/system_test.go |
| +++ b/system_test.go |
| @@ -297,3 +297,38 @@ func TestSystemFilesSeedCommandEntryRefusesBeforeEffects(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// Issue #316: status says when bootstrap/carlos is not stable's build, and is |
| +// quiet when it is, when there is nothing to compare, and under --channel |
| +// canary. |
| +func TestPrintSystemStatusSaysWhenBootstrapLags(t *testing.T) { |
| + stable := map[string]*systemPointerRow{"stable": {Version: "v0.28.0"}} |
| + behind := "bootstrap/carlos is not stable's build (v0.28.0), so a new box would boot a different one. Copy bootstrap/router/v0.28.0/carlos-linux-arm64 over it." |
| + for _, tc := range []struct { |
| + name, channel, want string |
| + row *systemBootstrapRow |
| + }{ |
| + {"behind", "", behind, &systemBootstrapRow{Version: "v0.28.0", Key: "bootstrap/router/v0.28.0/carlos-linux-arm64", State: "behind"}}, |
| + {"behind, stable only", "stable", behind, &systemBootstrapRow{Version: "v0.28.0", Key: "bootstrap/router/v0.28.0/carlos-linux-arm64", State: "behind"}}, |
| + {"unreadable", "", "bootstrap/carlos could not be read, so this can't say what a new box would boot: access denied", |
| + &systemBootstrapRow{Version: "v0.28.0", State: "unreadable", Error: "access denied"}}, |
| + {"current", "", "", &systemBootstrapRow{Version: "v0.28.0", State: "current"}}, |
| + {"nothing to compare", "", "", nil}, |
| + {"canary only", "canary", "", &systemBootstrapRow{Version: "v0.28.0", State: "behind"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + var b strings.Builder |
| + printSystemStatus(&b, systemStatusResponse{Pointers: stable, Bootstrap: tc.row}, tc.channel) |
| + out := b.String() |
| + if tc.want == "" { |
| + if strings.Contains(out, "bootstrap/carlos") { |
| + t.Fatalf("output mentions bootstrap/carlos:\n%s", out) |
| + } |
| + return |
| + } |
| + if !strings.Contains(out, tc.want) { |
| + t.Fatalf("output lacks %q:\n%s", tc.want, out) |
| + } |
| + }) |
| + } |
| +} |