| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "github.com/carlosframework/platform/internal/activator" |
| 7 | "github.com/carlosframework/platform/internal/genselect" |
| 8 | "github.com/carlosframework/platform/internal/hostbox" |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/hostguard" |
| 11 | "github.com/carlosframework/platform/internal/hostops" |
| 12 | "github.com/carlosframework/platform/internal/hostwrite" |
| 13 | "github.com/carlosframework/platform/internal/lease" |
| 14 | "github.com/carlosframework/platform/internal/park" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | "path/filepath" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | type parkRegistrationEvidence struct{} |
| 24 | |
| 25 | func (parkRegistrationEvidence) VerifyRetirement(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error) { |
| 26 | return "retired", time.Unix(10, 0), nil |
| 27 | } |
| 28 | |
| 29 | type parkRegistrationWake struct { |
| 30 | parkRegistrationEvidence |
| 31 | opens int |
| 32 | failure error |
| 33 | } |
| 34 | |
| 35 | func (p *parkRegistrationWake) Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor, ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error) { |
| 36 | p.opens++ |
| 37 | return ClaimMovesOwners{}, nil, p.failure |
| 38 | } |
| 39 | func (*parkRegistrationWake) View(context.Context, hostclaim.Observed) (park.View, error) { |
| 40 | return park.View{}, nil |
| 41 | } |
| 42 | func (*parkRegistrationWake) LeaseSet(context.Context, string, string) ([]string, error) { |
| 43 | return []string{"lease"}, nil |
| 44 | } |
| 45 | func (*parkRegistrationWake) Close(context.Context) error { return nil } |
| 46 | |
| 47 | func TestMoveParksRegistrationIndependentAndAtomic(t *testing.T) { |
| 48 | claimMoveSources.Lock() |
| 49 | oldSource, oldParks := claimMoveSources.Source, claimMoveSources.Parks |
| 50 | claimMoveSources.Source = nil |
| 51 | claimMoveSources.Parks = nil |
| 52 | claimMoveSources.Unlock() |
| 53 | t.Cleanup(func() { |
| 54 | claimMoveSources.Lock() |
| 55 | claimMoveSources.Source = oldSource |
| 56 | claimMoveSources.Parks = oldParks |
| 57 | claimMoveSources.Unlock() |
| 58 | }) |
| 59 | err := RegisterProductionClaimMoveParks(nil, nil) |
| 60 | for _, name := range []string{"view", "regional evidence"} { |
| 61 | if err == nil || !strings.Contains(err.Error(), name) { |
| 62 | t.Fatalf("missing %s: %v", name, err) |
| 63 | } |
| 64 | } |
| 65 | if claimMoveSources.Parks != nil { |
| 66 | t.Fatal("partial registration") |
| 67 | } |
| 68 | source := &parkRegistrationWake{} |
| 69 | if err := RegisterProductionClaimMoveParks(source.View, source); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if source.opens != 0 || claimMoveSources.Source != nil { |
| 73 | t.Fatal("parks opened or registered wake") |
| 74 | } |
| 75 | if err := RegisterProductionClaimMoveParks(source.View, source); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 76 | t.Fatalf("replacement: %v", err) |
| 77 | } |
| 78 | parks := claimMoveSources.Parks |
| 79 | if err := RegisterProductionClaimMoveSources(source); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if claimMoveSources.Parks != parks || claimMoveSources.Source != source { |
| 83 | t.Fatal("wake registration replaced parks") |
| 84 | } |
| 85 | if err := RegisterProductionClaimMoveSources(source); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 86 | t.Fatal("duplicate wake registration accepted") |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestMoveParksWakeFailureRemainsWakeRefusal(t *testing.T) { |
| 91 | boom := errors.New("wake failed") |
| 92 | source := &parkRegistrationWake{failure: boom} |
| 93 | out := ClaimOwnerBindings{} |
| 94 | openProductionClaimMoveWake(t.Context(), nil, hostbox.Actor{}, source, &out, func(context.Context) error { return nil }) |
| 95 | if source.opens != 1 || !errors.Is(out.wakeUnavailable, boom) { |
| 96 | t.Fatalf("wake failure lost: %v", out.wakeUnavailable) |
| 97 | } |
| 98 | w := refusingClaimMoveWake("box", nil, out.wakeUnavailable) |
| 99 | if err := w.Prerequisites(t.Context(), registry.Route{}, hostclaim.Observed{}, activator.StartPolicy{}); !errors.Is(err, boom) { |
| 100 | t.Fatalf("wake opened: %v", err) |
| 101 | } |
| 102 | openProductionClaimMoveWake(t.Context(), nil, hostbox.Actor{}, nil, &out, func(context.Context) error { return nil }) |
| 103 | if out.wakeUnavailable == nil || !strings.Contains(out.wakeUnavailable.Error(), "C8") { |
| 104 | t.Fatal("missing wake not named") |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestMoveParksVerifiedIdentityAndRealEngine(t *testing.T) { |
| 109 | t.Parallel() |
| 110 | identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1, Binding: "1"}) |
| 111 | actor, err := identity.Verify(t.Context()) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | check := claimBoundIdentity(identity.Verify, actor) |
| 116 | sentinel := errors.New("real view") |
| 117 | calls := 0 |
| 118 | engine := park.Engine{Bucket: actor.Bucket, Region: actor.Region, View: func(context.Context, hostclaim.Observed) (park.View, error) { calls++; return park.View{}, sentinel }} |
| 119 | providers := ClaimProviders{moveParks: &claimMoveParks{local: func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { return nil }, engine: engine, check: check, leases: func(context.Context, string, string) ([]string, error) { return []string{"real lease"}, nil }}} |
| 120 | got, leases, _, err := providers.MoveParks(t.Context()) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if _, err := got.View(t.Context(), hostclaim.Observed{}); !errors.Is(err, sentinel) || calls != 1 { |
| 125 | t.Fatalf("engine replaced: %v", err) |
| 126 | } |
| 127 | if names, err := leases(t.Context(), actor.Bucket, "scope"); err != nil || len(names) != 1 || names[0] != "real lease" { |
| 128 | t.Fatal("leases replaced") |
| 129 | } |
| 130 | mutations := map[string]func(*hostbox.Actor){"enrollment": func(a *hostbox.Actor) { a.Enrollment = "new" }, "instance": func(a *hostbox.Actor) { a.Cloud.InstanceID = "new" }, "control bucket": func(a *hostbox.Actor) { a.ControlBucket = "new" }, "data bucket": func(a *hostbox.Actor) { a.Bucket = "new" }, "namespace": func(a *hostbox.Actor) { a.Namespace = "new" }, "binding": func(a *hostbox.Actor) { a.Binding += "-changed" }, "account": func(a *hostbox.Actor) { a.Cloud.Account = "new" }} |
| 131 | for name, mutate := range mutations { |
| 132 | t.Run(name, func(t *testing.T) { |
| 133 | changed := actor |
| 134 | mutate(&changed) |
| 135 | providers.moveParks.check = claimBoundIdentity(func(context.Context) (hostbox.Actor, error) { return changed, nil }, actor) |
| 136 | if _, _, _, err := providers.MoveParks(t.Context()); !errors.Is(err, hostbox.ErrChanged) { |
| 137 | t.Fatalf("identity change accepted: %v", err) |
| 138 | } |
| 139 | }) |
| 140 | } |
| 141 | // The roster write counter is not part of the bound identity: holds and |
| 142 | // other boxes' rows move it without changing this box's binding. |
| 143 | t.Run("revision", func(t *testing.T) { |
| 144 | changed := actor |
| 145 | changed.Revision++ |
| 146 | providers.moveParks.check = claimBoundIdentity(func(context.Context) (hostbox.Actor, error) { return changed, nil }, actor) |
| 147 | if _, _, _, err := providers.MoveParks(t.Context()); err != nil { |
| 148 | t.Fatalf("roster revision bump refused: %v", err) |
| 149 | } |
| 150 | }) |
| 151 | if _, _, _, err := (ClaimProviders{}).MoveParks(t.Context()); !errors.Is(err, hostwrite.ErrUnavailable) { |
| 152 | t.Fatal("unbuilt accessor accepted") |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | type moveParkNoData struct{ lease.VersionedStore } |
| 157 | type moveParkNoLocal struct{ hostbox.LocalAuthority } |
| 158 | |
| 159 | func TestMoveParksBuildWithFailedWakeUsesRegionalEngine(t *testing.T) { |
| 160 | ctx := t.Context() |
| 161 | base := t.TempDir() |
| 162 | reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | defer reg.Close() |
| 167 | inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db")) |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | defer inventory.Close() |
| 172 | store := release.NewDirStore(filepath.Join(base, "store")) |
| 173 | identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: "enrolled", Region: "region", ControlBucket: "control", Revision: 1, Binding: "1"}) |
| 174 | actor, err := identity.Verify(ctx) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | boom := errors.New("configured wake failed") |
| 179 | wakeSource := &parkRegistrationWake{failure: boom} |
| 180 | owners := ClaimOwnerBindings{} |
| 181 | openProductionClaimMoveWake(ctx, store, actor, wakeSource, &owners, claimBoundIdentity(identity.Verify, actor)) |
| 182 | viewCalls := 0 |
| 183 | a := ClaimAssembly{ |
| 184 | wakeUnavailable: owners.wakeUnavailable, Claims: store, Guard: hostguard.Guard{DataDir: base}, Identity: identity, |
| 185 | RetirementAuthority: &hostops.ProductionAuthority{Store: store, Now: time.Now}, |
| 186 | Local: moveParkNoLocal{}, Data: moveParkNoData{}, Wake: &ClaimWakeAssembly{}, Stop: &ClaimStopCoordinator{}, |
| 187 | Controllers: productionStopControllers{}, Launches: &hostbox.LaunchJournal{}, |
| 188 | Reconcile: &hostbox.ReconcileAdapter{Registry: reg, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: filepath.Join(base, "retirement")}}, |
| 189 | ManagedRoots: map[string]string{"data": base, "opt": filepath.Join(base, "opt"), "units": filepath.Join(base, "units")}, |
| 190 | Evidence: parkRegistrationEvidence{}, |
| 191 | LeaseSet: func(context.Context, string, string) ([]string, error) { return []string{"actual-inventory"}, nil }, |
| 192 | View: func(context.Context, hostclaim.Observed) (park.View, error) { viewCalls++; return park.View{}, nil }, |
| 193 | Authorize: func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }, |
| 194 | AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }, |
| 195 | PublicationEvidence: func(context.Context, hostclaim.Observed) error { return nil }, NewID: func() string { return "id" }, Close: func(context.Context) error { return nil }, |
| 196 | } |
| 197 | providers, err := a.Build(ctx) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | engine, leases, local, err := providers.MoveParks(ctx) |
| 202 | if local == nil { |
| 203 | t.Fatal("missing local authorizer") |
| 204 | } |
| 205 | if err := local(ctx, &hostclaim.Claim{Host: "app.example"}, hostclaim.Obligation{Kind: "park-local"}); err != nil { |
| 206 | t.Fatal("local authorizer went through VerifyBound", err) |
| 207 | } |
| 208 | if err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if err := providers.Wake.Prerequisites(ctx, registry.Route{}, hostclaim.Observed{}, activator.StartPolicy{}); !errors.Is(err, boom) { |
| 212 | t.Fatalf("wake error lost: %v", err) |
| 213 | } |
| 214 | if _, err := engine.View(ctx, hostclaim.Observed{}); err != nil || viewCalls != 1 { |
| 215 | t.Fatalf("wrong view: %v", err) |
| 216 | } |
| 217 | names, err := leases(ctx, actor.Bucket, "scope") |
| 218 | if err != nil || len(names) != 1 || names[0] != "actual-inventory" { |
| 219 | t.Fatalf("wrong inventory: %v %v", names, err) |
| 220 | } |
| 221 | // NewClaimRegionalWorker strengthens Authorize. The accessor must return |
| 222 | // that final engine, not its earlier permissive construction value. |
| 223 | if err := engine.Authorize(ctx, hostclaim.Observed{}, hostclaim.Obligation{}); err == nil { |
| 224 | t.Fatal("accessor bypassed retirement evidence guard") |
| 225 | } |
| 226 | regional := providers.Regional.(hostbox.RegionalWorker) |
| 227 | if engine.Store.(hostbox.RequestStore).Store != regional.Regional.Engine.Store.(hostbox.RequestStore).Store || engine.Claims != regional.Regional.Engine.Claims { |
| 228 | t.Fatal("accessor reconstructed another engine") |
| 229 | } |
| 230 | if err := providers.Close(ctx); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | } |
| 234 | |