| index 0fd2939e4..c4630efd5 100644 |
| --- a/internal/console/updatesgate_test.go |
| +++ b/internal/console/updatesgate_test.go |
| @@ -2,11 +2,17 @@ package console |
| |
| import ( |
| "context" |
| + "net/http" |
| + "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| |
| // seedSettingsBox writes label's heartbeat at at carrying m as its private |
| @@ -66,6 +72,191 @@ func TestUpdatesEnforcedNeedsSomeBox(t *testing.T) { |
| } |
| } |
| |
| +// seedSettingsFleet registers fleet fx with one active box, bx-1, in the |
| +// fixture's registry, and (when at is not zero) writes bx-1's heartbeat |
| +// under the fleet's prefix carrying m. |
| +func seedSettingsFleet(t *testing.T, fx *consoleFixture, at time.Time, m *status.BoxMetrics) { |
| + t.Helper() |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: fx.sqid, Bucket: "fx-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-1", TokenHash: strings.Repeat("a", 64)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if at.IsZero() { |
| + return |
| + } |
| + b := status.CollectBox(nil, status.Probes{Release: "v1"}, at) |
| + b.Label = "bx-1" |
| + b.Metrics = m |
| + if err := status.WriteBox(context.Background(), release.NewPrefixStore(fx.rel, "fleets/fx/"), b); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Astra r1 #1: an unread fleet inventory is not an empty one. A console |
| +// whose registry path is unset, or names no file, has not listed the |
| +// fleets, so it cannot vouch for their boxes and the gate blocks, while the |
| +// GeoIP verdict's reading of the same inventory is unchanged (its |
| +// expected list stays complete, as before). |
| +func TestUpdatesEnforcedNeedsTheFleetRegistry(t *testing.T) { |
| + for name, path := range map[string]func(t *testing.T) string{ |
| + "unset": func(*testing.T) string { return "" }, |
| + "missing": func(t *testing.T) string { return filepath.Join(t.TempDir(), "absent-registry.db") }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedSettingsBox(t, fx, "ie-1", time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil { |
| + t.Fatalf("with the registry read: %v %q %v", ok, blocking, err) |
| + } |
| + fx.srv.cfg.Registry = path(t) |
| + ok, blocking, err := fx.srv.updatesEnforced(context.Background()) |
| + if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "the fleet registry could not be read") { |
| + t.Fatalf("registry %s: %v %q %v", name, ok, blocking, err) |
| + } |
| + inv, err := fx.srv.geoipInventory(context.Background()) |
| + if err != nil || !inv.expectedComplete || !inv.heartbeatsComplete { |
| + t.Fatalf("the GeoIP reading of an absent registry changed: %+v %v", inv, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Task 5's deferred gap: an active fleet box is expected, keyed by its |
| +// fleet, and blocks with no report, an old binary or a stale report; once |
| +// it reports the protocol the gate opens. |
| +func TestUpdatesEnforcedNeedsEveryFleetBox(t *testing.T) { |
| + now := time.Now() |
| + for name, c := range map[string]struct { |
| + at time.Time |
| + m *status.BoxMetrics |
| + want string |
| + }{ |
| + "no report": {want: "fx/bx-1: no heartbeat"}, |
| + "an old binary": {at: now, m: &status.BoxMetrics{}, want: "fx/bx-1: runs a binary that does not apply update settings"}, |
| + "a stale report": {at: now.Add(-3 * status.SnapshotPeriod), m: &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}, |
| + want: "fx/bx-1: no heartbeat since"}, |
| + "the protocol": {at: now, m: &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + seedSettingsFleet(t, fx, c.at, c.m) |
| + ok, blocking, err := fx.srv.updatesEnforced(context.Background()) |
| + if c.want == "" { |
| + if !ok || err != nil { |
| + t.Fatalf("a fleet box that applies settings: %v %q %v", ok, blocking, err) |
| + } |
| + return |
| + } |
| + if err != nil || ok || len(blocking) != 1 || !strings.HasPrefix(blocking[0], c.want) { |
| + t.Fatalf("want %q blocking: %v %q %v", c.want, ok, blocking, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Task 5's deferred gap: a heartbeat record that cannot be read leaves the |
| +// read incomplete, and an incomplete read blocks even when every box that |
| +// was read applies settings. |
| +func TestUpdatesEnforcedBlocksAnIncompleteRead(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedSettingsBox(t, fx, "ie-1", time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + if _, err := fx.rel.Put(context.Background(), "status/boxes/zz-1.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + ok, blocking, err := fx.srv.updatesEnforced(context.Background()) |
| + if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "could not be read in full") { |
| + t.Fatalf("an unreadable heartbeat: %v %q %v", ok, blocking, err) |
| + } |
| +} |
| + |
| +// Astra r1 #1 through the doors: whenever the gate cannot vouch for every |
| +// box (no registry path, a registry file that is not there, a fleet box with |
| +// no report, an old binary or a stale report, an unreadable heartbeat), both |
| +// enabling doors refuse with the not-enforced 409 and write nothing, and |
| +// clearing through both still answers 200 in that same state. |
| +func TestEnableDoorsFailClosedOnAnUncheckedBox(t *testing.T) { |
| + now := time.Now() |
| + notEnforced := strings.SplitN(updatewords.Text("settings.console.not-enforced"), "%d", 2)[0] |
| + for name, breakIt := range map[string]func(t *testing.T, fx *updatesFx){ |
| + "registry unset": func(t *testing.T, fx *updatesFx) { fx.srv.cfg.Registry = "" }, |
| + "registry missing": func(t *testing.T, fx *updatesFx) { |
| + fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "absent-registry.db") |
| + }, |
| + "fleet box with no report": func(t *testing.T, fx *updatesFx) { |
| + seedSettingsFleet(t, fx.consoleFixture, time.Time{}, nil) |
| + }, |
| + "fleet box with an old binary": func(t *testing.T, fx *updatesFx) { |
| + seedSettingsFleet(t, fx.consoleFixture, now, &status.BoxMetrics{}) |
| + }, |
| + "fleet box with a stale report": func(t *testing.T, fx *updatesFx) { |
| + seedSettingsFleet(t, fx.consoleFixture, now.Add(-3*status.SnapshotPeriod), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + }, |
| + "an unreadable heartbeat": func(t *testing.T, fx *updatesFx) { |
| + if _, err := fx.rel.Put(context.Background(), "status/boxes/zz-1.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + two := 2 |
| + fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"}) |
| + fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", |
| + Updates: &provision.UpdatePolicy{DefaultMajor: &two}}) |
| + breakIt(t, fx) |
| + |
| + st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, notEnforced) { |
| + t.Errorf("instance door, manual: %d %s", st, out) |
| + } |
| + if rec := fx.record("a.example.com"); rec.Updates != "" { |
| + t.Errorf("a refused instance enable wrote the record: %+v", rec) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "manual"}, fx.member) |
| + if st != http.StatusConflict || !strings.Contains(out, notEnforced) { |
| + t.Errorf("policy door, default manual: %d %s", st, out) |
| + } |
| + if p := fx.manifest().Updates; p == nil || p.DefaultUpdates != "" { |
| + t.Errorf("a refused policy enable wrote the policy: %+v", p) |
| + } |
| + |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"pin": ""}, fx.member) |
| + if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" { |
| + t.Fatalf("instance door, clearing a pin: %d %s, record %+v", st, out, rec) |
| + } |
| + st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": nil}, fx.member) |
| + if p := fx.manifest().Updates; st != http.StatusOK || (p != nil && p.DefaultMajor != nil) { |
| + t.Fatalf("policy door, clearing the default major: %d %s, policy %+v", st, out, p) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The control for the test above: with the registry read and the fleet box |
| +// reporting the protocol, both doors enable. |
| +func TestEnableDoorsOpenWhenEveryFleetBoxEnforces(t *testing.T) { |
| + fx := updatesFixture(t) |
| + fx.boxesEnforce(true) |
| + seedSettingsFleet(t, fx.consoleFixture, time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK { |
| + t.Fatalf("instance door: %d %s", st, out) |
| + } |
| + if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "manual"}, fx.member); st != http.StatusOK { |
| + t.Fatalf("policy door: %d %s", st, out) |
| + } |
| +} |
| + |
| // A heartbeat with no metrics block is a binary that does not say it |
| // enforces; an unexpected box that has gone stale is retired and does not |
| // block; an unexpected fresh box with an old binary does. |