| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strconv" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | "github.com/carlosframework/platform/internal/rootstate" |
| 18 | ) |
| 19 | |
| 20 | type plan14RestartUnits struct { |
| 21 | props unitProps |
| 22 | exe string |
| 23 | opt string |
| 24 | effects []string |
| 25 | crashNew bool |
| 26 | badRollback bool |
| 27 | failNew bool |
| 28 | afterEffect func() |
| 29 | afterRestart func() |
| 30 | } |
| 31 | |
| 32 | func (u *plan14RestartUnits) StartTime(pid int) (string, error) { return strconv.Itoa(pid), nil } |
| 33 | |
| 34 | func (u *plan14RestartUnits) Show(string) (unitProps, error) { return u.props, nil } |
| 35 | func (u *plan14RestartUnits) Exe(int) (string, error) { return u.exe, nil } |
| 36 | func (u *plan14RestartUnits) JournalTail(string, int) string { return "" } |
| 37 | func (u *plan14RestartUnits) ResetFailed(unit string) error { |
| 38 | u.effects = append(u.effects, "reset "+unit) |
| 39 | return nil |
| 40 | } |
| 41 | func (u *plan14RestartUnits) TryRestart(unit string) error { return u.Restart(unit) } |
| 42 | func (u *plan14RestartUnits) Restart(unit string) error { |
| 43 | u.effects = append(u.effects, "restart "+unit) |
| 44 | if u.afterRestart != nil { |
| 45 | u.afterRestart() |
| 46 | } |
| 47 | u.props.InvocationID += "x" |
| 48 | u.props.MainPID++ |
| 49 | u.props.ActiveState, u.props.SubState = "active", "running" |
| 50 | var err error |
| 51 | u.exe, err = filepath.EvalSymlinks(release.LiveLink(u.opt, observedHost)) |
| 52 | if u.crashNew && u.exe == release.VersionPath(u.opt, "bab", "v2", "console") { |
| 53 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 54 | } |
| 55 | if u.badRollback && u.exe == release.VersionPath(u.opt, "bab", "v1", "console") { |
| 56 | u.exe = release.VersionPath(u.opt, "bab", "v2", "console") |
| 57 | } |
| 58 | if u.failNew && u.exe == release.VersionPath(u.opt, "bab", "v2", "console") { |
| 59 | return os.ErrPermission |
| 60 | } |
| 61 | if u.afterEffect != nil { |
| 62 | u.afterEffect() |
| 63 | } |
| 64 | return err |
| 65 | } |
| 66 | |
| 67 | func TestPlan14CrashedNamedUnitRollsBack(t *testing.T) { |
| 68 | t.Parallel() |
| 69 | e, _, units, c, pub := plan14AdoptionFixture(t) |
| 70 | units.crashNew = true |
| 71 | forward := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 72 | var rollbackErr error |
| 73 | rollback := rollbackUnitRestart(e.root.reg, units, units.opt, func(string) string { return "5s" }, c) |
| 74 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, units.opt, forward, |
| 75 | release.BakeDir(e.bake), release.WithRestartFailurePropagation(), |
| 76 | release.WithRollbackRestart(func(host string) error { rollbackErr = rollback(host); return rollbackErr })) |
| 77 | if rollbackErr != nil || err == nil { |
| 78 | t.Fatalf("crash recovery: %v / %v", err, rollbackErr) |
| 79 | } |
| 80 | if len(units.effects) != 3 || units.props.MainPID <= 0 || units.exe != release.VersionPath(units.opt, "bab", "v1", "console") { |
| 81 | t.Fatalf("rollback was not verified: %+v", units) |
| 82 | } |
| 83 | if version, err := release.LiveVersion(units.opt, observedHost); err != nil || version != "v1" { |
| 84 | t.Fatalf("restored link = %q, %v", version, err) |
| 85 | } |
| 86 | if got := release.ReadHealthy(e.bake, "bab", "console"); len(got) != 1 { |
| 87 | t.Fatalf("crash recorded health: %+v", got) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func plan14AdoptionFixture(t *testing.T) (*proofRootEnv, registry.Route, *plan14RestartUnits, rootRestartChecks, ed25519.PublicKey) { |
| 92 | t.Helper() |
| 93 | e, rt, rec, source := plan14ProofFixture(t) |
| 94 | if got := e.root.observeAndRecord(rt, 15*time.Minute); got.err != nil { |
| 95 | t.Fatal(got.err) |
| 96 | } |
| 97 | source.healthy = func(acct, app, version, digest string) *release.HealthyBuild { |
| 98 | for _, b := range release.ReadHealthy(e.bake, acct, app) { |
| 99 | if b.SHA256 == digest { |
| 100 | return &b |
| 101 | } |
| 102 | } |
| 103 | return nil |
| 104 | } |
| 105 | u := &plan14RestartUnits{props: source.sys.(*fakeUnits).shows[0], exe: release.VersionPath(source.optDir, "bab", "v1", "console"), opt: source.optDir} |
| 106 | source.sys, e.root.obs.sys = u, u |
| 107 | e.root.obs.procExe = func(int) string { return u.exe } |
| 108 | rec.Pin = "v2" |
| 109 | plan14PutRecord(t, e.store, rec) |
| 110 | ctx := context.Background() |
| 111 | pub, priv, err := ed25519.GenerateKey(nil) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | for ver, body := range map[string]string{"v1": "x", "v2": "new build"} { |
| 116 | m := release.Manifest{App: "console", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| 117 | if err := release.Ship(ctx, e.store, "bab", m, map[string][]byte{release.AdoptPlatform(): []byte(body)}); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | } |
| 121 | if err := release.Promote(ctx, e.store, priv, "bab", "console", rt.Channel, "v2"); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | now, sleep := settleClock() |
| 125 | mem := &rollbackMemory{files: map[string][]byte{}} |
| 126 | c := rootRestartChecks{targetFile: os.Open, started: &sync.Map{}, attempts: func() (rootstate.Store, error) { return mem, nil }, rollback: source.rollback, prepareRollback: source.prepareRollback, resolve: source.resolve, host: source.forHost, ready: e.root.obs.ready, now: now, sleep: sleep} |
| 127 | return e, rt, u, c, pub |
| 128 | } |
| 129 | |
| 130 | func TestPlan14AdoptionCanonicalReadiness(t *testing.T) { |
| 131 | t.Parallel() |
| 132 | mutations := map[string]func(*registry.Route){ |
| 133 | "unchanged": func(*registry.Route) {}, |
| 134 | "socket": func(r *registry.Route) { r.Socket = "/run/forged.sock" }, |
| 135 | "addr": func(r *registry.Route) { r.Addr = "" }, |
| 136 | "addrs": func(r *registry.Route) { r.Addrs = "127.0.0.1:1" }, |
| 137 | "health": func(r *registry.Route) { r.Health = "" }, |
| 138 | "unit": func(r *registry.Route) { r.Unit = "" }, |
| 139 | "backing": func(r *registry.Route) { r.Backing = "exec" }, |
| 140 | "empty upstream": func(r *registry.Route) { r.Socket, r.Addr, r.Addrs, r.Health = "", "", "", "" }, |
| 141 | } |
| 142 | for name, mutate := range mutations { |
| 143 | t.Run(name, func(t *testing.T) { |
| 144 | e, rt, units, c, pub := plan14AdoptionFixture(t) |
| 145 | mutate(&rt) |
| 146 | if err := e.root.reg.Upsert(rt); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | c.ready = func(rt registry.Route) func() error { |
| 150 | if rt.Addr == "" && rt.Socket == "" { |
| 151 | return nil |
| 152 | } |
| 153 | return func() error { return os.ErrPermission } |
| 154 | } |
| 155 | e.root.obs.ready = c.ready |
| 156 | restart := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 157 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, units.opt, restart, |
| 158 | release.BakeDir(e.bake), release.WithRestartFailurePropagation(), |
| 159 | release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, units, units.opt, func(string) string { return "5s" }, c))) |
| 160 | if err == nil { |
| 161 | t.Fatal("adoption swallowed the canonical readiness refusal") |
| 162 | } |
| 163 | got, getErr := e.root.reg.Get(rt.Host) |
| 164 | if getErr != nil || got.Version == "v2" { |
| 165 | t.Fatalf("stamped adoption: %+v, %v", got, getErr) |
| 166 | } |
| 167 | if history := release.ReadHealthy(e.bake, "bab", "console"); len(history) != 1 || history[0].Version != "v1" { |
| 168 | t.Fatalf("new health: %+v", history) |
| 169 | } |
| 170 | }) |
| 171 | } |
| 172 | t.Run("health write cannot block restart", func(t *testing.T) { |
| 173 | e, _, units, c, pub := plan14AdoptionFixture(t) |
| 174 | sentinel := errors.New("settled witness") |
| 175 | restart := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, func(registry.Route) error { return sentinel }, c) |
| 176 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, units.opt, restart, release.WithRestartFailurePropagation()) |
| 177 | if err != nil { |
| 178 | t.Fatalf("health write blocked restart: %v", err) |
| 179 | } |
| 180 | }) |
| 181 | t.Run("positive adoption", func(t *testing.T) { |
| 182 | e, _, units, c, pub := plan14AdoptionFixture(t) |
| 183 | restart := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 184 | if err := release.Pass(context.Background(), e.root.reg, e.store, pub, units.opt, restart, release.WithRestartFailurePropagation()); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | rt, err := e.root.reg.Get(observedHost) |
| 188 | if err != nil || rt.Version != "v2" || len(release.ReadHealthy(e.bake, "bab", "console")) != 2 { |
| 189 | t.Fatalf("canonical adoption failed: %+v, %v", rt, err) |
| 190 | } |
| 191 | }) |
| 192 | } |
| 193 | |
| 194 | func TestPlan14RollbackCanonicalReadiness(t *testing.T) { |
| 195 | t.Parallel() |
| 196 | for _, attack := range []string{"missing", "unchanged", "account", "app", "unit", "backing", "socket", "addr", "addrs", "health", "all empty"} { |
| 197 | for _, ready := range []bool{false, true} { |
| 198 | name := attack + "/" + strconv.FormatBool(ready) |
| 199 | t.Run(name, func(t *testing.T) { |
| 200 | e, rt, units, c, pub := plan14AdoptionFixture(t) |
| 201 | c.ready = func(r registry.Route) func() error { |
| 202 | if r.Addr != "127.0.0.1:4000" || r.Health != rt.Health { |
| 203 | t.Fatalf("noncanonical rollback readiness: %+v", r) |
| 204 | } |
| 205 | return func() error { |
| 206 | if !ready || units.exe == release.VersionPath(units.opt, "bab", "v2", "console") { |
| 207 | return os.ErrPermission |
| 208 | } |
| 209 | return nil |
| 210 | } |
| 211 | } |
| 212 | forward := rootRestartRouteWith(e.root.reg, units, units.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 213 | restart := func(host string) error { |
| 214 | err := forward(host) |
| 215 | if attack == "missing" { |
| 216 | if e := e.root.reg.Delete(host); e != nil { |
| 217 | t.Fatal(e) |
| 218 | } |
| 219 | } else { |
| 220 | forged := rt |
| 221 | switch attack { |
| 222 | case "account": |
| 223 | forged.Account = "bap" |
| 224 | case "app": |
| 225 | forged.App = "other" |
| 226 | case "unit": |
| 227 | forged.Unit = "other.service" |
| 228 | case "backing": |
| 229 | forged.Backing = registry.BackingUnit |
| 230 | case "socket": |
| 231 | forged.Socket = "/run/forged.sock" |
| 232 | case "addr": |
| 233 | forged.Addr = "" |
| 234 | case "addrs": |
| 235 | forged.Addrs = "127.0.0.1:1" |
| 236 | case "health": |
| 237 | forged.Health = `{"path":"/forged"}` |
| 238 | case "all empty": |
| 239 | forged.Unit, forged.Backing, forged.Socket, forged.Addr, forged.Addrs, forged.Health = "", "", "", "", "", "" |
| 240 | } |
| 241 | if e := e.root.reg.Upsert(forged); e != nil { |
| 242 | t.Fatal(e) |
| 243 | } |
| 244 | } |
| 245 | return err |
| 246 | } |
| 247 | var rollbackErr error |
| 248 | rollback := rollbackUnitRestart(e.root.reg, units, units.opt, func(string) string { return "5s" }, c) |
| 249 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, units.opt, restart, |
| 250 | release.BakeDir(e.bake), release.WithRestartFailurePropagation(), |
| 251 | release.WithRollbackRestart(func(host string) error { rollbackErr = rollback(host); return rollbackErr })) |
| 252 | if err == nil { |
| 253 | t.Fatal("forward readiness failure disappeared") |
| 254 | } |
| 255 | if ready != (rollbackErr == nil) { |
| 256 | t.Fatalf("rollback error = %v, readiness = %v", rollbackErr, ready) |
| 257 | } |
| 258 | if len(units.effects) != 3 { |
| 259 | t.Fatalf("rollback did not use the authoritative unit: %v", units.effects) |
| 260 | } |
| 261 | if !ready && !errors.Is(err, rollbackErr) { |
| 262 | t.Fatalf("rollback failure not reported: %v / %v", err, rollbackErr) |
| 263 | } |
| 264 | if history := release.ReadHealthy(e.bake, "bab", "console"); len(history) != 1 { |
| 265 | t.Fatalf("rollback wrote health: %+v", history) |
| 266 | } |
| 267 | }) |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestPlan14BindingChangesDuringRestart(t *testing.T) { |
| 273 | t.Parallel() |
| 274 | e, _, u, c, pub := plan14AdoptionFixture(t) |
| 275 | u.afterRestart = func() { |
| 276 | rec, _, err := provision.FindRecordByHost(context.Background(), e.store, observedHost) |
| 277 | if err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | rec.RecordSeq++ |
| 281 | plan14PutRecord(t, e.store, *rec) |
| 282 | } |
| 283 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, e.root.obs.recordSettled(e.bake), c) |
| 284 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c))) |
| 285 | if err == nil || len(u.effects) != 1 || len(release.ReadHealthy(e.bake, "bab", "console")) != 1 { |
| 286 | t.Fatalf("changed binding settled: %v, %v", err, u.effects) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | func TestPlan14RollbackInvocationChangesInProbe(t *testing.T) { |
| 291 | t.Parallel() |
| 292 | e, _, u, c, pub := plan14AdoptionFixture(t) |
| 293 | u.crashNew = true |
| 294 | c.ready = func(registry.Route) func() error { return func() error { u.props.InvocationID += "raced"; return nil } } |
| 295 | forward := rootRestartRouteWith(e.root.reg, u, u.opt, func(string) string { return "5s" }, nil, c) |
| 296 | var recovered error |
| 297 | err := release.Pass(context.Background(), e.root.reg, e.store, pub, u.opt, forward, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRollbackRestart(func(host string) error { |
| 298 | recovered = rollbackUnitRestart(e.root.reg, u, u.opt, func(string) string { return "5s" }, c)(host) |
| 299 | return recovered |
| 300 | })) |
| 301 | if err == nil || recovered == nil { |
| 302 | t.Fatal("changed invocation accepted as recovery") |
| 303 | } |
| 304 | } |
| 305 | |