| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostfloor" |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/rootstate" |
| 17 | ) |
| 18 | |
| 19 | func TestPlan14NamedUnitsWithoutDeploymentRecord(t *testing.T) { |
| 20 | if !plan14RootFixture(t) { |
| 21 | return |
| 22 | } |
| 23 | for _, scenario := range []string{"console bootstrap", "console bootstrap invalid capture", "console update", "console crash", "operator update", "forged unit", "changed list", "untrusted list", "missing dropin", "captured target changed"} { |
| 24 | t.Run(scenario, func(t *testing.T) { |
| 25 | e, rt, u, c, pub := plan14AdoptionFixture(t) |
| 26 | if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | rt.Account = "" |
| 30 | rt.Addr = "127.0.0.1:8090" |
| 31 | rt.Health = `{"path":"/healthz","timeout":"2s","interval":"30s","grace":"10s","failures":3}` |
| 32 | if err := e.root.reg.Upsert(rt); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if err := os.MkdirAll("/var/lib", 0755); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if err := rootstate.EnsurePublicDir(release.RootHealthyDir); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | oldHealth := release.ReadHealthy(e.bake, "bab", "console") |
| 42 | for _, b := range oldHealth { |
| 43 | if strings.HasPrefix(scenario, "console bootstrap") { |
| 44 | continue |
| 45 | } |
| 46 | if err := release.RecordHealthy(release.RootHealthyDir, "", "console", b); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | } |
| 50 | t.Cleanup(func() { |
| 51 | if err := os.RemoveAll(release.RootHealthyDir); err != nil { |
| 52 | t.Error(err) |
| 53 | } |
| 54 | }) |
| 55 | e.bake = release.RootHealthyDir |
| 56 | s := productionProofBindingSource(e.store, u, u.opt) |
| 57 | s.namedPath = filepath.Join(t.TempDir(), "named-units.json") |
| 58 | s.unitDirs = []string{filepath.Dir(u.props.FragmentPath)} |
| 59 | s.now = c.now |
| 60 | c.attempts = productionRootRestartChecks(u, u.opt).attempts |
| 61 | c.resolve, c.host, c.rollback, c.prepareRollback, c.dispatch, c.targetFile = s.resolve, s.forHost, s.rollback, s.prepareRollback, s.needsRestart, rootstate.OpenTrustedFile |
| 62 | e.root.obs.resolve = s.resolve |
| 63 | put := func(path string, body []byte) { |
| 64 | t.Helper() |
| 65 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if err := os.WriteFile(path, body, 0644); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | makeProofAncestorsPublic(t, path) |
| 72 | } |
| 73 | if scenario == "operator update" || scenario == "changed list" || scenario == "untrusted list" { |
| 74 | u.props.Name = "operator.service" |
| 75 | rt.Unit = u.props.Name |
| 76 | raw, err := json.Marshal(struct { |
| 77 | Schema int `json:"schema"` |
| 78 | Units []namedUnit `json:"units"` |
| 79 | }{1, []namedUnit{{Host: rt.Host, App: rt.App, Unit: rt.Unit, Addr: rt.Addr, Health: &provision.Health{Path: "/healthz"}}}}) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | put(s.namedPath, raw) |
| 84 | } else if scenario != "forged unit" { |
| 85 | marker := filepath.Join(u.opt, "system", "guard", "console-ready.json") |
| 86 | put(marker, []byte(release.LiveLink(u.opt, rt.Host))) |
| 87 | dropin := filepath.Join(s.unitDirs[0], "console.service.d", "90-host-floor.conf") |
| 88 | put(dropin, []byte(hostfloor.ConsoleGuardDropIn(release.LiveLink(u.opt, rt.Host)))) |
| 89 | u.props.DropInPaths = []string{dropin} |
| 90 | u.props.ExecPath = "/opt/carlos/system/guard/carlos" |
| 91 | put(u.props.ExecPath, []byte("root guard")) |
| 92 | } |
| 93 | if strings.HasPrefix(scenario, "console bootstrap") { |
| 94 | public, key, err := ed25519.GenerateKey(nil) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(public)) |
| 99 | rel := release.SystemRelease{Version: "v1", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{release.AdoptPlatform(): {Key: release.SystemArtifactKey("v1", release.AdoptPlatform()), SHA256: strings.Repeat("a", 64)}}, ConsoleArtifacts: map[string]release.SystemArtifact{release.AdoptPlatform(): {Key: "bootstrap/console/v1/carlos-" + release.AdoptPlatform(), SHA256: oldHealth[0].SHA256}}} |
| 100 | if _, err := release.PromoteSystem(t.Context(), e.store, release.RootSigner(key), "router", "canary", rel, release.SystemPromoteOptions{}); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | wire, _, err := e.store.Get(t.Context(), release.SystemPointerKey("router", "canary")) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | put(filepath.Join(u.opt, "system", "guard", "identities", "v1.0.0.json"), wire) |
| 108 | } |
| 109 | makeProofAncestorsPublic(t, u.props.FragmentPath) |
| 110 | makeProofAncestorsPublic(t, u.exe) |
| 111 | makeProofAncestorsPublic(t, s.namedPath) |
| 112 | if scenario != "forged unit" { |
| 113 | if _, err := s.resolve(t.Context(), rt); err != nil { |
| 114 | t.Fatalf("baseline: %v", err) |
| 115 | } |
| 116 | } |
| 117 | if scenario == "console bootstrap invalid capture" { |
| 118 | put(filepath.Join(u.opt, "system", "guard", "identities", "v1.0.0.json"), []byte(`{"signature":"forged"}`)) |
| 119 | if _, err := s.resolve(t.Context(), rt); err != nil { |
| 120 | t.Fatal("root live pointer must remain authoritative without installation history", err) |
| 121 | } |
| 122 | if len(u.effects) != 0 { |
| 123 | t.Fatal("invalid capture restarted console") |
| 124 | } |
| 125 | return |
| 126 | } |
| 127 | if strings.HasPrefix(scenario, "console bootstrap") { |
| 128 | if err := e.root.obs.recordSettled(e.bake)(rt); err != nil { |
| 129 | t.Fatal("initial health", err) |
| 130 | } |
| 131 | } |
| 132 | switch scenario { |
| 133 | case "console crash": |
| 134 | u.crashNew = true |
| 135 | case "untrusted list": |
| 136 | if err := os.Chmod(s.namedPath, 0666); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | case "missing dropin": |
| 140 | u.props.DropInPaths = nil |
| 141 | case "changed list": |
| 142 | u.crashNew = true |
| 143 | u.afterEffect = func() { |
| 144 | raw, err := os.ReadFile(s.namedPath) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | put(s.namedPath, append(raw, ' ')) |
| 149 | } |
| 150 | case "captured target changed": |
| 151 | u.afterRestart = func() { put(release.VersionPath(u.opt, "", "v2", "console"), []byte("changed after capture")) } |
| 152 | } |
| 153 | // Registry metadata cannot select a unit or the readiness inputs. |
| 154 | rt.Unit = "forged.service" |
| 155 | rt.Addr = "127.0.0.1:1" |
| 156 | rt.Health = "" |
| 157 | if err := e.root.reg.Upsert(rt); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | c.ready = func(got registry.Route) func() error { |
| 161 | if got.Unit == rt.Unit || got.Addr != "127.0.0.1:8090" || got.Health == "" { |
| 162 | return func() error { return os.ErrPermission } |
| 163 | } |
| 164 | return func() error { return nil } |
| 165 | } |
| 166 | e.root.obs.ready = c.ready |
| 167 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 168 | rollback := rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c) |
| 169 | var rollbackErr error |
| 170 | err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, forward, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRollbackRestart(func(host string) error { rollbackErr = rollback(host); return rollbackErr })) |
| 171 | switch scenario { |
| 172 | case "console bootstrap", "console update", "operator update": |
| 173 | if err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if len(u.effects) != 1 || u.exe != release.VersionPath(u.opt, "", "v2", "console") { |
| 177 | t.Fatal(u.effects, u.exe) |
| 178 | } |
| 179 | if got := release.ReadHealthy(e.bake, "", "console"); len(got) != 2 { |
| 180 | t.Fatal("missing health", got) |
| 181 | } |
| 182 | if err := forward(rt.Host); err != nil { |
| 183 | t.Fatal("explicit named-unit restart", err) |
| 184 | } |
| 185 | if len(u.effects) != 2 { |
| 186 | t.Fatal("missing explicit restart", u.effects) |
| 187 | } |
| 188 | if got := release.ReadHealthy(e.bake, "", "console"); len(got) != 2 { |
| 189 | t.Fatal("explicit restart changed digest history", got) |
| 190 | } |
| 191 | case "console crash": |
| 192 | if err == nil || rollbackErr != nil || len(u.effects) != 3 || u.exe != release.VersionPath(u.opt, "", "v1", "console") { |
| 193 | t.Fatal(err, rollbackErr, u.effects, u.exe) |
| 194 | } |
| 195 | if got := release.ReadHealthy(e.bake, "", "console"); len(got) != 1 { |
| 196 | t.Fatal("crash recorded healthy", got) |
| 197 | } |
| 198 | case "forged unit", "untrusted list", "missing dropin": |
| 199 | if err == nil || len(u.effects) != 0 { |
| 200 | t.Fatal("forged authority restarted", err, u.effects) |
| 201 | } |
| 202 | default: |
| 203 | if err == nil { |
| 204 | t.Fatal("mutation succeeded") |
| 205 | } |
| 206 | } |
| 207 | }) |
| 208 | } |
| 209 | } |
| 210 | |