| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostfloor" |
| 10 | "github.com/carlosframework/platform/internal/hostwrite" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 14 | "github.com/carlosframework/platform/internal/rootstate" |
| 15 | ) |
| 16 | |
| 17 | func TestPlan14Fix2LiveSafety(t *testing.T) { |
| 18 | if !plan14RootFixture(t) { |
| 19 | return |
| 20 | } |
| 21 | for _, scenario := range []string{"console update with empty history", "two consecutive updates", "incident recipe", "failed forward fix", "crash rollback", "successful restart crash before settle"} { |
| 22 | t.Run(scenario, func(t *testing.T) { |
| 23 | e, rt, base, c, _ := plan14AdoptionFixture(t) |
| 24 | u := &r2Units{plan14RestartUnits: base} |
| 25 | c.legacy, c.legacyReady = legacyRootRestart, c.ready |
| 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 | if err := e.root.reg.Upsert(rt); err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | source := productionProofBindingSource(e.store, u, u.opt) |
| 34 | source.namedPath = filepath.Join(t.TempDir(), "named-units.json") |
| 35 | source.unitDirs = []string{filepath.Dir(u.props.FragmentPath)} |
| 36 | source.healthy = nil |
| 37 | makeProofAncestorsPublic(t, source.namedPath) |
| 38 | put := func(path string, raw []byte) { |
| 39 | t.Helper() |
| 40 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := os.WriteFile(path, raw, 0644); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | makeProofAncestorsPublic(t, path) |
| 47 | } |
| 48 | put(filepath.Join(u.opt, "system/guard/console-ready.json"), []byte(release.LiveLink(u.opt, rt.Host))) |
| 49 | drop := filepath.Join(source.unitDirs[0], "console.service.d/90-host-floor.conf") |
| 50 | put(drop, []byte(hostfloor.ConsoleGuardDropIn(release.LiveLink(u.opt, rt.Host)))) |
| 51 | u.props.DropInPaths = []string{drop} |
| 52 | u.props.ExecPath = "/opt/carlos/system/guard/carlos" |
| 53 | put(u.props.ExecPath, []byte("guard")) |
| 54 | makeProofAncestorsPublic(t, u.exe) |
| 55 | makeProofAncestorsPublic(t, u.props.FragmentPath) |
| 56 | c.resolve, c.host, c.rollback, c.prepareRollback, c.dispatch, c.targetFile = source.resolve, source.forHost, source.rollback, source.prepareRollback, source.needsRestart, rootstate.OpenTrustedFile |
| 57 | pub, key, err := ed25519.GenerateKey(nil) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if err := e.store.Delete(t.Context(), release.ChannelKey("bab", "console", rt.Channel)); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | promote := func(v string) { |
| 65 | t.Helper() |
| 66 | if err := release.Promote(t.Context(), e.store, key, "bab", "console", rt.Channel, v); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | } |
| 70 | promote("v1") |
| 71 | promote("v2") |
| 72 | if scenario == "crash rollback" { |
| 73 | u.crashNew = true |
| 74 | } |
| 75 | if scenario == "failed forward fix" { |
| 76 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 77 | } |
| 78 | if scenario == "incident recipe" { |
| 79 | if err := release.StageArtifact(u.opt, "", "v2", "console", []byte("new build")); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if err := release.PointLive(u.opt, rt.Host, "", "v2", "console"); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if err := e.root.reg.SetVersions(rt.Host, "v2", "v1"); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | u.exe = release.VersionPath(u.opt, "", "v2", "console") |
| 89 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 90 | promote("v1") |
| 91 | } |
| 92 | restart := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| 93 | rollback := rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c) |
| 94 | if scenario == "successful restart crash before settle" { |
| 95 | u.afterEffect = func() { panic("crash") } |
| 96 | func() { |
| 97 | defer func() { |
| 98 | if recover() != "crash" { |
| 99 | t.Fatal("missing crash") |
| 100 | } |
| 101 | }() |
| 102 | _ = release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, restart, release.WithRestartFailurePropagation()) |
| 103 | }() |
| 104 | u.afterEffect = nil |
| 105 | before := len(u.effects) |
| 106 | if err := recoverRootRestarts(t.Context(), u, u.opt, func(string) string { return "5s" }, c, hostwrite.Config{}); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if len(u.effects) != before || u.exe != release.VersionPath(u.opt, "", "v2", "console") { |
| 110 | t.Fatal("recovery repeated restart", u.effects) |
| 111 | } |
| 112 | return |
| 113 | } |
| 114 | run := func() error { |
| 115 | return release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, restart, release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollback)) |
| 116 | } |
| 117 | err = run() |
| 118 | if err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | want := "v2" |
| 122 | if scenario == "incident recipe" { |
| 123 | want = "v1" |
| 124 | } |
| 125 | if scenario == "crash rollback" || scenario == "incident recipe" || scenario == "failed forward fix" { |
| 126 | // Main cannot settle a guard ExecStart against the live-link |
| 127 | // stamp. A stopped unit needs the operator's reset and start. |
| 128 | if u.props.MainPID != 0 { |
| 129 | t.Fatal("main path woke a stopped guard unit", u.effects) |
| 130 | } |
| 131 | if live, err := release.LiveVersion(u.opt, rt.Host); err != nil || live != want { |
| 132 | t.Fatal("main pointer result", live, err) |
| 133 | } |
| 134 | if scenario == "crash rollback" { |
| 135 | return |
| 136 | } |
| 137 | if err := u.ResetFailed(rt.Unit); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if err := u.Start(rt.Unit); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | } |
| 144 | if u.props.MainPID <= 0 || u.exe != release.VersionPath(u.opt, "", want, "console") { |
| 145 | t.Fatalf("not serving %s: %+v", want, u) |
| 146 | } |
| 147 | if scenario == "two consecutive updates" { |
| 148 | m := release.Manifest{App: "console", Version: "v3", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| 149 | if err := release.Ship(t.Context(), e.store, "bab", m, map[string][]byte{release.AdoptPlatform(): []byte("third build")}); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | promote("v3") |
| 153 | if err := run(); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | if u.exe != release.VersionPath(u.opt, "", "v3", "console") || len(u.effects) != 2 { |
| 157 | t.Fatal("second update", u.effects, u.exe) |
| 158 | } |
| 159 | } |
| 160 | }) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestPlan14Fix2PlatformUpdateClaimsOffNoCensus(t *testing.T) { |
| 165 | if !plan14RootFixture(t) { |
| 166 | return |
| 167 | } |
| 168 | for _, flag := range []string{"", "1"} { |
| 169 | t.Run("flag="+flag, func(t *testing.T) { |
| 170 | t.Setenv("CARLOS_HOST_CLAIM", flag) |
| 171 | l, required, err := rootparticipation.OpenForUpdate() |
| 172 | if l.State != nil { |
| 173 | l.State.Close() |
| 174 | } |
| 175 | if err != nil || required { |
| 176 | t.Fatalf("unactivated census blocked update: %v %v", required, err) |
| 177 | } |
| 178 | if legacyActivationReady(t.Context()) == nil { |
| 179 | t.Fatal("claims activated without census and verifier") |
| 180 | } |
| 181 | guard := hostfloor.Disk{Dir: t.TempDir()} |
| 182 | data := t.TempDir() |
| 183 | if err := os.Mkdir(filepath.Join(data, ".hostexec"), 0700); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if err := guard.Begin(t.Context(), "update", data); err != nil { |
| 187 | t.Fatal("real update path", err) |
| 188 | } |
| 189 | if _, err := os.Stat(filepath.Join(guard.Dir, "update-begun.json")); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | if _, err := os.Stat(rootparticipation.Namespace); !os.IsNotExist(err) { |
| 193 | t.Fatal("update created census", err) |
| 194 | } |
| 195 | }) |
| 196 | } |
| 197 | } |
| 198 | |