| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "net" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "reflect" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/hostbox" |
| 22 | "github.com/carlosframework/platform/internal/hostclaim" |
| 23 | "github.com/carlosframework/platform/internal/hostguard" |
| 24 | "github.com/carlosframework/platform/internal/hostwrite" |
| 25 | "github.com/carlosframework/platform/internal/provision" |
| 26 | "golang.org/x/sys/unix" |
| 27 | ) |
| 28 | |
| 29 | // Disk persistence and lock exclusion are real. Root ownership, mount policy, |
| 30 | // roster and authenticated process observations remain explicit test seams. |
| 31 | type servingAuthorityDisk struct { |
| 32 | *rollbackDisk |
| 33 | mu sync.Mutex |
| 34 | locks map[string]chan struct{} |
| 35 | trace *[]string |
| 36 | prefix string |
| 37 | } |
| 38 | |
| 39 | func (s *servingAuthorityDisk) Lock(ctx context.Context, name string) (func(), error) { |
| 40 | s.mu.Lock() |
| 41 | if s.locks == nil { |
| 42 | s.locks = make(map[string]chan struct{}) |
| 43 | } |
| 44 | lock := s.locks[name] |
| 45 | if lock == nil { |
| 46 | lock = make(chan struct{}, 1) |
| 47 | s.locks[name] = lock |
| 48 | } |
| 49 | s.mu.Unlock() |
| 50 | select { |
| 51 | case lock <- struct{}{}: |
| 52 | if s.trace != nil { |
| 53 | *s.trace = append(*s.trace, s.prefix+name) |
| 54 | } |
| 55 | return func() { <-lock }, nil |
| 56 | case <-ctx.Done(): |
| 57 | return nil, ctx.Err() |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | type servingRootFixture struct { |
| 62 | t *testing.T |
| 63 | f *recoveryFixture |
| 64 | p *servingParkRoot |
| 65 | disk *servingAuthorityDisk |
| 66 | rootDisk *servingAuthorityDisk |
| 67 | conn net.Conn |
| 68 | actor hostbox.Actor |
| 69 | current, prior servingArtifactIdentity |
| 70 | admitted hostclaim.Observed |
| 71 | predecessor hostclaim.Observed |
| 72 | receipt hostguard.FenceReceipt |
| 73 | db string |
| 74 | trace []string |
| 75 | } |
| 76 | |
| 77 | // servingFixtureActor is the serving fixtures' root actor. Its binding is the |
| 78 | // fixture shorthand "7"; newServingRootFixtureFor takes a production actor. |
| 79 | var servingFixtureActor = hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enrollment", Revision: 7, Binding: "7", Eligible: true} |
| 80 | |
| 81 | func newServingRootFixture(t *testing.T, launched bool) *servingRootFixture { |
| 82 | t.Helper() |
| 83 | return newServingRootFixtureFor(t, launched, servingFixtureActor) |
| 84 | } |
| 85 | |
| 86 | // newServingRootFixtureFor stamps the installation, its stop receipt and the |
| 87 | // root's inventory actor with actor (box-1 in ie). |
| 88 | func newServingRootFixtureFor(t *testing.T, launched bool, actor hostbox.Actor) *servingRootFixture { |
| 89 | t.Helper() |
| 90 | f, _, _, in := servingRetirementFixture(t) |
| 91 | first := servingAdmit(t, f, &in, "G") |
| 92 | disk := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}, prefix: "park:"} |
| 93 | roots := &servingAuthorityDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}, prefix: "host:"} |
| 94 | f.guard.Root = hostguard.NewRootAuthority(roots) |
| 95 | journals, err := hostbox.NewJournalService(f.journal) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | fx := &servingRootFixture{t: t, f: f, disk: disk, rootDisk: roots, db: in.DBPath, actor: actor} |
| 100 | fx.prior, err = servingWriterIdentity(first, "box-1", "ie") |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | fs := newServingParkFS(f.guard.DataDir, disk) |
| 105 | fs.owner = uint32(os.Geteuid()) |
| 106 | fs.mount = func(string) (bool, error) { return true, nil } |
| 107 | fx.p = &servingParkRoot{ledger: &servingParkLedger{state: disk, fs: fs, journal: f.journal, now: func() time.Time { return f.now }}} |
| 108 | fx.p.root = ClaimRootHTTP{Roots: map[string]string{"data": f.guard.DataDir}, Journals: journals, |
| 109 | Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: f.guard, Evidence: f.claims}, Authenticate: func(context.Context, net.Conn) (StopPeer, error) { |
| 110 | return StopPeer{UID: 1001, PID: 77, ProcessStart: "edge-start", BootID: "boot", ControllerID: "controller"}, nil |
| 111 | }}, |
| 112 | InventoryActor: func(context.Context) (hostbox.Actor, error) { return fx.actor, nil }, RequireNewWork: func(context.Context) error { return nil }} |
| 113 | fx.install(first) |
| 114 | fx.tags(fx.prior.Stamp) |
| 115 | boot, err := fs.currentBoot() |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | fx.receipt = servingStopFor(t, f, f.read(), fx.actor, boot) |
| 120 | target := fx.receipt.Target |
| 121 | if err := f.journal.Prelaunch(t.Context(), hostbox.LaunchInventory{Target: target, ControllerPID: 77, ProcessStart: "edge-start", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "carlos/parent", Device: 1, Inode: 2}}}); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if launched { |
| 125 | res := hostbox.LaunchReservation{ID: "attempt:main", Role: "main", InvocationID: "attempt", Inputs: "digest", Target: target, Group: hostbox.LaunchGroup{RelativePath: "system.slice/carlos-app@shop.service"}, Executable: hostbox.LaunchFile{Device: 1, Inode: 3}} |
| 126 | if err := f.journal.IntendUnit(t.Context(), res); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if err := f.journal.StartedUnit(t.Context(), res, hostbox.LaunchGroup{RelativePath: res.Group.RelativePath, Device: 1, Inode: 4, InvocationID: "unit"}, 99, "tenant-start"); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if err := f.journal.SettleUnit(t.Context(), target, res.ID); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | } |
| 136 | o := f.read() |
| 137 | fx.predecessor = o |
| 138 | fx.admitted, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G2"})) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | fx.current, err = servingWriterIdentity(fx.admitted, "box-1", "ie") |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | for suffix, body := range map[string]string{"": "served bytes", ".gen": "G", "-wal": "WAL", "-shm": "SHM"} { |
| 147 | if err := os.WriteFile(fx.db+suffix, []byte(body), 0600); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | } |
| 151 | left, right := net.Pipe() |
| 152 | fx.conn = left |
| 153 | t.Cleanup(func() { left.Close(); right.Close() }) |
| 154 | disk.trace, roots.trace = &fx.trace, &fx.trace |
| 155 | return fx |
| 156 | } |
| 157 | func (f *servingRootFixture) install(o hostclaim.Observed) { |
| 158 | f.t.Helper() |
| 159 | h := o.Claim.Holder |
| 160 | d, err := os.Open(filepath.Dir(f.db)) |
| 161 | if err != nil { |
| 162 | f.t.Fatal(err) |
| 163 | } |
| 164 | id, err := servingParkDirID(d) |
| 165 | d.Close() |
| 166 | if err != nil { |
| 167 | f.t.Fatal(err) |
| 168 | } |
| 169 | owner := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", o.Host, h.Incarnation, h.Stamp, f.actor.Box))) |
| 170 | in := hostbox.StopInventory{Complete: true, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: f.actor.Box, Enrollment: f.actor.Enrollment, DirectoryRevision: f.actor.Binding, Entries: []hostbox.StopInventoryEntry{{Kind: "path", ManagedRoot: "data", RelativePath: "instances/" + provision.InstanceID(o.Host), ArtifactID: "state", Ownership: hex.EncodeToString(owner[:]), Materialization: "materialized", Device: id.Device, Inode: id.Inode, Type: unix.S_IFDIR}}} |
| 171 | inventory := f.p.root.Journals.Inventory |
| 172 | if err := inventory.WriteStopInventory(f.t.Context(), in); err != nil { |
| 173 | f.t.Fatal(err) |
| 174 | } |
| 175 | _, revision, err := inventory.Snapshot(f.t.Context(), o.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| 176 | if err != nil { |
| 177 | f.t.Fatal(err) |
| 178 | } |
| 179 | root := hostguard.RootInstallation{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Migration: 1, InventoryRevision: revision, Account: h.Account, App: h.App, Record: h.Key, Box: f.actor.Box, Enrollment: f.actor.Enrollment} |
| 180 | if err := f.f.guard.EstablishRoot(f.t.Context(), root, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 181 | f.t.Fatal(err) |
| 182 | } |
| 183 | } |
| 184 | func (f *servingRootFixture) tags(stamp uint64) { |
| 185 | f.t.Helper() |
| 186 | _, err := hostclaim.Update(f.t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { |
| 187 | rel, _ := filepath.Rel(f.f.guard.DataDir, f.db) |
| 188 | c.Inventory = map[string]hostclaim.InventoryEntry{f.actor.Box: {Layout: "nested", Paths: map[string]uint64{rel: stamp, rel + ".gen": stamp}}} |
| 189 | return nil |
| 190 | }) |
| 191 | if err != nil { |
| 192 | f.t.Fatal(err) |
| 193 | } |
| 194 | } |
| 195 | func (f *servingRootFixture) park() (servingParkAuthorization, error) { |
| 196 | return f.p.parkServingDatabase(f.t.Context(), f.conn, f.current, f.prior) |
| 197 | } |
| 198 | func (f *servingRootFixture) mutateRoot(edit func(map[string]any)) { |
| 199 | f.t.Helper() |
| 200 | name := f.current.Host + ".json" |
| 201 | b, err := f.rootDisk.Read(name) |
| 202 | if err != nil { |
| 203 | f.t.Fatal(err) |
| 204 | } |
| 205 | var m map[string]any |
| 206 | if err = json.Unmarshal(b, &m); err != nil { |
| 207 | f.t.Fatal(err) |
| 208 | } |
| 209 | edit(m) |
| 210 | b, err = json.Marshal(m) |
| 211 | if err != nil { |
| 212 | f.t.Fatal(err) |
| 213 | } |
| 214 | if err = f.rootDisk.Write(name, b); err != nil { |
| 215 | f.t.Fatal(err) |
| 216 | } |
| 217 | } |
| 218 | func TestServingParkRootGenerationAuthority(t *testing.T) { |
| 219 | for _, launched := range []bool{false, true} { |
| 220 | t.Run(fmt.Sprint(launched), func(t *testing.T) { |
| 221 | f := newServingRootFixture(t, launched) |
| 222 | a, err := f.park() |
| 223 | if err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | want := []string{"park:namespace.lock", "host:" + f.current.Host + ".lock", "park:slot-" + servingParkKey(f.current.Box, f.current.Host) + ".lock", "park:retirement-" + a.ID + ".lock"} |
| 227 | if !reflect.DeepEqual(f.trace, want) { |
| 228 | t.Fatalf("lock order: %v", f.trace) |
| 229 | } |
| 230 | if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err == nil { |
| 231 | t.Fatal("completed before retag") |
| 232 | } |
| 233 | f.tags(f.current.Stamp) |
| 234 | if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | r, err := f.p.ledger.read(a.ID) |
| 238 | if err != nil || !r.Complete { |
| 239 | t.Fatalf("record: %+v %v", r, err) |
| 240 | } |
| 241 | if (r.Disposition == servingParkRetain) != launched { |
| 242 | t.Fatalf("wrong disposition: %v", r.Disposition) |
| 243 | } |
| 244 | again, err := f.p.parkServingDatabase(t.Context(), f.conn, f.current, servingArtifactIdentity{}) |
| 245 | if err != nil || again != a { |
| 246 | t.Fatalf("lost completion reply: %+v %v", again, err) |
| 247 | } |
| 248 | if err = os.WriteFile(f.db, []byte("replacement"), 0600); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | if _, err = f.p.parkServingDatabase(t.Context(), f.conn, f.current, servingArtifactIdentity{}); err == nil { |
| 252 | t.Fatal("accepted replacement source") |
| 253 | } |
| 254 | }) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestServingParkRootReplaysOriginalSelectorAfterRetag(t *testing.T) { |
| 259 | for _, complete := range []bool{false, true} { |
| 260 | t.Run(fmt.Sprintf("complete=%v", complete), func(t *testing.T) { |
| 261 | f := newServingRootFixture(t, true) |
| 262 | a, err := f.park() |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | f.tags(f.current.Stamp) |
| 267 | if complete { |
| 268 | if err := f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | } |
| 272 | before, err := f.p.ledger.read(a.ID) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | f.f.now = f.f.now.Add(time.Hour) |
| 277 | again, err := f.park() |
| 278 | if err != nil || again != a { |
| 279 | t.Fatalf("original selector after retag: %+v %v", again, err) |
| 280 | } |
| 281 | for _, field := range []string{"generation", "stamp", "session", "box", "incarnation"} { |
| 282 | bad := f.prior |
| 283 | switch field { |
| 284 | case "generation": |
| 285 | bad.Generation = "foreign" |
| 286 | case "stamp": |
| 287 | bad.Stamp = f.current.Stamp |
| 288 | case "session": |
| 289 | bad.Session = f.current.Session |
| 290 | case "box": |
| 291 | bad.Box = "foreign" |
| 292 | case "incarnation": |
| 293 | bad.Incarnation = "foreign" |
| 294 | } |
| 295 | if _, err := f.p.parkServingDatabase(t.Context(), f.conn, f.current, bad); err == nil { |
| 296 | t.Fatalf("accepted changed %s", field) |
| 297 | } |
| 298 | } |
| 299 | after, err := f.p.ledger.read(a.ID) |
| 300 | if err != nil || !reflect.DeepEqual(before, after) { |
| 301 | t.Fatalf("replay changed retirement: %+v %v", after, err) |
| 302 | } |
| 303 | if err := f.p.ledger.fs.CheckParked(&after); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | }) |
| 307 | } |
| 308 | } |
| 309 | func TestServingParkRootRefusesBeforeEffects(t *testing.T) { |
| 310 | for _, attack := range []string{"peer", "peer-root", "census", "roster", "quarantine", "enrollment", "binding", "claim", "prior", "tags", "half-tags", "source", "inventory-revision", "receipt", "pending", "root-quarantine", "root-pending", "barrier", "canceled"} { |
| 311 | t.Run(attack, func(t *testing.T) { |
| 312 | f := newServingRootFixture(t, true) |
| 313 | ctx := t.Context() |
| 314 | switch attack { |
| 315 | case "peer": |
| 316 | f.p.root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, ErrClaimStopIdentity } |
| 317 | case "peer-root": |
| 318 | f.p.root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { |
| 319 | return StopPeer{PID: 77, ProcessStart: "start", BootID: "boot", ControllerID: "controller"}, nil |
| 320 | } |
| 321 | case "census": |
| 322 | f.p.root.RequireNewWork = func(context.Context) error { return hostbox.ErrChanged } |
| 323 | case "roster": |
| 324 | f.actor.Eligible = false |
| 325 | case "quarantine": |
| 326 | f.actor.Quarantined = true |
| 327 | case "enrollment": |
| 328 | f.actor.Enrollment = "other" |
| 329 | case "binding": |
| 330 | f.actor.Binding = "8" |
| 331 | case "claim": |
| 332 | f.current.Generation = "foreign" |
| 333 | case "prior": |
| 334 | f.prior.Generation = "foreign" |
| 335 | case "tags": |
| 336 | f.tags(f.current.Stamp) |
| 337 | case "half-tags": |
| 338 | _, err := hostclaim.Update(t.Context(), f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { |
| 339 | rel, _ := filepath.Rel(f.f.guard.DataDir, f.db) |
| 340 | delete(c.Inventory[f.actor.Box].Paths, rel+".gen") |
| 341 | return nil |
| 342 | }) |
| 343 | if err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | case "source": |
| 347 | if err := os.Rename(filepath.Dir(f.db), filepath.Dir(f.db)+".saved"); err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | if err := os.Mkdir(filepath.Dir(f.db), 0700); err != nil { |
| 351 | t.Fatal(err) |
| 352 | } |
| 353 | case "inventory-revision": |
| 354 | in, _, err := f.p.root.Journals.Inventory.Snapshot(t.Context(), f.prior.Host, hostguard.Captured{Incarnation: f.prior.Incarnation, Stamp: f.prior.Stamp}) |
| 355 | if err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | if err = f.p.root.Journals.Inventory.WriteStopInventory(t.Context(), in); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | case "receipt": |
| 362 | f.mutateRoot(func(m map[string]any) { |
| 363 | s := m["State"].(map[string]any) |
| 364 | s["fence_journal"].([]any)[0].(map[string]any)["bytes"] = "Zm9yZ2Vk" |
| 365 | }) |
| 366 | case "pending": |
| 367 | f.mutateRoot(func(m map[string]any) { m["State"].(map[string]any)["fence_journal"] = []any{} }) |
| 368 | case "root-quarantine": |
| 369 | f.mutateRoot(func(m map[string]any) { m["Quarantined"] = true }) |
| 370 | case "root-pending": |
| 371 | f.mutateRoot(func(m map[string]any) { |
| 372 | m["PendingInstallation"] = m["Installation"] |
| 373 | m["PendingInventory"] = "cGVuZGluZw==" |
| 374 | }) |
| 375 | case "barrier": |
| 376 | f.mutateRoot(func(m map[string]any) { m["Barrier"] = m["State"].(map[string]any)["active_fence"] }) |
| 377 | case "canceled": |
| 378 | var cancel context.CancelFunc |
| 379 | ctx, cancel = context.WithCancel(ctx) |
| 380 | cancel() |
| 381 | } |
| 382 | if _, err := f.p.parkServingDatabase(ctx, f.conn, f.current, f.prior); err == nil { |
| 383 | t.Fatal("unproved authority accepted") |
| 384 | } |
| 385 | names, err := f.disk.Names() |
| 386 | if err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | if len(names) != 0 { |
| 390 | t.Fatalf("refusal wrote private state: %v", names) |
| 391 | } |
| 392 | db := f.db |
| 393 | if attack == "source" { |
| 394 | db = filepath.Join(filepath.Dir(db)+".saved", filepath.Base(db)) |
| 395 | } |
| 396 | if b, err := os.ReadFile(db); err != nil || string(b) != "served bytes" { |
| 397 | t.Fatalf("source changed: %q %v", b, err) |
| 398 | } |
| 399 | }) |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | type servingRootClientFixture struct{ f *servingRootFixture } |
| 404 | |
| 405 | func (c servingRootClientFixture) Park(ctx context.Context, current, prior servingArtifactIdentity) (servingParkAuthorization, error) { |
| 406 | return c.f.p.parkServingDatabase(ctx, c.f.conn, current, prior) |
| 407 | } |
| 408 | func (c servingRootClientFixture) ParkLegacy(ctx context.Context, current servingArtifactIdentity, prior servingLegacyPredecessor) (servingParkAuthorization, error) { |
| 409 | return c.f.p.parkLegacyServingDatabase(ctx, c.f.conn, current, prior) |
| 410 | } |
| 411 | func (c servingRootClientFixture) Complete(ctx context.Context, id string, current servingArtifactIdentity) error { |
| 412 | return c.f.p.completeServingDatabasePark(ctx, c.f.conn, id, current) |
| 413 | } |
| 414 | |
| 415 | type servingRootWork struct { |
| 416 | g hostguard.Guard |
| 417 | o hostclaim.Observed |
| 418 | } |
| 419 | |
| 420 | func (w servingRootWork) With(ctx context.Context, _ hostwrite.Identity, fn func() error) error { |
| 421 | return w.g.With(ctx, w.o.Host, hostguard.Captured{Incarnation: w.o.Claim.Holder.Incarnation, Stamp: w.o.Claim.Holder.Stamp}, func(c *hostclaim.Claim) error { |
| 422 | if !claimSameWriter(w.o, hostclaim.Observed{Host: w.o.Host, Claim: c}, nil) { |
| 423 | return hostbox.ErrChanged |
| 424 | } |
| 425 | return nil |
| 426 | }, fn) |
| 427 | } |
| 428 | func TestServingParkRootProductionRestoreSuccessor(t *testing.T) { |
| 429 | f := newServingRootFixture(t, true) |
| 430 | plan := plan12RestoreBaseline(t, f.f) |
| 431 | f.install(f.admitted) |
| 432 | r := &claimServingArtifactRetirement{Claims: f.f.claims, Guard: f.f.guard, DataDir: f.f.guard.DataDir, Box: f.actor.Box, Region: f.actor.Region, Root: servingRootClientFixture{f}} |
| 433 | in := ClaimLaunchInputs{DBPath: f.db} |
| 434 | work := servingRootWork{f.f.guard, f.admitted} |
| 435 | restore := hostbox.ColdRestore{Reader: f.f.data, Bucket: f.f.bucket, Generation: f.current.Generation, Work: work, |
| 436 | BeforeLegacyInstall: func(ctx context.Context, _, _ string) error { return r.BeforeLegacyInstall(ctx, f.admitted, in, work) }, |
| 437 | BeforeInstallLocked: func(ctx context.Context, _, _ string) error { return r.EnrollLocked(ctx, f.admitted, in) }, |
| 438 | RetireArtifacts: func(ctx context.Context, generation string) error { return r.Retire(ctx, f.admitted, in, generation) }} |
| 439 | if err := r.Recover(t.Context(), f.admitted, in); err != nil { |
| 440 | t.Fatal(err) |
| 441 | } |
| 442 | if err := restore.Install(t.Context(), f.db, plan); err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | if got := mwReadDB(t, f.db); got != "selected recovery baseline" { |
| 446 | t.Fatal(got) |
| 447 | } |
| 448 | marker, err := hostbox.ReadGenerationMarker(f.db + ".gen") |
| 449 | if err != nil || marker != "G2" { |
| 450 | t.Fatalf("marker %q %v", marker, err) |
| 451 | } |
| 452 | names, err := f.disk.Names() |
| 453 | if err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | for _, name := range names { |
| 457 | if strings.HasPrefix(name, "retirement-") && strings.HasSuffix(name, ".json") { |
| 458 | rec, err := f.p.ledger.read(strings.TrimSuffix(strings.TrimPrefix(name, "retirement-"), ".json")) |
| 459 | if err != nil || !rec.Complete { |
| 460 | t.Fatalf("incomplete: %v", err) |
| 461 | } |
| 462 | b, err := os.ReadFile(filepath.Join(f.f.guard.DataDir, "instances", ".serving-parked", rec.Authorization.ID, filepath.Base(f.db))) |
| 463 | if err != nil || string(b) != "served bytes" { |
| 464 | t.Fatalf("parked predecessor: %q %v", b, err) |
| 465 | } |
| 466 | } |
| 467 | } |
| 468 | } |
| 469 | func TestServingParkRootWaitReauthenticates(t *testing.T) { |
| 470 | f := newServingRootFixture(t, true) |
| 471 | unlock, err := f.disk.Lock(t.Context(), "namespace.lock") |
| 472 | if err != nil { |
| 473 | t.Fatal(err) |
| 474 | } |
| 475 | ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) |
| 476 | defer cancel() |
| 477 | if _, err = f.p.parkServingDatabase(ctx, f.conn, f.current, f.prior); !errors.Is(err, context.DeadlineExceeded) { |
| 478 | t.Fatalf("lock bypass: %v", err) |
| 479 | } |
| 480 | unlock() |
| 481 | f.p.root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, ErrClaimStopIdentity } |
| 482 | if _, err = f.park(); !errors.Is(err, ErrClaimStopIdentity) { |
| 483 | t.Fatalf("stale authentication: %v", err) |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | func TestServingParkRootContinuesOriginalRetirement(t *testing.T) { |
| 488 | for _, point := range []string{"rename", "parked", "retag", "lost-continuation"} { |
| 489 | t.Run(point, func(t *testing.T) { |
| 490 | f := newServingRootFixture(t, true) |
| 491 | original := f.current |
| 492 | fs := f.p.ledger.fs.(*servingParkFS) |
| 493 | rename := fs.rename |
| 494 | if point == "rename" { |
| 495 | once := false |
| 496 | fs.rename = func(a int, b string, c int, d string, e uint) error { |
| 497 | err := rename(a, b, c, d, e) |
| 498 | if err == nil && !once { |
| 499 | once = true |
| 500 | return errors.New("lost rename reply") |
| 501 | } |
| 502 | return err |
| 503 | } |
| 504 | } |
| 505 | a, err := f.park() |
| 506 | if point == "rename" { |
| 507 | if err == nil { |
| 508 | t.Fatal("fault missed") |
| 509 | } |
| 510 | fs.rename = rename |
| 511 | a = servingParkAuthorization{ID: servingRetirementID(f.prior, original, false)} |
| 512 | } else if err != nil { |
| 513 | t.Fatal(err) |
| 514 | } |
| 515 | before, err := f.p.ledger.read(a.ID) |
| 516 | if err != nil { |
| 517 | t.Fatal(err) |
| 518 | } |
| 519 | if point == "retag" || point == "lost-continuation" { |
| 520 | f.tags(original.Stamp) |
| 521 | } |
| 522 | f.install(f.f.read()) |
| 523 | servingStop(t, f.f, f.f.read()) |
| 524 | o := f.f.read() |
| 525 | later, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G3"})) |
| 526 | if err != nil { |
| 527 | t.Fatal(err) |
| 528 | } |
| 529 | f.current, err = servingWriterIdentity(later, "box-1", "ie") |
| 530 | if err != nil { |
| 531 | t.Fatal(err) |
| 532 | } |
| 533 | f.f.now = f.f.now.Add(2 * time.Hour) |
| 534 | requested := f.prior |
| 535 | if point == "retag" || point == "lost-continuation" { |
| 536 | requested = original |
| 537 | } |
| 538 | if point == "lost-continuation" { |
| 539 | disk := &servingParkFaultStore{Store: f.disk, failAt: 1} |
| 540 | f.p.ledger.state = disk |
| 541 | if _, err = f.p.parkServingDatabase(t.Context(), f.conn, f.current, requested); err == nil { |
| 542 | t.Fatal("lost continuation reply missed") |
| 543 | } |
| 544 | f.p.ledger.state = f.disk |
| 545 | } |
| 546 | continued, err := f.p.parkServingDatabase(t.Context(), f.conn, f.current, requested) |
| 547 | if err != nil { |
| 548 | t.Fatal(err) |
| 549 | } |
| 550 | if continued.ID != a.ID || continued.OriginalSuccessor != original || continued.Successor != f.current || continued.Predecessor != f.prior { |
| 551 | t.Fatalf("relabelled retirement: %+v", continued) |
| 552 | } |
| 553 | f.tags(f.current.Stamp) |
| 554 | if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, f.current); err != nil { |
| 555 | t.Fatal(err) |
| 556 | } |
| 557 | after, err := f.p.ledger.read(a.ID) |
| 558 | if err != nil { |
| 559 | t.Fatal(err) |
| 560 | } |
| 561 | if after.Authorization != before.Authorization || len(after.Continuations) != 1 || !after.Complete { |
| 562 | t.Fatalf("history: %+v", after) |
| 563 | } |
| 564 | if !before.ParkedAt.IsZero() && (!before.ParkedAt.Equal(after.ParkedAt) || !before.ReapAfter.Equal(after.ReapAfter)) { |
| 565 | t.Fatal("continuation reset retention") |
| 566 | } |
| 567 | if err = f.p.completeServingDatabasePark(t.Context(), f.conn, a.ID, original); err == nil { |
| 568 | t.Fatal("old successor completed continuation") |
| 569 | } |
| 570 | again, err := f.p.parkServingDatabase(t.Context(), f.conn, f.current, servingArtifactIdentity{}) |
| 571 | if err != nil || again != continued { |
| 572 | t.Fatalf("replay: %+v %v", again, err) |
| 573 | } |
| 574 | again, err = f.p.parkServingDatabase(t.Context(), f.conn, f.current, requested) |
| 575 | if err != nil || again != continued { |
| 576 | t.Fatalf("original continuation selector: %+v %v", again, err) |
| 577 | } |
| 578 | }) |
| 579 | } |
| 580 | } |
| 581 | |
| 582 | func TestServingParkRootRechecksClaimAfterInputs(t *testing.T) { |
| 583 | f := newServingRootFixture(t, true) |
| 584 | original := f.p.root.Stop.Authenticate |
| 585 | f.p.root.Stop.Authenticate = func(ctx context.Context, conn net.Conn) (StopPeer, error) { |
| 586 | _, err := hostclaim.Update(ctx, f.f.claims, f.f.read(), func(c *hostclaim.Claim) error { c.Inventory = nil; return nil }) |
| 587 | if err != nil { |
| 588 | return StopPeer{}, err |
| 589 | } |
| 590 | return original(ctx, conn) |
| 591 | } |
| 592 | if _, err := f.park(); err == nil { |
| 593 | t.Fatal("used claim captured before peer and inventory checks") |
| 594 | } |
| 595 | names, err := f.disk.Names() |
| 596 | if err != nil || len(names) != 0 { |
| 597 | t.Fatalf("effects: %v %v", names, err) |
| 598 | } |
| 599 | } |
| 600 | func TestServingParkRootContinuationNeedsStop(t *testing.T) { |
| 601 | f := newServingRootFixture(t, true) |
| 602 | a, err := f.park() |
| 603 | if err != nil { |
| 604 | t.Fatal(err) |
| 605 | } |
| 606 | f.install(f.admitted) |
| 607 | original := f.current |
| 608 | o := f.f.read() |
| 609 | next, err := hostclaim.Update(t.Context(), f.f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, Box: "box-1", Region: "ie", PredecessorRegion: "ie", Mode: "cold", NewGeneration: "G3"})) |
| 610 | if err != nil { |
| 611 | t.Fatal(err) |
| 612 | } |
| 613 | f.current, err = servingWriterIdentity(next, "box-1", "ie") |
| 614 | if err != nil { |
| 615 | t.Fatal(err) |
| 616 | } |
| 617 | if _, err = f.park(); err == nil { |
| 618 | t.Fatal("continued without original successor stop") |
| 619 | } |
| 620 | rec, err := f.p.ledger.read(a.ID) |
| 621 | if err != nil || rec.activeAuthorization().Successor != original || len(rec.Continuations) != 0 { |
| 622 | t.Fatalf("changed record: %+v %v", rec, err) |
| 623 | } |
| 624 | } |
| 625 | |