| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "database/sql" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostbox" |
| 17 | "github.com/carlosframework/platform/internal/hostclaim" |
| 18 | "github.com/carlosframework/platform/internal/hostguard" |
| 19 | "github.com/carlosframework/platform/internal/hostwrite" |
| 20 | "github.com/carlosframework/platform/internal/provision" |
| 21 | "github.com/carlosframework/platform/internal/registry" |
| 22 | "github.com/carlosframework/platform/internal/rootstate" |
| 23 | "github.com/carlosframework/platform/internal/schedule" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | func TestPlan14TimerIntentionBeforeMaterialization(t *testing.T) { |
| 28 | plan14TimerIntention(t, func(t *testing.T) rootstate.Store { return &rollbackDisk{dir: t.TempDir()} }) |
| 29 | } |
| 30 | |
| 31 | type timerSyncFault struct { |
| 32 | rootstate.Store |
| 33 | phase string |
| 34 | } |
| 35 | |
| 36 | func (s *timerSyncFault) Write(name string, body []byte) error { |
| 37 | if err := s.Store.Write(name, body); err != nil { |
| 38 | return err |
| 39 | } |
| 40 | pending := strings.Contains(string(body), `"PendingInstallation"`) |
| 41 | if s.phase == "pending" && pending || s.phase == "complete" && !pending { |
| 42 | s.phase = "" |
| 43 | return os.ErrInvalid |
| 44 | } |
| 45 | return nil |
| 46 | } |
| 47 | |
| 48 | func plan14TimerIntention(t *testing.T, open func(*testing.T) rootstate.Store) { |
| 49 | for _, attack := range []string{"none", "already-materialized", "late-intention", "changed-registration", "changed-row", "omit-intention", "crash-after-intention", "crash-after-row", "intention-sync", "materialization-sync", "replace", "remove", "replace-sync", "remove-sync", "replace-crash", "remove-crash", "replace-advanced-crash", "remove-advanced-crash", "producer", "bootstrap"} { |
| 50 | t.Run(attack, func(t *testing.T) { |
| 51 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 52 | f := newRecoveryFixture(t) |
| 53 | o := f.read() |
| 54 | h := o.Claim.Holder |
| 55 | plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: o.Host, Account: h.Account, App: h.App, Channel: "stable", CreatedAt: f.now}) |
| 56 | _, etag, _ := f.claims.Get(t.Context(), h.Key) |
| 57 | journals, err := hostbox.NewJournalService(f.journal) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | registryPath := filepath.Join(t.TempDir(), "registry.db") |
| 62 | reg, err := registry.Open(registryPath) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | defer reg.Close() |
| 67 | sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", o.Host, h.Incarnation, h.Stamp, "box-1"))) |
| 68 | owner := registry.ClaimOwnership{ClaimIncarnation: h.Incarnation, ClaimStamp: h.Stamp, ClaimOwner: hex.EncodeToString(sum[:]), ClaimRowID: "route"} |
| 69 | var bootstrap *registry.Timer |
| 70 | if attack == "bootstrap" { |
| 71 | if err := reg.UpsertTimer(registry.Timer{Host: o.Host, Name: "daily", Kind: "schedule", Path: "/daily", Spec: "every 1h0m0s", Attempts: 2, NextAt: 17}); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | bootstrap, err = reg.GetTimer(o.Host, "daily") |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | owned := owner |
| 79 | owned.ClaimRowID = bootstrap.Registration |
| 80 | if err := reg.AdoptClaimTimer(t.Context(), *bootstrap, owned); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | } |
| 84 | if err := reg.Upsert(registry.Route{Host: o.Host, Account: h.Account, App: h.App, DBPath: provision.NestedDBPath(f.guard.DataDir, o.Host), ClaimOwnership: owner}); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | in := hostbox.StopInventory{Complete: true, Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box-1", Enrollment: "en", DirectoryRevision: "1", Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: o.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}} |
| 88 | artifacts := hostguard.InstallationArtifacts{Schema: 1, StateDirectory: true} |
| 89 | if bootstrap != nil { |
| 90 | artifacts.Timers = bootstrap.Name |
| 91 | in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: bootstrap.Registration, ManagedRoot: "registry", RelativePath: o.Host + "/" + bootstrap.Name, RowIdentity: bootstrap.Registration, Ownership: owner.ClaimOwner, Materialization: "materialized"}) |
| 92 | } |
| 93 | if err := journals.Inventory.WriteStopInventory(t.Context(), in); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | g := f.guard |
| 97 | state := &timerSyncFault{Store: open(t)} |
| 98 | defer state.Close() |
| 99 | g.Root = hostguard.NewRootAuthority(state) |
| 100 | if err := g.EstablishRoot(t.Context(), hostguard.RootInstallation{Artifacts: artifacts, Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Account: h.Account, App: h.App, Record: h.Key, Box: "box-1", Enrollment: "en", Migration: 1, InventoryRevision: 1}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | actor := hostbox.Actor{Box: "box-1", Enrollment: "en", Revision: 1, Binding: "1"} |
| 104 | root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: g, Evidence: f.claims}}, Journals: journals, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Roots: map[string]string{"data": g.DataDir}, Reconcile: hostbox.ReconcileAdapter{Registry: reg, Census: &hostbox.EmptyInstallationCensus{}}} |
| 105 | var last hostbox.JournalRequest |
| 106 | _, client := hostbox.RemoteJournals(func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| 107 | raw, err := json.Marshal(q) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | q = hostbox.JournalRequest{} |
| 112 | if err := json.Unmarshal(raw, &q); err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if q.Op == "inventory-version" { |
| 116 | out, err := journals.Inventory.SnapshotRequest(ctx, q.Target) |
| 117 | out.V, out.ID = 1, q.ID |
| 118 | return out, err |
| 119 | } |
| 120 | last = q |
| 121 | return root.commitRootInventory(ctx, q) |
| 122 | }) |
| 123 | expect := hostbox.InventoryExpectation{Revision: 1, RecordKey: h.Key, RecordETag: etag, Target: hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: actor.Box, Enrollment: actor.Enrollment, ClaimETag: o.ETag, Op: o.Claim.Op.ID, Phase: o.Claim.Op.Phase}} |
| 124 | write := func() error { |
| 125 | _, err := client.WriteExpected(t.Context(), in, expect) |
| 126 | if err == nil { |
| 127 | expect.Revision++ |
| 128 | } |
| 129 | return err |
| 130 | } |
| 131 | timer := registry.Timer{Host: o.Host, Name: "daily", Kind: "schedule", Spec: "every 1h0m0s", Path: "/daily", Registration: "planned", ClaimOwnership: owner} |
| 132 | timer.ClaimRowID = timer.Registration |
| 133 | if attack == "producer" || attack == "bootstrap" { |
| 134 | serviceGuard := f.guard |
| 135 | if err := serviceGuard.Activate(t.Context(), o.Host, actor.Box, nil); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | serviceGuard.FenceView = g.Root.View |
| 139 | adapter := hostbox.ReconcileAdapter{Registry: reg, Inventory: client, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "1", Reconciler: hostbox.Reconciler{Guard: serviceGuard, Claims: f.claims}} |
| 140 | source := hostbox.WorkSource{Guard: serviceGuard, Box: actor.Box, Region: "ie", Now: time.Now, Policy: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }, Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }, Timer: adapter.MutateTimer} |
| 141 | work, err := source.Select(t.Context(), o.Host, "timers") |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | producer := schedule.SelectedTimers{Store: reg, Work: work, Context: t.Context(), Host: o.Host} |
| 146 | timer.Registration = "" |
| 147 | timer.ClaimOwnership = registry.ClaimOwnership{} |
| 148 | if err := producer.UpsertTimer(timer); err != nil { |
| 149 | t.Fatal("producer registration", err) |
| 150 | } |
| 151 | first, err := reg.GetTimer(o.Host, timer.Name) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | if err := producer.UpsertTimer(timer); err != nil { |
| 156 | t.Fatal("producer replacement", err) |
| 157 | } |
| 158 | second, err := reg.GetTimer(o.Host, timer.Name) |
| 159 | if err != nil || first.Registration == second.Registration { |
| 160 | t.Fatal("producer identity", second, err) |
| 161 | } |
| 162 | if err := producer.DeleteTimer(o.Host, timer.Name); err != nil { |
| 163 | t.Fatal("producer withdrawal", err) |
| 164 | } |
| 165 | if _, err := reg.GetTimer(o.Host, timer.Name); !errors.Is(err, os.ErrNotExist) { |
| 166 | t.Fatal("producer deletion", err) |
| 167 | } |
| 168 | if err := producer.UpsertTimer(timer); err != nil { |
| 169 | t.Fatal("producer registration after withdrawal", err) |
| 170 | } |
| 171 | imported := timer |
| 172 | imported.Registration = "imported" |
| 173 | imported.Attempts, imported.LastAt, imported.NextAt, imported.CreatedAt = 3, 7, 11, 13 |
| 174 | if err := producer.ReplaceTimers(o.Host, []registry.Timer{imported}); err != nil { |
| 175 | t.Fatal("import", err) |
| 176 | } |
| 177 | if err := producer.ReplaceTimers(o.Host, []registry.Timer{imported}); err != nil { |
| 178 | t.Fatal("import replay", err) |
| 179 | } |
| 180 | got, err := reg.GetTimer(o.Host, imported.Name) |
| 181 | if err != nil || got.Registration != "imported" || got.Attempts != 3 || got.LastAt != 7 || got.NextAt != 11 || got.CreatedAt != 13 { |
| 182 | t.Fatal("import identity and bookkeeping", got, err) |
| 183 | } |
| 184 | return |
| 185 | } |
| 186 | |
| 187 | row := func() { |
| 188 | t.Helper() |
| 189 | if err := reg.ReplaceTimers(o.Host, []registry.Timer{timer}); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | } |
| 193 | in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: o.Host + "/daily", RowIdentity: timer.Registration, Ownership: owner.ClaimOwner, Materialization: "intended"}) |
| 194 | if attack == "already-materialized" || attack == "late-intention" { |
| 195 | row() |
| 196 | if attack == "already-materialized" { |
| 197 | in.Entries[1].Materialization = "materialized" |
| 198 | } |
| 199 | if err := write(); err == nil { |
| 200 | t.Fatal("adopted timer without prior root intention") |
| 201 | } |
| 202 | return |
| 203 | } |
| 204 | if attack == "intention-sync" { |
| 205 | state.phase = "pending" |
| 206 | if err := write(); err == nil { |
| 207 | t.Fatal("acknowledged unsynced intention") |
| 208 | } |
| 209 | if err := root.recoverRootInventory(t.Context()); err != nil { |
| 210 | t.Fatal("recover intended write", err) |
| 211 | } |
| 212 | if _, err := root.commitRootInventory(t.Context(), last); err != nil { |
| 213 | t.Fatal("replay recovered intention", err) |
| 214 | } |
| 215 | expect.Revision++ |
| 216 | } else if err := write(); err != nil { |
| 217 | t.Fatal("record intention", err) |
| 218 | } |
| 219 | if attack == "crash-after-intention" { |
| 220 | if err := write(); err != nil { |
| 221 | t.Fatal("resume absent intended row", err) |
| 222 | } |
| 223 | } |
| 224 | row() |
| 225 | if attack == "crash-after-row" { |
| 226 | if err := write(); err != nil { |
| 227 | t.Fatal("resume intended exact row", err) |
| 228 | } |
| 229 | } |
| 230 | in.Entries[1].Materialization = "materialized" |
| 231 | if attack == "materialization-sync" { |
| 232 | state.phase = "complete" |
| 233 | } |
| 234 | switch attack { |
| 235 | case "changed-registration", "changed-row": |
| 236 | db, err := sql.Open("sqlite", registryPath) |
| 237 | if err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | column := "registration" |
| 241 | if attack == "changed-row" { |
| 242 | column = "claim_row_id" |
| 243 | } |
| 244 | _, err = db.Exec("UPDATE timers SET "+column+"='successor' WHERE host=? AND name=?", o.Host, timer.Name) |
| 245 | db.Close() |
| 246 | if err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | case "omit-intention": |
| 250 | in.Entries = in.Entries[:1] |
| 251 | } |
| 252 | err = write() |
| 253 | if attack == "materialization-sync" { |
| 254 | if err == nil { |
| 255 | t.Fatal("acknowledged unsynced materialization") |
| 256 | } |
| 257 | _, err = root.commitRootInventory(t.Context(), last) |
| 258 | if err == nil { |
| 259 | expect.Revision++ |
| 260 | } |
| 261 | } |
| 262 | valid := attack == "none" || attack == "crash-after-intention" || attack == "crash-after-row" || attack == "intention-sync" || attack == "materialization-sync" || strings.HasPrefix(attack, "replace") || strings.HasPrefix(attack, "remove") |
| 263 | if (err == nil) != valid { |
| 264 | t.Fatalf("materialization valid=%t: %v", valid, err) |
| 265 | } |
| 266 | if valid { |
| 267 | if err := write(); err != nil { |
| 268 | t.Fatal("materialized replay", err) |
| 269 | } |
| 270 | if strings.HasPrefix(attack, "replace") || strings.HasPrefix(attack, "remove") { |
| 271 | mutation := hostbox.TimerMutation{Timer: timer, Expected: timer.Registration, Remove: strings.HasPrefix(attack, "remove")} |
| 272 | if mutation.Remove { |
| 273 | in.Entries[1].TimerRetired = true |
| 274 | } else { |
| 275 | mutation.Timer.Registration = "replacement" |
| 276 | mutation.Timer.ClaimRowID = "replacement" |
| 277 | in.Entries[1].RowIdentity = "replacement" |
| 278 | } |
| 279 | if strings.HasSuffix(attack, "sync") { |
| 280 | state.phase = "complete" |
| 281 | } |
| 282 | if strings.HasSuffix(attack, "crash") { |
| 283 | calls := 0 |
| 284 | journals.Now = func() time.Time { |
| 285 | calls++ |
| 286 | if calls > 1 { |
| 287 | return time.Now().Add(2 * hostbox.JournalRequestWindow) |
| 288 | } |
| 289 | return time.Now() |
| 290 | } |
| 291 | } |
| 292 | revision, err := client.WriteTimerExpected(t.Context(), in, expect, mutation) |
| 293 | if strings.HasSuffix(attack, "crash") { |
| 294 | if err == nil { |
| 295 | t.Fatal("journal fault did not interrupt mutation") |
| 296 | } |
| 297 | got, getErr := reg.GetTimer(timer.Host, timer.Name) |
| 298 | if mutation.Remove { |
| 299 | if !errors.Is(getErr, os.ErrNotExist) { |
| 300 | t.Fatal("did not reach row removal", getErr) |
| 301 | } |
| 302 | } else if getErr != nil || got.Registration != mutation.Timer.Registration { |
| 303 | t.Fatal("did not reach row replacement", got, getErr) |
| 304 | } |
| 305 | journals.Now = time.Now |
| 306 | if strings.Contains(attack, "advanced") { |
| 307 | key, _ := hostclaim.Key(o.Host) |
| 308 | body, etag, err := f.claims.Get(t.Context(), key) |
| 309 | if err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | if _, err := f.claims.Put(t.Context(), key, append(body, '\n'), etag); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | } |
| 316 | if err := root.recoverRootInventory(t.Context()); err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | if strings.Contains(attack, "advanced") { |
| 320 | snapshot, err := journals.Inventory.SnapshotRequest(t.Context(), expect.Target) |
| 321 | if err != nil || snapshot.Revision != expect.Revision+1 || snapshot.Inventory.Entries[1] != in.Entries[1] { |
| 322 | t.Fatal("advanced claim lost pending effect", snapshot, err) |
| 323 | } |
| 324 | pending, err := g.Root.PendingInstallations() |
| 325 | if err != nil || len(pending) != 0 { |
| 326 | t.Fatal("effect still pending", pending, err) |
| 327 | } |
| 328 | return |
| 329 | } |
| 330 | reply, replayErr := root.commitRootInventory(t.Context(), last) |
| 331 | err, revision = replayErr, reply.Revision |
| 332 | } |
| 333 | |
| 334 | if strings.HasSuffix(attack, "sync") { |
| 335 | if err == nil { |
| 336 | t.Fatal("acknowledged failed sync") |
| 337 | } |
| 338 | reply, replayErr := root.commitRootInventory(t.Context(), last) |
| 339 | err, revision = replayErr, reply.Revision |
| 340 | } |
| 341 | if err != nil || revision != expect.Revision+1 { |
| 342 | t.Fatal("timer mutation", revision, err) |
| 343 | } |
| 344 | expect.Revision = revision |
| 345 | got, err := reg.GetTimer(o.Host, timer.Name) |
| 346 | if mutation.Remove { |
| 347 | if !errors.Is(err, os.ErrNotExist) { |
| 348 | t.Fatal("timer survived", got, err) |
| 349 | } |
| 350 | } else if err != nil || got.Registration != "replacement" { |
| 351 | t.Fatal("replacement missing", got, err) |
| 352 | } |
| 353 | if _, err := root.commitRootInventory(t.Context(), last); err != nil { |
| 354 | t.Fatal("mutation replay", err) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | got, err := journals.Inventory.ReadStopInventory(t.Context(), o.Host) |
| 359 | if err != nil || len(got.Entries) != 2 || got.Entries[1] != in.Entries[1] { |
| 360 | t.Fatal("lost exact timer", got, err) |
| 361 | } |
| 362 | } |
| 363 | }) |
| 364 | } |
| 365 | } |
| 366 | |