| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/status" |
| 14 | ) |
| 15 | |
| 16 | func updatesBox(label string, enforce bool, hosts map[string]status.HostUpdates, routes map[string]string) status.Box { |
| 17 | m := &status.BoxMetrics{Updates: hosts, RouteVersions: routes} |
| 18 | if enforce { |
| 19 | m.UpdateSettings = status.UpdateSettingsProtocol |
| 20 | } |
| 21 | return status.Box{Label: label, GeneratedAt: time.Now(), Metrics: m} |
| 22 | } |
| 23 | |
| 24 | func TestUpdatesJoinCarriesTheReason(t *testing.T) { |
| 25 | two := 2 |
| 26 | j := joinBoxes([]status.Box{updatesBox("ie-1", true, |
| 27 | map[string]status.HostUpdates{"a.shop.example.com": {Desired: "v1", Reason: "major", Detail: "v2", Baking: "v4", Major: &two, |
| 28 | Pending: 7, Completed: 6, CompletedFailed: true, Account: "bab", App: "shop"}}, |
| 29 | map[string]string{"a.shop.example.com": "v1"})}, time.Now()) |
| 30 | u := j.update("a.shop.example.com", "bab", "shop") |
| 31 | if u == nil || !u.Enforced || u.Desired != "v1" || u.Reason != "major" || u.Detail != "v2" || u.Baking != "v4" || |
| 32 | u.Major == nil || *u.Major != 2 || u.Pending != 7 || u.Completed != 6 || !u.CompletedFailed { |
| 33 | t.Fatalf("join = %+v", u) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // enforcedOnly says u is the join's answer for a report it cannot attribute: |
| 38 | // whether the box enforces settings, and nothing else. |
| 39 | func enforcedOnly(u *updatesJoinView, enforced bool) bool { |
| 40 | return u != nil && *u == updatesJoinView{Enforced: enforced} |
| 41 | } |
| 42 | |
| 43 | // Astra r1 (R18): a report that names no tenant, on a box with no rollout |
| 44 | // report for the host, may be another tenant's: the hostname may have been |
| 45 | // retired and given to this one while the snapshot is still fresh. A |
| 46 | // platform box's report is no exception. The join answers only whether the |
| 47 | // box enforces settings, so an old box still reads "not enforced". |
| 48 | func TestATenantlessReportJoinsOnlyWhetherTheBoxEnforces(t *testing.T) { |
| 49 | now := time.Now() |
| 50 | secret := map[string]status.HostUpdates{"h": {Desired: "v-a-desired", Reason: "pin", Detail: "v-a-pin", Baking: "v-a-baking", Pending: 4, Completed: 3, CompletedFailed: true}} |
| 51 | for _, enforce := range []bool{true, false} { |
| 52 | if u := joinBoxes([]status.Box{updatesBox("platform", enforce, secret, map[string]string{"h": "v1"})}, now).update("h", "bab", "shop"); !enforcedOnly(u, enforce) { |
| 53 | t.Fatalf("platform box (enforces %v): tenantless report joined as %+v", enforce, u) |
| 54 | } |
| 55 | var j boxJoin |
| 56 | j.joinFleetRollouts([]status.Box{updatesBox("fleet", enforce, secret, map[string]string{"h": "v1"})}, now) |
| 57 | if u := j.update("h", "bab", "shop"); !enforcedOnly(u, enforce) { |
| 58 | t.Fatalf("fleet box (enforces %v): tenantless report joined as %+v", enforce, u) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // R18: an entry that names its own tenant needs no rollout report, and its |
| 64 | // own word wins over the rollout report's. |
| 65 | func TestAnAttributedReportNeedsNoRolloutReport(t *testing.T) { |
| 66 | now := time.Now() |
| 67 | b := updatesBox("platform", true, map[string]status.HostUpdates{"h": {Reason: "manual", Detail: "v1", Account: "bab", App: "shop"}}, map[string]string{"h": "v1"}) |
| 68 | j := joinBoxes([]status.Box{b}, now) |
| 69 | if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" || u.Detail != "v1" || !u.Enforced { |
| 70 | t.Fatalf("the named tenant = %+v", u) |
| 71 | } |
| 72 | for _, other := range [][2]string{{"cdf", "shop"}, {"bab", "blog"}} { |
| 73 | if u := j.update("h", other[0], other[1]); u != nil { |
| 74 | t.Fatalf("%s/%s joined bab/shop's report: %+v", other[0], other[1], u) |
| 75 | } |
| 76 | } |
| 77 | b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "cdf", App: "blog", Installed: "v1"}}} |
| 78 | j = joinBoxes([]status.Box{b}, now) |
| 79 | if u := j.update("h", "cdf", "blog"); u != nil { |
| 80 | t.Fatalf("the rollout report's tenant outranked the entry's own: %+v", u) |
| 81 | } |
| 82 | if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" { |
| 83 | t.Fatalf("the entry's own tenant = %+v", u) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // Review Focus 3: a box's report about a host never surfaces on another |
| 88 | // tenant's list. The box's own rollout report names the host's tenant. |
| 89 | func TestUpdatesJoinRefusesAnotherTenantsReport(t *testing.T) { |
| 90 | b := updatesBox("ie-1", true, |
| 91 | map[string]status.HostUpdates{"a.shop.example.com": {Reason: "manual"}}, nil) |
| 92 | b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| 93 | "a.shop.example.com": {Account: "cdf", App: "blog", Installed: "v1"}}} |
| 94 | j := joinBoxes([]status.Box{b}, time.Now()) |
| 95 | if u := j.update("a.shop.example.com", "bab", "shop"); u != nil { |
| 96 | t.Fatalf("another tenant's report was joined: %+v", u) |
| 97 | } |
| 98 | if u := j.update("a.shop.example.com", "bab", "blog"); u != nil { |
| 99 | t.Fatalf("another account's report was joined on a matching app: %+v", u) |
| 100 | } |
| 101 | if u := j.update("a.shop.example.com", "cdf", "blog"); u == nil || u.Reason != "manual" { |
| 102 | t.Fatalf("the named tenant lost its own report: %+v", u) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestAnOldBoxIsNotEnforced(t *testing.T) { |
| 107 | j := joinBoxes([]status.Box{updatesBox("ie-1", false, nil, map[string]string{"a.shop.example.com": "v1"})}, time.Now()) |
| 108 | if u := j.update("a.shop.example.com", "bab", "shop"); u == nil || u.Enforced { |
| 109 | t.Fatalf("an old box's host = %+v; want a view saying not enforced", u) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // A fleet box's report names no tenant unless its rollout report does, and a |
| 114 | // customer's box is not trusted to speak for a host on its own say-so: all |
| 115 | // that joins is whether the box enforces settings (R18). |
| 116 | func TestAFleetBoxReportWithoutATenantIsRefused(t *testing.T) { |
| 117 | now := time.Now() |
| 118 | fleet := updatesBox("fleet", true, map[string]status.HostUpdates{"h": {Reason: "manual"}}, map[string]string{"h": "v1"}) |
| 119 | var j boxJoin |
| 120 | j.joinFleetRollouts([]status.Box{fleet}, now) |
| 121 | if u := j.update("h", "bab", "shop"); !enforcedOnly(u, true) { |
| 122 | t.Fatalf("a tenantless fleet report was joined: %+v", u) |
| 123 | } |
| 124 | // With the tenant from its own rollout report it is this tenant's, and |
| 125 | // only this tenant's. |
| 126 | fleet.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "bab", App: "shop", Installed: "v1"}}} |
| 127 | j = boxJoin{} |
| 128 | j.joinFleetRollouts([]status.Box{fleet}, now) |
| 129 | if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" { |
| 130 | t.Fatalf("a fleet report naming this tenant = %+v", u) |
| 131 | } |
| 132 | if u := j.update("h", "cdf", "shop"); u != nil { |
| 133 | t.Fatalf("a fleet report naming bab was joined for cdf: %+v", u) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // A platform box's word wins over a fleet box's for the same host, as the |
| 138 | // rollout join's does. |
| 139 | func TestAFleetBoxNeverOverridesAPlatformBoxUpdates(t *testing.T) { |
| 140 | now := time.Now() |
| 141 | view := func(label, reason string) status.Box { |
| 142 | b := updatesBox(label, true, map[string]status.HostUpdates{"h": {Reason: reason}}, map[string]string{"h": "v1"}) |
| 143 | b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "bab", App: "shop", Installed: "v1"}}} |
| 144 | return b |
| 145 | } |
| 146 | j := joinBoxes([]status.Box{view("platform", "manual")}, now) |
| 147 | j.joinFleetRollouts([]status.Box{view("fleet", "pin")}, now) |
| 148 | if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" { |
| 149 | t.Fatalf("h = %+v, want the platform box's report", u) |
| 150 | } |
| 151 | var empty boxJoin |
| 152 | empty.joinFleetRollouts([]status.Box{view("fleet", "pin")}, now) |
| 153 | if u := empty.update("h", "bab", "shop"); u == nil || u.Reason != "pin" { |
| 154 | t.Fatalf("h on an empty join = %+v, want the fleet box's report", u) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestAStaleBoxReportsNothing(t *testing.T) { |
| 159 | b := updatesBox("ie-1", true, map[string]status.HostUpdates{"h": {Reason: "manual"}}, map[string]string{"h": "v1"}) |
| 160 | b.GeneratedAt = time.Now().Add(-24 * time.Hour) |
| 161 | if u := joinBoxes([]status.Box{b}, time.Now()).update("h", "bab", "shop"); u != nil { |
| 162 | t.Fatalf("a stale box's report was joined: %+v", u) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // The list door: update_report rides beside the record's own updates key, |
| 167 | // the other tenant's host carries none, and settings_default tells an |
| 168 | // untouched record from one that asked for a hold. |
| 169 | func TestAPIInstanceListCarriesTheUpdateReport(t *testing.T) { |
| 170 | fx, token := instanceFixture(t, "code-inst-updates") |
| 171 | enableInstances(t, fx, "titogo", titoManifest()) |
| 172 | u := instancesURL(fx, "bab", "titogo") |
| 173 | for _, h := range []string{"acme.go.tito.io", "beta.go.tito.io"} { |
| 174 | if code, _, out := rawReq(t, http.MethodPost, u, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated { |
| 175 | t.Fatalf("create %s = %d %s", h, code, out) |
| 176 | } |
| 177 | } |
| 178 | box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| 179 | box.Metrics = &status.BoxMetrics{ |
| 180 | UpdateSettings: status.UpdateSettingsProtocol, |
| 181 | RouteVersions: map[string]string{"acme.go.tito.io": "v1", "beta.go.tito.io": "v9"}, |
| 182 | Updates: map[string]status.HostUpdates{ |
| 183 | "acme.go.tito.io": {Desired: "v1", Reason: "manual", Detail: "v1"}, |
| 184 | "beta.go.tito.io": {Reason: "pin", Detail: "v9"}, |
| 185 | }, |
| 186 | Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ |
| 187 | "acme.go.tito.io": {Account: "bab", App: "titogo", Installed: "v1"}, |
| 188 | // A report naming another app under this host: never joined. |
| 189 | "beta.go.tito.io": {Account: "bab", App: "other", Installed: "v9"}, |
| 190 | }}, |
| 191 | } |
| 192 | if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | type listed struct { |
| 196 | Host string `json:"host"` |
| 197 | Updates string `json:"updates"` |
| 198 | SettingsDefault bool `json:"settings_default"` |
| 199 | UpdateReport *updatesJoinView `json:"update_report"` |
| 200 | } |
| 201 | list := func() []listed { |
| 202 | code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| 203 | if code != http.StatusOK { |
| 204 | t.Fatalf("list = %d %s", code, out) |
| 205 | } |
| 206 | var l struct { |
| 207 | Instances []listed `json:"instances"` |
| 208 | } |
| 209 | if err := json.Unmarshal([]byte(out), &l); err != nil { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | return l.Instances |
| 213 | } |
| 214 | for _, r := range list() { |
| 215 | switch r.Host { |
| 216 | case "acme.go.tito.io": |
| 217 | if r.UpdateReport == nil || r.UpdateReport.Reason != "manual" || !r.UpdateReport.Enforced { |
| 218 | t.Errorf("acme update_report = %+v", r.UpdateReport) |
| 219 | } |
| 220 | case "beta.go.tito.io": |
| 221 | if r.UpdateReport != nil { |
| 222 | t.Errorf("beta joined another app's report: %+v", r.UpdateReport) |
| 223 | } |
| 224 | if !r.SettingsDefault || r.Updates != "" { |
| 225 | t.Errorf("beta never asked for settings: default=%v updates=%q", r.SettingsDefault, r.Updates) |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // The app's policy reaches settings_default: a manual default makes every |
| 232 | // record a non-default one without the record saying so itself. |
| 233 | func TestAPIInstanceListSettingsDefaultFollowsThePolicy(t *testing.T) { |
| 234 | fx, token := instanceFixture(t, "code-inst-updates-default") |
| 235 | m := titoManifest() |
| 236 | m.Updates = &provision.UpdatePolicy{DefaultUpdates: "manual"} |
| 237 | enableInstances(t, fx, "titogo", m) |
| 238 | u := instancesURL(fx, "bab", "titogo") |
| 239 | if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| 240 | t.Fatalf("create = %d %s", code, out) |
| 241 | } |
| 242 | code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| 243 | if code != http.StatusOK { |
| 244 | t.Fatalf("list = %d %s", code, out) |
| 245 | } |
| 246 | var l struct { |
| 247 | Instances []struct { |
| 248 | SettingsDefault bool `json:"settings_default"` |
| 249 | } `json:"instances"` |
| 250 | } |
| 251 | if err := json.Unmarshal([]byte(out), &l); err != nil || len(l.Instances) != 1 { |
| 252 | t.Fatalf("decode %v: %s", err, out) |
| 253 | } |
| 254 | if l.Instances[0].SettingsDefault { |
| 255 | t.Fatalf("a manual app default read as default: %s", out) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // Astra r1 (R18), through the list door: box1 (a platform box with no |
| 260 | // rollout report, as with CARLOS_SWAP off) still reports the update state |
| 261 | // it last had for acme under its previous tenant. The hostname now belongs |
| 262 | // to bab/titogo. The list carries whether the box enforces settings and |
| 263 | // nothing of the report, and the CLI's REASON cell is empty. |
| 264 | func TestAReassignedHostNeverListsThePreviousTenantsReport(t *testing.T) { |
| 265 | fx, token := instanceFixture(t, "code-inst-updates-reassigned") |
| 266 | enableInstances(t, fx, "titogo", titoManifest()) |
| 267 | u := instancesURL(fx, "bab", "titogo") |
| 268 | if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated { |
| 269 | t.Fatalf("create = %d %s", code, out) |
| 270 | } |
| 271 | box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now()) |
| 272 | box.Metrics = &status.BoxMetrics{ |
| 273 | UpdateSettings: status.UpdateSettingsProtocol, |
| 274 | RouteVersions: map[string]string{"acme.go.tito.io": "v1"}, |
| 275 | Updates: map[string]status.HostUpdates{ |
| 276 | "acme.go.tito.io": {Desired: "v-prev-desired", Reason: "pin", Detail: "v-prev-pin", Baking: "v-prev-baking", Pending: 9, Completed: 8}, |
| 277 | }, |
| 278 | } |
| 279 | if err := status.WriteBox(context.Background(), fx.rel, box); err != nil { |
| 280 | t.Fatal(err) |
| 281 | } |
| 282 | code, _, out := rawReq(t, http.MethodGet, u, token, "") |
| 283 | if code != http.StatusOK { |
| 284 | t.Fatalf("list = %d %s", code, out) |
| 285 | } |
| 286 | for _, leak := range []string{"v-prev-desired", "v-prev-pin", "v-prev-baking", `"reason":"pin"`, `"pending"`, `"completed"`} { |
| 287 | if strings.Contains(out, leak) { |
| 288 | t.Errorf("the list leaked %s: %s", leak, out) |
| 289 | } |
| 290 | } |
| 291 | var l struct { |
| 292 | Instances []struct { |
| 293 | Host string `json:"host"` |
| 294 | UpdateReport *updatesJoinView `json:"update_report"` |
| 295 | } `json:"instances"` |
| 296 | } |
| 297 | if err := json.Unmarshal([]byte(out), &l); err != nil || len(l.Instances) != 1 { |
| 298 | t.Fatalf("decode %v: %s", err, out) |
| 299 | } |
| 300 | if r := l.Instances[0].UpdateReport; !enforcedOnly(r, true) { |
| 301 | t.Fatalf("acme update_report = %+v, want enforced and nothing else", r) |
| 302 | } |
| 303 | } |
| 304 | |