| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | func seedHostRootHistory(t *testing.T, rt registry.Route) *release.RootHistory { |
| 19 | t.Helper() |
| 20 | b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 21 | store := &rollbackMemory{files: map[string][]byte{}} |
| 22 | h := &release.RootHistory{Store: store} |
| 23 | id := sha256.Sum256([]byte(b.Account + "\x00" + b.App + "\x00" + b.Host)) |
| 24 | rawBinding, _ := json.Marshal(b) |
| 25 | raw := fmt.Sprintf(`{"schema":1,"binding":%s,"revision":1,"programs":{"revision":1,"complete":true,"values":null},"refusals":{"revision":1,"complete":true,"values":null}}`, rawBinding) |
| 26 | if err := store.Write(hex.EncodeToString(id[:])+".json", []byte(raw)); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | at := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano) |
| 30 | raw = fmt.Sprintf(`{"schema":1,"revision":1,"complete":{%q:1},"observed":%q,"clock":{"jitter":0.1,"channels":{}}}`, b.Account+"|"+b.App, at) |
| 31 | if err := store.Write("bake.json", []byte(raw)); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | id = sha256.Sum256([]byte(b.Account + "\x00" + b.App)) |
| 35 | raw = fmt.Sprintf(`{"schema":1,"account":%q,"app":%q,"revision":1,"complete":true,"hosts":[%q]}`, b.Account, b.App, b.Host) |
| 36 | if err := store.Write(hex.EncodeToString(id[:])+".app", []byte(raw)); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | return h |
| 40 | } |
| 41 | |
| 42 | func TestPlan14RootHistoryProductionPass(t *testing.T) { |
| 43 | t.Parallel() |
| 44 | for _, attack := range []string{"positive", "legacy-deleted", "legacy-recovered", "failed-ready", "record-change"} { |
| 45 | t.Run(attack, func(t *testing.T) { |
| 46 | e, rt, u, c, pub := plan14AdoptionFixture(t) |
| 47 | h := seedHostRootHistory(t, rt) |
| 48 | b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 49 | if attack == "legacy-deleted" || attack == "legacy-recovered" { |
| 50 | ptr, err := release.Channel(context.Background(), e.store, pub, rt.Account, rt.App, rt.Channel) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | ref := release.Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, PointerPromotedAt: ptr.PromotedAt, RefusedAt: time.Now(), Reason: "failed"} |
| 55 | if err := h.AddRefusal(b, ref); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if attack == "legacy-recovered" { |
| 59 | ref.Recovered = true |
| 60 | ref.Version = "v1" |
| 61 | if err := release.WriteRefusal(e.bake, ref); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | if attack == "failed-ready" { |
| 67 | c.ready = func(registry.Route) func() error { return func() error { return os.ErrPermission } } |
| 68 | } |
| 69 | if attack == "record-change" { |
| 70 | rt.Account = "bap" |
| 71 | if err := e.root.reg.Upsert(rt); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | } |
| 75 | deps := boundRootHistory(h, c.resolve, e.root.obs) |
| 76 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 77 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, |
| 78 | release.BakeDir(e.bake), release.WithRootHistory(deps), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| 79 | if attack == "positive" { |
| 80 | if err != nil || len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, "bab", "v2", "console") { |
| 81 | t.Fatalf("root positive: %v, %+v", err, u.effects) |
| 82 | } |
| 83 | } else if attack == "failed-ready" { |
| 84 | if err == nil { |
| 85 | t.Fatal("failed readiness accepted") |
| 86 | } |
| 87 | if _, err := h.Refusals(b); err == nil { |
| 88 | t.Fatal("failed update lost its pending obligation") |
| 89 | } |
| 90 | } else if len(u.effects) != 0 { |
| 91 | t.Fatalf("%s restarted: %v", attack, u.effects) |
| 92 | } |
| 93 | }) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestPlan14RootHistoryMintCensus(t *testing.T) { |
| 98 | t.Parallel() |
| 99 | for _, state := range []string{"complete", "refused", "unknown", "census-error", "missing-probe", "no-census-complete"} { |
| 100 | t.Run(state, func(t *testing.T) { |
| 101 | e, rt, _, _ := plan14ProofFixture(t) |
| 102 | h := seedHostRootHistory(t, rt) |
| 103 | e.root.refusals = h.MintRefusals |
| 104 | e.root.mintCensus = func() (bool, error) { return true, nil } |
| 105 | switch state { |
| 106 | case "refused": |
| 107 | if err := h.AddRefusal(release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App}, release.Refusal{Host: rt.Host, App: rt.App, Version: "v1", SHA256: e.sha, RefusedAt: time.Now(), Recovered: true}); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | case "unknown": |
| 111 | h.Store = &rollbackMemory{files: map[string][]byte{}} |
| 112 | case "census-error": |
| 113 | e.root.mintCensus = func() (bool, error) { return true, os.ErrPermission } |
| 114 | case "missing-probe": |
| 115 | e.root.mintCensus = nil |
| 116 | case "no-census-complete": |
| 117 | e.root.mintCensus = func() (bool, error) { return false, nil } |
| 118 | } |
| 119 | e.root.run(context.Background()) |
| 120 | if (e.proof(t) != nil) != (state == "complete") { |
| 121 | t.Fatalf("mint %s: %v", state, e.logs) |
| 122 | } |
| 123 | }) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func TestPlan14ProgramFailureStillRecordsHealth(t *testing.T) { |
| 128 | t.Parallel() |
| 129 | for _, census := range []bool{false, true} { |
| 130 | t.Run(fmt.Sprint(census), func(t *testing.T) { |
| 131 | e, _, _, _ := plan14ProofFixture(t) |
| 132 | e.root.mintCensus = func() (bool, error) { return census, nil } |
| 133 | e.root.recordProgram = func(liveBuild) error { return os.ErrPermission } |
| 134 | e.root.run(context.Background()) |
| 135 | healthy := release.ReadHealthy(e.root.historyDir(), "bab", "console") |
| 136 | if len(healthy) != 1 || healthy[0].SHA256 != e.sha { |
| 137 | t.Fatalf("program error suppressed healthy record: %+v", healthy) |
| 138 | } |
| 139 | if e.proof(t) != nil { |
| 140 | t.Fatal("mint ignored program error") |
| 141 | } |
| 142 | if !strings.Contains(strings.Join(e.logs, "\n"), "recording the installed program") { |
| 143 | t.Fatalf("missing diagnostic: %v", e.logs) |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestPlan14RootHistoryCanonicalBinding(t *testing.T) { |
| 150 | t.Parallel() |
| 151 | for _, field := range []string{"unchanged", "account", "app", "unit", "upstream", "readiness"} { |
| 152 | t.Run(field, func(t *testing.T) { |
| 153 | e, rt, _, source := plan14ProofFixture(t) |
| 154 | deps := boundRootHistory(&release.RootHistory{}, source.resolve, e.root.obs) |
| 155 | candidate := rt |
| 156 | switch field { |
| 157 | case "account": |
| 158 | candidate.Account = "bap" |
| 159 | case "app": |
| 160 | candidate.App = "other" |
| 161 | case "unit": |
| 162 | candidate.Unit = "other.service" |
| 163 | case "upstream": |
| 164 | candidate.Addr = "" |
| 165 | case "readiness": |
| 166 | candidate.Health = "" |
| 167 | } |
| 168 | b, revision, err := deps.Resolve(context.Background(), candidate) |
| 169 | if field == "unchanged" { |
| 170 | if err != nil || revision == "" || b.Account != "bab" || b.App != "console" || b.Host != rt.Host { |
| 171 | t.Fatalf("canonical binding: %+v %q %v", b, revision, err) |
| 172 | } |
| 173 | } else if err == nil { |
| 174 | t.Fatalf("forged %s accepted", field) |
| 175 | } |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestPlan14RootHistoryInstalledRequiresObservation(t *testing.T) { |
| 181 | t.Parallel() |
| 182 | for _, attack := range []string{"unchanged", "not-ready", "wrong-version", "changed-record"} { |
| 183 | t.Run(attack, func(t *testing.T) { |
| 184 | e, rt, _, source := plan14ProofFixture(t) |
| 185 | version := "v1" |
| 186 | if attack == "wrong-version" { |
| 187 | version = "v2" |
| 188 | } |
| 189 | if attack == "not-ready" { |
| 190 | e.root.obs.ready = func(registry.Route) func() error { return nil } |
| 191 | } |
| 192 | deps := boundRootHistory(&release.RootHistory{}, source.resolve, e.root.obs) |
| 193 | if attack == "changed-record" { |
| 194 | rt.Account = "bap" |
| 195 | } |
| 196 | _, err := deps.Installed(context.Background(), rt, version) |
| 197 | if attack == "unchanged" && err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if attack != "unchanged" && err == nil { |
| 201 | t.Fatalf("%s granted installed history", attack) |
| 202 | } |
| 203 | }) |
| 204 | } |
| 205 | } |
| 206 | |