package console import ( "context" "encoding/json" "fmt" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) func TestAPIInstanceListCarriesTheRollout(t *testing.T) { fx, token := instanceFixture(t, "code-inst-rollout") enableInstances(t, fx, "titogo", titoManifest()) u := instancesURL(fx, "bab", "titogo") for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} { if code, _, out := rawReq(t, http.MethodPost, u, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated { t.Fatalf("create %s = %d %s", h, code, out) } } box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{ Hosts: map[string]status.HostRollout{ "acme.go.tito.io": {Account: "bab", App: "titogo", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, Attempt: status.AttemptRunning, AttemptBuild: "v2"}, // A report naming another app under this host: never joined. "beta.go.tito.io": {Account: "bab", App: "other", Installed: "v9", Serving: "v9"}, }, Apps: map[string]status.AppRollout{"bab/titogo": {Running: 1}}, }} if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { t.Fatal(err) } code, _, out := rawReq(t, http.MethodGet, u, token, "") if code != http.StatusOK { t.Fatalf("list = %d %s", code, out) } var list struct { Instances []struct { Host string `json:"host"` Rollout *rolloutView `json:"rollout"` } `json:"instances"` } if err := json.Unmarshal([]byte(out), &list); err != nil { t.Fatal(err) } got := map[string]*rolloutView{} for _, r := range list.Instances { got[r.Host] = r.Rollout } a := got["acme.go.tito.io"] if a == nil || a.Box != box.Label || a.Installed != "v2" || a.Serving != "v1" || a.Attempt != status.AttemptRunning || a.SchedulerIdle { t.Fatalf("acme rollout = %+v", a) } if got["beta.go.tito.io"] != nil { t.Fatalf("beta joined another app's report: %+v", got["beta.go.tito.io"]) } } func TestJoinBoxesIdleNeedsNothingQueuedRunningOrSettling(t *testing.T) { idle := func(a status.AppRollout) bool { b := status.Box{GeneratedAt: time.Now(), Label: "x", Metrics: &status.BoxMetrics{Rollout: &status.RolloutReport{ Hosts: map[string]status.HostRollout{"h": {Account: "bab", App: "shop", Installed: "v1"}}, Apps: map[string]status.AppRollout{"bab/shop": a}, }}} return joinBoxes([]status.Box{b}, time.Now()).rollout("h", "bab", "shop").SchedulerIdle } if !idle(status.AppRollout{}) { t.Error("an empty scheduler is not idle") } for _, a := range []status.AppRollout{{Queued: 1}, {Running: 1}, {Settling: 1}} { if idle(a) { t.Errorf("%+v reads as idle", a) } } } // Plan 5 reports every host a failure record holds as failed. Spec ยง9.1: // the one the build failed on is failed, its siblings are held. func TestJoinBoxesTellsFailedFromHeld(t *testing.T) { failed := status.HostRollout{Account: "bab", App: "shop", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, Attempt: status.AttemptFailed, AttemptBuild: "v2", HeldBy: "v2", Reason: status.ReasonHealth} b := status.Box{GeneratedAt: time.Now(), Label: "x", Metrics: &status.BoxMetrics{Rollout: &status.RolloutReport{ Hosts: map[string]status.HostRollout{"a": failed, "b": failed}, Apps: map[string]status.AppRollout{"bab/shop": {}}, Failures: []status.RolloutFailure{{Account: "bab", App: "shop", Build: "v2", Host: "a", Reason: status.ReasonHealth}}, }}} j := joinBoxes([]status.Box{b}, time.Now()) if a := j.rollout("a", "bab", "shop"); a.Attempt != status.AttemptFailed || a.AttemptBuild != "v2" || a.HeldBy != "v2" { t.Errorf("a, where v2 failed = %+v", a) } if sib := j.rollout("b", "bab", "shop"); sib.Attempt != "" || sib.AttemptBuild != "" || sib.HeldBy != "v2" || sib.Reason != status.ReasonHealth { t.Errorf("b, held by a's failure = %+v", sib) } } func TestAPIPromoteHeldSaysWhenItLifts(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } shipVersion(t, fx, "titogo", "abc1234") seedChannel(t, fx, "titogo", "edge", "abc1234") if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "titogo", true, "test"); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-held-until") before := time.Now() code, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", map[string]string{"version": "abc1234", "channel": "beta"}, token) if code != http.StatusOK || !held(t, body) { t.Fatalf("beta promote = %d %s", code, body) } var out struct { HeldUntil string `json:"held_until"` } if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatal(err) } until, err := time.Parse(time.RFC3339, out.HeldUntil) if err != nil { t.Fatalf("held_until = %q: %v", out.HeldUntil, err) } window := release.HoldFor(release.DefaultHolds, "beta") if until.Before(before.Add(window).Add(-time.Minute)) || until.After(time.Now().Add(window).Add(time.Minute)) { t.Fatalf("held_until = %s, want about now + %s", until, window) } code, body = postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", map[string]string{"version": "abc1234", "channel": "canary/x"}, token) var canary struct { HeldUntil string `json:"held_until"` } if code != http.StatusOK || held(t, body) || json.Unmarshal([]byte(body), &canary) != nil || canary.HeldUntil != "" { t.Fatalf("canary promote = %d %s", code, body) } } // A box that armed nothing says why (CARLOS_SWAP is on but the scheduler could // not arm): its Hosts map is empty, so the join answers from the box's route // versions with the reason code and nothing else. func TestJoinBoxesCarriesTheRefusalOfAnUnavailableBox(t *testing.T) { b := status.Box{GeneratedAt: time.Now(), Label: "refusing", Metrics: &status.BoxMetrics{ RouteVersions: map[string]string{"h": "v3"}, Rollout: &status.RolloutReport{Unavailable: "no-deploy-store"}, }} got := joinBoxes([]status.Box{b}, time.Now()).rollout("h", "bab", "shop") if got == nil || got.Box != "refusing" || got.Installed != "v3" || got.Unavailable != "no-deploy-store" || got.Serving != "" || got.Attempt != "" || got.SchedulerIdle { t.Fatalf("refusing box rollout = %+v", got) } if joinBoxes([]status.Box{b}, time.Now()).rollout("other", "bab", "shop") != nil { t.Error("a host the refusing box does not list joined a rollout") } raw, err := json.Marshal(got) if err != nil || !strings.Contains(string(raw), `"unavailable":"no-deploy-store"`) { t.Fatalf("wire = %s, %v", raw, err) } } // A box with no rollout report (CARLOS_SWAP off, or from before plan 5) joins // nothing: the flag-off wire is unchanged. func TestJoinBoxesNilReportJoinsNothing(t *testing.T) { b := status.Box{GeneratedAt: time.Now(), Label: "old", Metrics: &status.BoxMetrics{ RouteVersions: map[string]string{"h": "v3"}, }} if got := joinBoxes([]status.Box{b}, time.Now()).rollout("h", "bab", "shop"); got != nil { t.Fatalf("nil report joined %+v", got) } } // Astra plan 6 R1 #4: the list says when its rollout join could not read // every box's report (rollout_incomplete), so the deploy watch never reads a // missing report as "this box does not report". The flag is additive: a // complete join leaves the key off, and the wire is as before. func TestAPIInstanceListSaysWhenTheRolloutJoinIsIncomplete(t *testing.T) { fx, token := instanceFixture(t, "code-inst-incomplete") enableInstances(t, fx, "titogo", titoManifest()) u := instancesURL(fx, "bab", "titogo") if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { t.Fatalf("create = %d %s", code, out) } box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Apps: map[string]status.AppRollout{"bab/titogo": {}}}} if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { t.Fatal(err) } incomplete := func() (bool, bool) { t.Helper() code, _, out := rawReq(t, http.MethodGet, u, token, "") if code != http.StatusOK { t.Fatalf("list = %d %s", code, out) } var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(out), &raw); err != nil { t.Fatal(err) } v, ok := raw["rollout_incomplete"] return ok, ok && string(v) == "true" } if present, _ := incomplete(); present { t.Fatal("a complete join carries rollout_incomplete") } if _, err := fx.rel.Put(context.Background(), "status/boxes/broken.json", []byte("{not json"), ""); err != nil { t.Fatal(err) } if _, set := incomplete(); !set { t.Fatal("a join that skipped an unreadable box snapshot does not say rollout_incomplete") } } // A box's transient word reaches the member as "transient": true, and an // ordinary host carries no such key. func TestAPIInstanceListCarriesATransientHost(t *testing.T) { fx, token := instanceFixture(t, "code-inst-transient") enableInstances(t, fx, "titogo", titoManifest()) u := instancesURL(fx, "bab", "titogo") for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} { if code, _, out := rawReq(t, http.MethodPost, u, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated { t.Fatalf("create %s = %d %s", h, code, out) } } box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{ Hosts: map[string]status.HostRollout{ "acme.go.tito.io": {Account: "bab", App: "titogo", Installed: "v2", Good: "v1", Transient: true}, "beta.go.tito.io": {Account: "bab", App: "titogo", Installed: "v2", Serving: "v2", Good: "v2", Awake: true}, }, Apps: map[string]status.AppRollout{"bab/titogo": {}}, }} if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { t.Fatal(err) } code, _, out := rawReq(t, http.MethodGet, u, token, "") if code != http.StatusOK { t.Fatalf("list = %d %s", code, out) } var list struct { Instances []struct { Host string `json:"host"` Rollout json.RawMessage `json:"rollout"` } `json:"instances"` } if err := json.Unmarshal([]byte(out), &list); err != nil { t.Fatal(err) } got := map[string]map[string]any{} for _, r := range list.Instances { var m map[string]any if err := json.Unmarshal(r.Rollout, &m); err != nil { t.Fatalf("%s rollout: %v", r.Host, err) } got[r.Host] = m } if got["acme.go.tito.io"]["transient"] != true { t.Fatalf("acme rollout = %v, want transient true", got["acme.go.tito.io"]) } if _, has := got["beta.go.tito.io"]["transient"]; has { t.Fatalf("beta rollout = %v, want no transient key", got["beta.go.tito.io"]) } }