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 // metrics block (nil writes a heartbeat with no metrics at all). func seedSettingsBox(t *testing.T, fx *consoleFixture, label string, at time.Time, m *status.BoxMetrics) { t.Helper() b := status.CollectBox(nil, status.Probes{Release: "v1"}, at) b.Label = label b.Metrics = m if err := status.WriteBox(context.Background(), fx.rel, b); err != nil { t.Fatal(err) } } func TestUpdatesEnforcedNeedsEveryBox(t *testing.T) { fx := newConsoleFixture(t) seedEdgesRecord(t, fx, "ie-1") seedEdgesRecord(t, fx, "uk-1") now := time.Now() seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{}) ok, blocking, err := fx.srv.updatesEnforced(context.Background()) if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "uk-1") { t.Fatalf("one old binary: %v %q %v", ok, blocking, err) } seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) if ok, _, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil { t.Fatalf("every box enforces: %v %v", ok, err) } } // Review Focus 4. func TestUpdatesEnforcedNamesAStaleBox(t *testing.T) { fx := newConsoleFixture(t) seedEdgesRecord(t, fx, "ie-1") seedSettingsBox(t, fx, "ie-1", time.Now().Add(-3*status.SnapshotPeriod), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) ok, blocking, _ := fx.srv.updatesEnforced(context.Background()) if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") { t.Fatalf("a stale box: %v %q", ok, blocking) } } func TestUpdatesEnforcedNeedsAHeartbeat(t *testing.T) { fx := newConsoleFixture(t) seedEdgesRecord(t, fx, "ie-1") if ok, blocking, _ := fx.srv.updatesEnforced(context.Background()); ok || len(blocking) != 1 { t.Fatalf("an expected box with no heartbeat: %v %q", ok, blocking) } } // An empty inventory has seen no box at all, so it cannot vouch that every // box enforces: it blocks rather than reading as "nothing to wait on". func TestUpdatesEnforcedNeedsSomeBox(t *testing.T) { fx := newConsoleFixture(t) if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); ok || err != nil || len(blocking) != 1 { t.Fatalf("no box known: %v %q %v", ok, blocking, err) } } // 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. func TestUpdatesEnforcedBoxShapes(t *testing.T) { fx := newConsoleFixture(t) now := time.Now() seedEdgesRecord(t, fx, "ie-1") seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) seedSettingsBox(t, fx, "old-1", now.Add(-time.Hour), &status.BoxMetrics{}) if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil { t.Fatalf("a retired box blocked: %v %q %v", ok, blocking, err) } seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{}) ok, blocking, _ := fx.srv.updatesEnforced(context.Background()) if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "new-1") { t.Fatalf("an unlisted fresh old binary: %v %q", ok, blocking) } seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) seedSettingsBox(t, fx, "ie-1", now, nil) ok, blocking, _ = fx.srv.updatesEnforced(context.Background()) if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") { t.Fatalf("a heartbeat with no metrics: %v %q", ok, blocking) } }