| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "crypto/rand" |
| 10 | "crypto/sha256" |
| 11 | "database/sql" |
| 12 | "encoding/base64" |
| 13 | "encoding/hex" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "io" |
| 17 | "net" |
| 18 | "net/http" |
| 19 | "net/http/httptest" |
| 20 | "os" |
| 21 | "os/exec" |
| 22 | "path/filepath" |
| 23 | "runtime" |
| 24 | "strings" |
| 25 | "sync" |
| 26 | "testing" |
| 27 | "time" |
| 28 | |
| 29 | "github.com/carlosframework/platform/internal/activator" |
| 30 | "github.com/carlosframework/platform/internal/cgroup" |
| 31 | "github.com/carlosframework/platform/internal/controlsock" |
| 32 | "github.com/carlosframework/platform/internal/hostactors" |
| 33 | "github.com/carlosframework/platform/internal/hostbox" |
| 34 | "github.com/carlosframework/platform/internal/hostclaim" |
| 35 | "github.com/carlosframework/platform/internal/hostfloor" |
| 36 | "github.com/carlosframework/platform/internal/hostguard" |
| 37 | "github.com/carlosframework/platform/internal/hostops" |
| 38 | "github.com/carlosframework/platform/internal/hostwrite" |
| 39 | "github.com/carlosframework/platform/internal/instancectl" |
| 40 | "github.com/carlosframework/platform/internal/lease" |
| 41 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 42 | "github.com/carlosframework/platform/internal/park" |
| 43 | "github.com/carlosframework/platform/internal/provision" |
| 44 | "github.com/carlosframework/platform/internal/registry" |
| 45 | "github.com/carlosframework/platform/internal/release" |
| 46 | "github.com/carlosframework/platform/internal/rollout" |
| 47 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 48 | "github.com/carlosframework/platform/internal/rootstate" |
| 49 | ) |
| 50 | |
| 51 | const lifecycleHost = "lifecycle.bab.example" |
| 52 | const lifecycleSibling = "sibling.bab.example" |
| 53 | const lifecycleHome = "/var/lib/plan14-lifecycle" |
| 54 | |
| 55 | func init() { |
| 56 | if lifecyclePayloadArgs(os.Args) { |
| 57 | db, err := sql.Open("sqlite", os.Args[4]) |
| 58 | if err != nil { |
| 59 | panic(err) |
| 60 | } |
| 61 | if _, err = db.Exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS lifecycle (value TEXT)"); err != nil { |
| 62 | panic(err) |
| 63 | } |
| 64 | listener, err := net.Listen("unix", os.Args[2]) |
| 65 | if err != nil { |
| 66 | panic(err) |
| 67 | } |
| 68 | if err = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "lifecycle tenant") })); err != nil { |
| 69 | panic(err) |
| 70 | } |
| 71 | os.Exit(0) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func lifecyclePayloadArgs(args []string) bool { |
| 76 | if len(args) != 5 { |
| 77 | return false |
| 78 | } |
| 79 | host := lifecycleHost |
| 80 | if args[2] == filepath.Join(provision.InstanceRunDir(lifecycleSibling), "app.sock") { |
| 81 | host = lifecycleSibling |
| 82 | } |
| 83 | return args[1] == "--socket" && args[2] == filepath.Join(provision.InstanceRunDir(host), "app.sock") && args[3] == "--db" && args[4] == provision.NestedDBPath("/data", host) |
| 84 | } |
| 85 | |
| 86 | func TestPlan14LifecyclePayloadDispatch(t *testing.T) { |
| 87 | ours := []string{"tenant", "--socket", filepath.Join(provision.InstanceRunDir(lifecycleHost), "app.sock"), "--db", provision.NestedDBPath("/data", lifecycleHost)} |
| 88 | if !lifecyclePayloadArgs(ours) { |
| 89 | t.Fatal("lifecycle payload refused") |
| 90 | } |
| 91 | for _, index := range []int{1, 2, 3, 4} { |
| 92 | other := append([]string(nil), ours...) |
| 93 | other[index] = "another-test" |
| 94 | if lifecyclePayloadArgs(other) { |
| 95 | t.Fatalf("another payload intercepted at argument %d", index) |
| 96 | } |
| 97 | } |
| 98 | if lifecyclePayloadArgs(ours[:4]) { |
| 99 | t.Fatal("incomplete payload intercepted") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func lifecycleCheck(t *testing.T, step string, err error) { |
| 104 | t.Helper() |
| 105 | if err != nil { |
| 106 | t.Fatalf("%s: %v", step, err) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // Only the external box enrollment and object service are supplied by this |
| 111 | // harness. Local authority, execution, settings, files and launch wiring use |
| 112 | // the same constructors as instance-control and the edge. |
| 113 | type lifecycleOwners struct{ t *testing.T } |
| 114 | |
| 115 | func (p lifecycleOwners) OpenClaimOwners(ctx context.Context, store release.Store, r ClaimRuntimeResources) (ClaimOwnerBindings, error) { |
| 116 | key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| 117 | identity := handoverRecoveryIdentity(p.t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Binding: "1", Region: "ie", FleetHomeRegion: "ie", ControlBucket: "deployment"}) |
| 118 | identity.HomeRegion = "ie" |
| 119 | actor, err := identity.Verify(ctx) |
| 120 | if err != nil { |
| 121 | return ClaimOwnerBindings{}, err |
| 122 | } |
| 123 | directory := hostactors.Directory{Store: store, PublicKey: key.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket} |
| 124 | data, err := lease.NewVersionedS3(ctx, map[string]string{actor.Bucket: actor.Cloud.Region}) |
| 125 | if err != nil { |
| 126 | return ClaimOwnerBindings{}, err |
| 127 | } |
| 128 | markers, err := newProductionClaimMarkers(ctx, actor.Bucket, actor.Cloud.Region) |
| 129 | if err != nil { |
| 130 | return ClaimOwnerBindings{}, err |
| 131 | } |
| 132 | local := &productionClaimLocal{Resources: r, Identity: identity, Directory: directory, Store: store, slots: map[string]*claimParticipation{}} |
| 133 | backend := claimExecBackend(r, productionInstanceKeyReaders()) |
| 134 | tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: identity.Verify, Local: identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now} |
| 135 | input := claimUnitInputReader(rootClaimUnitInputs) |
| 136 | if r.Mode == "agent" { |
| 137 | input = NewClaimRoleRootClient(controlsock.Path).UnitInputs |
| 138 | } |
| 139 | regional := productionClaimRegional{Identity: identity, Local: local} |
| 140 | out := ClaimOwnerBindings{Identity: identity, RetirementAuthority: &hostops.ProductionAuthority{Store: store, Directory: directory, Now: time.Now}, EvidenceKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)), Controllers: productionStopControllers{}, Data: data, Local: local, |
| 141 | Serving: ClaimServingSources{Launch: claimServingLaunchSource(backend, tokens, input), Markers: markers}, |
| 142 | InputPolicy: claimInputPolicy(r.Registry.Get, store, key.Public().(ed25519.PublicKey), backend, input), MovesPolicy: MovesClaimPolicy(MovesRecordsFrom(store)), |
| 143 | Evidence: regional, LeaseSet: regional.LeaseSet, View: regional.View, Authorize: regional.Authorize, Close: local.Close} |
| 144 | source := &movesFilesSource{DataDir: r.DataDir, Routes: r.Registry, newFiles: newProductionFilesStore} |
| 145 | _, err = openClaimFilesManifest(ctx, os.Getenv, source, store, data, actor, &out, claimBoundIdentity(identity.Verify, actor)) |
| 146 | if r.Mode != "instance-control" { |
| 147 | out.EvidenceKey = nil |
| 148 | } |
| 149 | bindClaimLegacyOwners(&out) |
| 150 | return out, err |
| 151 | } |
| 152 | |
| 153 | func lifecycleEnvironment(t *testing.T, endpoint string) { |
| 154 | t.Helper() |
| 155 | for k, v := range map[string]string{"CARLOS_HOST_CLAIM": "1", "CARLOS_HOST_CLAIM_ORIGINS": "1", "CARLOS_MOVES_PHASE1": "", "CARLOS_REGION_HA": "", "CARLOS_DATA_DIR": "/data", "CARLOS_OPT_DIR": "/opt/carlos", "CARLOS_DEPLOYMENT_BUCKET": "", "CARLOS_DEPLOYMENT_DIR": lifecycleHome + "/deployment", "CARLOS_REGION": "", "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": endpoint, "AWS_ENDPOINT_URL_S3": endpoint, "CARLOS_RELEASE_PUBKEY": base64.StdEncoding.EncodeToString(ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)).Public().(ed25519.PublicKey))} { |
| 156 | t.Setenv(k, v) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestPlan14LifecycleColdWakeRootRunner(t *testing.T) { |
| 161 | t.Setenv("PLAN14_LIFECYCLE_PHASE", "probe") |
| 162 | lifecycleRun(t) |
| 163 | } |
| 164 | |
| 165 | func TestRootRebootPlan14LifecycleEndToEndPhase1(t *testing.T) { |
| 166 | t.Setenv("PLAN14_LIFECYCLE_PHASE", "1") |
| 167 | TestPlan14LifecycleEndToEndRootRunnerPayload(t) |
| 168 | } |
| 169 | |
| 170 | func TestRootRebootPlan14LifecycleEndToEndPhase2(t *testing.T) { |
| 171 | t.Setenv("PLAN14_LIFECYCLE_PHASE", "2") |
| 172 | TestPlan14LifecycleEndToEndRootRunnerPayload(t) |
| 173 | } |
| 174 | |
| 175 | func TestPlan14LifecycleEndToEndRootRunnerPayload(t *testing.T) { |
| 176 | if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "" { |
| 177 | t.Fatal("lifecycle E2E requires root-runner --reboot Plan14LifecycleEndToEnd") |
| 178 | } |
| 179 | t.Run("ordinary", lifecycleRun) |
| 180 | if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" && os.Getenv("PLAN14_LIFECYCLE_AGENT") == "" { |
| 181 | for _, point := range []string{"reservation", "handover", "retirement"} { |
| 182 | t.Run("crash-"+point, func(t *testing.T) { |
| 183 | t.Setenv("PLAN14_LIFECYCLE_PHASE", "crash") |
| 184 | t.Setenv("PLAN14_LIFECYCLE_CRASH", point) |
| 185 | lifecycleRun(t) |
| 186 | }) |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func lifecycleRun(t *testing.T) { |
| 192 | if os.Getenv("PLAN14_LIFECYCLE_AGENT") == "1" { |
| 193 | lifecycleAgent(t) |
| 194 | return |
| 195 | } |
| 196 | TestPlan14RootJournalNamespaceRootRunner(t) |
| 197 | phase := os.Getenv("PLAN14_LIFECYCLE_PHASE") |
| 198 | resume := phase == "2" |
| 199 | uid, gid := generationRunnerRegistryUser(t) |
| 200 | for _, path := range []string{lifecycleHome, "/data", "/opt/carlos", "/run/carlos", "/run/carlos-control", "/etc/carlos", "/var/lib/carlos-claims", "/var/lib/carlos-release", rootRetryProjection} { |
| 201 | if !resume || strings.HasPrefix(path, "/run/") { |
| 202 | lifecycleCheck(t, "exclusive fixture "+path, os.Mkdir(path, 0755)) |
| 203 | } |
| 204 | t.Cleanup(func() { |
| 205 | if phase == "1" && !t.Failed() { |
| 206 | return |
| 207 | } |
| 208 | lifecycleCheck(t, "cleanup "+path, os.RemoveAll(path)) |
| 209 | }) |
| 210 | } |
| 211 | lifecycleCheck(t, "private authority", os.Chmod("/var/lib/carlos-claims", 0700)) |
| 212 | lifecycleCheck(t, "input service owner", os.Chown(lifecycleHome, uid, gid)) |
| 213 | lifecycleCheck(t, "runtime service owner", os.Chown("/run/carlos", uid, gid)) |
| 214 | lifecycleCheck(t, "data owner", os.Chown("/data", uid, gid)) |
| 215 | for _, path := range []string{lifecycleHome + "/deployment", "/data/hostguard", "/data/.parked", "/data/layout", "/data/instances", "/data/.instances", "/run/carlos/.instances", "/run/carlos/.logs", "/data/.hostexec", "/run/carlos/instances", "/run/carlos/logs", "/run/carlos/hostguard", "/run/carlos/hostexec"} { |
| 216 | lifecycleCheck(t, "directory", os.MkdirAll(path, 0755)) |
| 217 | lifecycleCheck(t, "directory owner", os.Chown(path, uid, gid)) |
| 218 | } |
| 219 | for _, path := range []string{"/data/.instances", "/data/instances", "/data/layout", "/run/carlos/.logs", "/run/carlos/logs"} { |
| 220 | lifecycleCheck(t, "private tree", os.Chmod(path, 0700)) |
| 221 | } |
| 222 | model := leasetest.NewVersionedBucket() |
| 223 | model.AddBucket("data") |
| 224 | if resume { |
| 225 | lifecycleRestoreBucket(t, model) |
| 226 | } |
| 227 | handler, _ := leasetest.NewS3Server(model) |
| 228 | server := httptest.NewServer(lifecycleS3(handler, model)) |
| 229 | t.Cleanup(server.Close) |
| 230 | lifecycleEnvironment(t, server.URL) |
| 231 | store := release.NewDirStore(lifecycleHome + "/deployment") |
| 232 | key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| 233 | identity := handoverRecoveryIdentity(t, hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Revision: 1, Binding: "1", Region: "ie", FleetHomeRegion: "ie", ControlBucket: "deployment"}) |
| 234 | identity.HomeRegion = "ie" |
| 235 | actor, err := identity.Verify(t.Context()) |
| 236 | lifecycleCheck(t, "identity", err) |
| 237 | if !resume { |
| 238 | roster := hostactors.Roster{V: 1, HomeRegion: "ie", DeploymentBucket: actor.ControlBucket, Revision: 1, RequestLimitNS: int64(hostbox.RequestLimit), Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), CensusPublicKey: ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)).Public().(ed25519.PublicKey), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 239 | raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| 240 | lifecycleCheck(t, "sign roster", err) |
| 241 | _, err = store.Put(t.Context(), hostactors.Key, raw, "") |
| 242 | lifecycleCheck(t, "roster", err) |
| 243 | filesSeedConstructionMigration(t, "/opt/carlos", actor.ControlBucket, actor.Box, actor.Enrollment) |
| 244 | |
| 245 | } |
| 246 | binary, err := os.Executable() |
| 247 | lifecycleCheck(t, "test binary", err) |
| 248 | image, err := os.ReadFile(binary) |
| 249 | lifecycleCheck(t, "test image", err) |
| 250 | if !resume { |
| 251 | lifecycleCheck(t, "initial version namespace", os.MkdirAll("/opt/carlos/versions", 0755)) |
| 252 | lifecycleCheck(t, "executor", os.WriteFile("/opt/carlos/carlos", image, 0755)) |
| 253 | lifecycleCheck(t, "previous executor", os.WriteFile("/opt/carlos/carlos.prev", image, 0755)) |
| 254 | } |
| 255 | sum := sha256.Sum256(image) |
| 256 | platform := runtime.GOOS + "-" + runtime.GOARCH |
| 257 | pointer := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: hex.EncodeToString(sum[:])}}}} |
| 258 | if !resume { |
| 259 | signed, err := release.SignRootEnvelope(release.RootSigner(key), pointer) |
| 260 | lifecycleCheck(t, "sign executor", err) |
| 261 | identities, err := rootstate.Open("/opt/carlos/system/guard/identities", false, true) |
| 262 | lifecycleCheck(t, "executor identities", err) |
| 263 | lifecycleCheck(t, "executor identity", identities.Write("v2.0.0.json", signed)) |
| 264 | identities.Close() |
| 265 | } |
| 266 | |
| 267 | lifecycleArmGuard(t, store, key, actor, pointer, resume) |
| 268 | if !resume { |
| 269 | state, err := rootstate.Open(rootparticipation.Namespace, true, false) |
| 270 | lifecycleCheck(t, "census state", err) |
| 271 | census := rootparticipation.Census{Schema: 1, Box: actor.Box, Enrollment: actor.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true} |
| 272 | raw, err := json.Marshal(census) |
| 273 | lifecycleCheck(t, "census encode", err) |
| 274 | lifecycleCheck(t, "census", state.Write("census.json", raw)) |
| 275 | state.Close() |
| 276 | failures, err := rootstate.Open(rollout.RootFailuresDir, true, false) |
| 277 | lifecycleCheck(t, "failure state", err) |
| 278 | _, err = rollout.InitializeRootFailures(failures, rootUpdatesMigration(census), nil) |
| 279 | lifecycleCheck(t, "initialize migration failures", err) |
| 280 | failures.Close() |
| 281 | pins, err := rootstate.Open(release.RootGenerationsDir, true, false) |
| 282 | lifecycleCheck(t, "generation store", err) |
| 283 | pins.Close() |
| 284 | } |
| 285 | |
| 286 | now := time.Now().UTC() |
| 287 | rec := provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "", CreatedAt: now} |
| 288 | completeCreate := func() error { return nil } |
| 289 | if !resume { |
| 290 | completeCreate = lifecycleCreate(t, store, key, actor, pointer, rec) |
| 291 | manifest := release.Manifest{App: rec.App, Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| 292 | lifecycleCheck(t, "ship", release.Ship(t.Context(), store, rec.Account, manifest, map[string][]byte{release.AdoptPlatform(): image})) |
| 293 | lifecycleCheck(t, "promote", release.Promote(t.Context(), store, key, rec.Account, rec.App, "stable", "v1")) |
| 294 | } else { |
| 295 | found, _, e := provision.FindRecordByHost(t.Context(), store, lifecycleHost) |
| 296 | lifecycleCheck(t, "retained record", e) |
| 297 | rec = *found |
| 298 | } |
| 299 | withSibling := phase == "1" || phase == "2" || phase == "crash" |
| 300 | var completeSibling func() error |
| 301 | createSibling := func() { |
| 302 | if !withSibling || resume { |
| 303 | return |
| 304 | } |
| 305 | if completeSibling == nil { |
| 306 | sibling := provision.Record{Schema: 1, Host: lifecycleSibling, Account: "bab", App: "sibling", Channel: "stable", CreatedAt: time.Now().UTC()} |
| 307 | completeSibling = lifecycleCreate(t, store, key, actor, pointer, sibling) |
| 308 | manifest := release.Manifest{App: "sibling", Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| 309 | lifecycleCheck(t, "ship active sibling", release.Ship(t.Context(), store, "bab", manifest, map[string][]byte{release.AdoptPlatform(): image})) |
| 310 | lifecycleCheck(t, "promote active sibling", release.Promote(t.Context(), store, key, "bab", "sibling", "stable", "v1")) |
| 311 | _, _, e := release.StageSignedHead(t.Context(), store, key.Public().(ed25519.PublicKey), nil, "/opt/carlos", registry.Route{Host: lifecycleSibling, Account: "bab", App: "sibling", Channel: "stable"}) |
| 312 | lifecycleCheck(t, "signed sibling stage", e) |
| 313 | lifecycleCheck(t, "new deployment owner", filepath.Walk(lifecycleHome+"/deployment", func(path string, info os.FileInfo, e error) error { |
| 314 | if e != nil { |
| 315 | return e |
| 316 | } |
| 317 | return os.Chown(path, uid, gid) |
| 318 | })) |
| 319 | } |
| 320 | if err := completeSibling(); errors.Is(err, hostops.ErrPending) { |
| 321 | return |
| 322 | } else { |
| 323 | lifecycleCheck(t, "complete populated-box create", err) |
| 324 | } |
| 325 | lifecycleCheck(t, "sibling create ack", os.WriteFile("/run/carlos/lifecycle-sibling-created", []byte("ready"), 0644)) |
| 326 | } |
| 327 | // Only deployment inputs are service-owned. Root authority remains private. |
| 328 | lifecycleCheck(t, "deployment owner", filepath.Walk(lifecycleHome+"/deployment", func(path string, info os.FileInfo, err error) error { |
| 329 | if err != nil { |
| 330 | return err |
| 331 | } |
| 332 | return os.Chown(path, uid, gid) |
| 333 | })) |
| 334 | ctx := t.Context() |
| 335 | lifecycleInstallUnits(t) |
| 336 | out, mountErr := exec.Command("systemctl", "daemon-reload").CombinedOutput() |
| 337 | lifecycleCheck(t, string(out), mountErr) |
| 338 | out, mountErr = exec.Command("systemctl", "start", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount").CombinedOutput() |
| 339 | lifecycleCheck(t, string(out), mountErr) |
| 340 | recoverControl := lifecycleStartControl(t, server.URL) |
| 341 | lifecycleStartAgent(t, server.URL, completeCreate, createSibling, func() { |
| 342 | _, _, err = release.StageSignedHead(ctx, store, key.Public().(ed25519.PublicKey), nil, "/opt/carlos", registry.Route{Host: rec.Host, Account: rec.Account, App: rec.App, Channel: "stable"}) |
| 343 | lifecycleCheck(t, "signed stage", err) |
| 344 | lifecycleCheck(t, "point initial version", release.PointLive("/opt/carlos", rec.Host, rec.Account, "v1", rec.App)) |
| 345 | if withSibling && resume { |
| 346 | _, _, err := release.StageSignedHead(ctx, store, key.Public().(ed25519.PublicKey), nil, "/opt/carlos", registry.Route{Host: lifecycleSibling, Account: "bab", App: "sibling", Channel: "stable"}) |
| 347 | lifecycleCheck(t, "signed sibling stage", err) |
| 348 | if resume { |
| 349 | lifecycleCheck(t, "sibling live", release.PointLive("/opt/carlos", lifecycleSibling, "bab", "v1", "sibling")) |
| 350 | } |
| 351 | } |
| 352 | }, lifecycleDeleteDriver(t, phase, store, key, actor, pointer), recoverControl) |
| 353 | if phase == "1" { |
| 354 | lifecycleSaveBucket(t, model) |
| 355 | } |
| 356 | if phase == "2" || phase == "crash" { |
| 357 | lifecycleVerifyRetiredPins(t, store, key) |
| 358 | lifecycleReinstall(t, store, key, actor, pointer) |
| 359 | lifecycleCheck(t, "release agent", os.WriteFile("/run/carlos/lifecycle-finish", []byte("ready"), 0644)) |
| 360 | deadline := time.Now().Add(time.Minute) |
| 361 | for { |
| 362 | state, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ActiveState", "--value").Output() |
| 363 | if strings.TrimSpace(string(state)) == "inactive" { |
| 364 | break |
| 365 | } |
| 366 | if strings.TrimSpace(string(state)) == "failed" || time.Now().After(deadline) { |
| 367 | log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager", "-n", "80").CombinedOutput() |
| 368 | t.Fatalf("agent completion: %s", log) |
| 369 | } |
| 370 | time.Sleep(20 * time.Millisecond) |
| 371 | } |
| 372 | if phase == "crash" { |
| 373 | if _, err := os.Stat(lifecycleCrashPath); err != nil { |
| 374 | t.Fatal("crash point was not reached", err) |
| 375 | } |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | } |
| 380 | |
| 381 | func lifecycleStartAgent(t *testing.T, endpoint string, completeCreate func() error, createSibling func(), stage func(), deletion func(), recoverControl func()) { |
| 382 | t.Helper() |
| 383 | unit, err := unitsFS.ReadFile("units/carlos-edge.service") |
| 384 | lifecycleCheck(t, "edge template", err) |
| 385 | var lines []string |
| 386 | for _, line := range strings.Split(string(unit), "\n") { |
| 387 | key, _, _ := strings.Cut(line, "=") |
| 388 | switch key { |
| 389 | case "ExecReload", "Restart": |
| 390 | continue |
| 391 | } |
| 392 | lines = append(lines, line) |
| 393 | } |
| 394 | lines = append(lines, "[Service]", "Restart=no", "Environment=PLAN14_LIFECYCLE_AGENT=1", "Environment=PLAN14_LIFECYCLE_PHASE="+os.Getenv("PLAN14_LIFECYCLE_PHASE"), "Environment=PLAN14_LIFECYCLE_DATA_ENDPOINT="+endpoint, "Environment=TMPDIR=/run/carlos", "ReadWritePaths="+lifecycleHome) |
| 395 | path := "/run/systemd/system/carlos-edge.service" |
| 396 | f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) |
| 397 | lifecycleCheck(t, "edge exclusive", err) |
| 398 | _, err = f.WriteString(strings.Join(lines, "\n")) |
| 399 | f.Close() |
| 400 | lifecycleCheck(t, "edge write", err) |
| 401 | t.Cleanup(func() { |
| 402 | exec.Command("systemctl", "stop", "carlos-edge.service").Run() |
| 403 | os.Remove(path) |
| 404 | exec.Command("systemctl", "daemon-reload").Run() |
| 405 | }) |
| 406 | out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput() |
| 407 | lifecycleCheck(t, string(out), err) |
| 408 | out, err = exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput() |
| 409 | lifecycleCheck(t, string(out), err) |
| 410 | deadline := time.Now().Add(3 * time.Minute) |
| 411 | staged, created, recovered := false, false, false |
| 412 | for time.Now().Before(deadline) { |
| 413 | if _, err := os.Stat(lifecycleCrashPath); err == nil && !recovered { |
| 414 | recoverControl() |
| 415 | until := time.Now().Add(110 * time.Second) |
| 416 | for { |
| 417 | state, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ActiveState", "--value").Output() |
| 418 | if strings.TrimSpace(string(state)) == "failed" || strings.TrimSpace(string(state)) == "inactive" { |
| 419 | break |
| 420 | } |
| 421 | if time.Now().After(until) { |
| 422 | lifecycleCrashDiagnostic(t) |
| 423 | t.Fatal("edge did not finish cleanup after controller crash") |
| 424 | } |
| 425 | time.Sleep(100 * time.Millisecond) |
| 426 | } |
| 427 | os.Remove("/run/carlos/lifecycle-result") |
| 428 | if os.Getenv("PLAN14_LIFECYCLE_CRASH") == "retirement" { |
| 429 | lifecycleCheck(t, "resume retirement checkpoint", os.WriteFile("/run/carlos/lifecycle-resume-retirement", []byte("ready"), 0644)) |
| 430 | } |
| 431 | recoverControl() |
| 432 | out, err := exec.Command("systemctl", "start", "carlos-edge.service").CombinedOutput() |
| 433 | lifecycleCheck(t, string(out), err) |
| 434 | recovered = true |
| 435 | t.Log("controller and edge restarted after", os.Getenv("PLAN14_LIFECYCLE_CRASH")) |
| 436 | } |
| 437 | if _, err := os.Stat("/run/carlos/lifecycle-create-sibling"); err == nil { |
| 438 | createSibling() |
| 439 | } |
| 440 | if _, err := os.Stat("/run/carlos/lifecycle-sibling-installed"); err == nil { |
| 441 | lifecycleCheck(t, "sibling live", release.PointLive("/opt/carlos", lifecycleSibling, "bab", "v1", "sibling")) |
| 442 | } |
| 443 | if !created { |
| 444 | err := completeCreate() |
| 445 | if err == nil { |
| 446 | created = true |
| 447 | lifecycleCheck(t, "create ack", os.WriteFile("/run/carlos/lifecycle-created", []byte("ready"), 0644)) |
| 448 | } else if !errors.Is(err, hostops.ErrPending) { |
| 449 | lifecycleCheck(t, "complete create", err) |
| 450 | } |
| 451 | } |
| 452 | if !staged { |
| 453 | if _, err := os.Stat("/run/carlos/lifecycle-stage"); err == nil { |
| 454 | stage() |
| 455 | lifecycleCheck(t, "stage ack", os.WriteFile("/run/carlos/lifecycle-staged", []byte("ready"), 0644)) |
| 456 | staged = true |
| 457 | } |
| 458 | } |
| 459 | if _, err := os.Stat("/run/carlos/lifecycle-delete"); err == nil { |
| 460 | deletion() |
| 461 | } |
| 462 | raw, err := os.ReadFile("/run/carlos/lifecycle-result") |
| 463 | if err == nil { |
| 464 | if string(raw) != "pass" { |
| 465 | log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager").CombinedOutput() |
| 466 | t.Fatalf("%s: %s", raw, log) |
| 467 | } |
| 468 | log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager", "-n", "100").CombinedOutput() |
| 469 | t.Logf("completed agent: %s", log) |
| 470 | return |
| 471 | } |
| 472 | status, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "ActiveState", "--value").Output() |
| 473 | if strings.TrimSpace(string(status)) == "failed" { |
| 474 | break |
| 475 | } |
| 476 | time.Sleep(100 * time.Millisecond) |
| 477 | } |
| 478 | log, _ := exec.Command("journalctl", "-u", "carlos-edge.service", "--no-pager", "-n", "100").CombinedOutput() |
| 479 | t.Fatalf("agent did not complete: %s", log) |
| 480 | } |
| 481 | |
| 482 | func lifecycleAgent(t *testing.T) { |
| 483 | lifecycleStackTrace(t) |
| 484 | deadlineReady := time.Now().Add(3 * time.Minute) |
| 485 | for { |
| 486 | if _, err := os.Stat("/run/carlos-control/lifecycle-ready"); err == nil { |
| 487 | break |
| 488 | } |
| 489 | if time.Now().After(deadlineReady) { |
| 490 | t.Fatal("controller activation timeout") |
| 491 | } |
| 492 | time.Sleep(20 * time.Millisecond) |
| 493 | } |
| 494 | lifecycleEnvironment(t, os.Getenv("CARLOS_S3_ENDPOINT")) |
| 495 | completed := false |
| 496 | defer func() { |
| 497 | result := "agent failed; inspect journal" |
| 498 | if completed && !t.Failed() { |
| 499 | result = "pass" |
| 500 | } |
| 501 | os.WriteFile("/run/carlos/lifecycle-result", []byte(result), 0600) |
| 502 | }() |
| 503 | store := release.NewDirStore(lifecycleHome + "/deployment") |
| 504 | reg, err := registry.Open("/data/registry.db") |
| 505 | lifecycleCheck(t, "agent registry", err) |
| 506 | defer reg.Close() |
| 507 | manager := cgroup.SetupRetaining(t.Logf) |
| 508 | resources := ClaimRuntimeResources{Mode: "agent", Registry: reg, Manager: manager, DataDir: "/data", OptDir: "/opt/carlos", UnitsDir: "/etc/systemd/system"} |
| 509 | ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Store: store, Resources: resources}) |
| 510 | assembly, err := OpenProductionClaimAssembly(ctx, store, resources, lifecycleOwners{t}) |
| 511 | lifecycleCheck(t, "agent assembly", err) |
| 512 | defer func() { |
| 513 | cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 514 | defer cancel() |
| 515 | for { |
| 516 | t.Log("agent assembly close enter") |
| 517 | err := assembly.Close(cleanup) |
| 518 | t.Log("agent assembly close returned", err) |
| 519 | if err == nil { |
| 520 | return |
| 521 | } |
| 522 | if cleanup.Err() != nil { |
| 523 | t.Error("agent cleanup", err) |
| 524 | return |
| 525 | } |
| 526 | time.Sleep(100 * time.Millisecond) |
| 527 | } |
| 528 | }() |
| 529 | providers, err := assembly.Build(ctx) |
| 530 | lifecycleCheck(t, "agent build", err) |
| 531 | lifecycleTraceRegional(t, &providers) |
| 532 | oldPrerequisites := providers.Wake.Prerequisites |
| 533 | providers.Wake.Prerequisites = func(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) error { |
| 534 | err := oldPrerequisites(ctx, rt, o, policy) |
| 535 | if err != nil && strings.Contains(err.Error(), "no restorable data") { |
| 536 | h := o.Claim.Holder |
| 537 | if assembly.Guard.FenceView != nil { |
| 538 | view, e := assembly.Guard.FenceView(ctx, o.Host, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| 539 | t.Logf("empty recovery root view %+v error=%v", view, e) |
| 540 | for _, r := range view.Receipts { |
| 541 | t.Logf("empty recovery receipt target=%+v body=%s", r.Target, r.Bytes) |
| 542 | } |
| 543 | } |
| 544 | launches, e := assembly.Launches.HostLaunches(ctx, o.Host) |
| 545 | t.Logf("empty recovery launches %+v error=%v", launches, e) |
| 546 | } |
| 547 | return err |
| 548 | } |
| 549 | lifecycleTraceWake(t, assembly.Wake) |
| 550 | deadline := time.Now().Add(time.Minute) |
| 551 | for { |
| 552 | if _, err := os.Stat("/run/carlos/lifecycle-created"); err == nil { |
| 553 | break |
| 554 | } |
| 555 | if time.Now().After(deadline) { |
| 556 | t.Fatal("create acknowledgement timeout") |
| 557 | } |
| 558 | time.Sleep(20 * time.Millisecond) |
| 559 | } |
| 560 | cfg := provision.ReconcileConfig{Region: "", DataDir: "/data", Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }} |
| 561 | providers.Provision(&cfg) |
| 562 | resumingRetirement := false |
| 563 | if _, e := os.Stat("/run/carlos/lifecycle-resume-retirement"); e == nil { |
| 564 | obs, e := hostclaim.Read(ctx, store, lifecycleHost) |
| 565 | lifecycleCheck(t, "retirement recovery claim", e) |
| 566 | if obs.Claim == nil || (!obs.Claim.Free() && obs.Claim.Op.Kind != hostclaim.KindParking) { |
| 567 | t.Fatal("retirement recovery lost parking claim") |
| 568 | } |
| 569 | _, _, e = store.Get(ctx, provision.RecordKey("bab", "lifecycle", lifecycleHost)) |
| 570 | if !errors.Is(e, os.ErrNotExist) { |
| 571 | t.Fatal("retirement recovery record still present", e) |
| 572 | } |
| 573 | resumingRetirement = true |
| 574 | } |
| 575 | lifecycleCheck(t, "provision", provision.Reconcile(ctx, store, reg, cfg)) |
| 576 | if !resumingRetirement { |
| 577 | route, err := reg.Get(lifecycleHost) |
| 578 | lifecycleCheck(t, "route", err) |
| 579 | if route == nil { |
| 580 | t.Fatal("create did not install route") |
| 581 | } |
| 582 | lifecycleCheck(t, "request staging", os.WriteFile("/run/carlos/lifecycle-stage", []byte("ready"), 0600)) |
| 583 | stageDeadline := time.Now().Add(time.Minute) |
| 584 | for { |
| 585 | if _, err := os.Stat("/run/carlos/lifecycle-staged"); err == nil { |
| 586 | break |
| 587 | } |
| 588 | if time.Now().After(stageDeadline) { |
| 589 | t.Fatal("stage timeout") |
| 590 | } |
| 591 | time.Sleep(20 * time.Millisecond) |
| 592 | } |
| 593 | wakes := 2 |
| 594 | if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" { |
| 595 | wakes = 1 |
| 596 | } |
| 597 | for wake := 0; wake < wakes; wake++ { |
| 598 | route, err = reg.Get(lifecycleHost) |
| 599 | lifecycleCheck(t, "fresh route for wake", err) |
| 600 | if route == nil { |
| 601 | t.Fatal("route disappeared before wake") |
| 602 | } |
| 603 | finish := lifecycleLaunch(t, ctx, providers.Wake, assembly.Wake, *route) |
| 604 | if wake == 0 && (os.Getenv("PLAN14_LIFECYCLE_PHASE") == "1" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash") { |
| 605 | lifecycleCheck(t, "request populated-box create", os.WriteFile("/run/carlos/lifecycle-create-sibling", []byte("ready"), 0644)) |
| 606 | until := time.Now().Add(time.Minute) |
| 607 | for { |
| 608 | if _, e := os.Stat("/run/carlos/lifecycle-sibling-created"); e == nil { |
| 609 | break |
| 610 | } |
| 611 | if time.Now().After(until) { |
| 612 | t.Fatal("populated-box creation timeout") |
| 613 | } |
| 614 | lifecycleCheck(t, "populated-box reconcile", provision.Reconcile(ctx, store, reg, cfg)) |
| 615 | time.Sleep(20 * time.Millisecond) |
| 616 | } |
| 617 | lifecycleCheck(t, "provision sibling while main active", provision.Reconcile(ctx, store, reg, cfg)) |
| 618 | sibling, e := reg.Get(lifecycleSibling) |
| 619 | lifecycleCheck(t, "new sibling row", e) |
| 620 | if sibling == nil { |
| 621 | t.Fatal("populated-box creation has no route") |
| 622 | } |
| 623 | lifecycleCheck(t, "sibling installed", os.WriteFile("/run/carlos/lifecycle-sibling-installed", []byte("ready"), 0644)) |
| 624 | } |
| 625 | finish() |
| 626 | t.Logf("completed cold wake %d", wake+1) |
| 627 | } |
| 628 | } |
| 629 | if os.Getenv("PLAN14_LIFECYCLE_PHASE") == "2" || os.Getenv("PLAN14_LIFECYCLE_PHASE") == "crash" { |
| 630 | if !resumingRetirement { |
| 631 | parkDeadline := time.Now().Add(time.Minute) |
| 632 | for { |
| 633 | obs, err := hostclaim.Read(ctx, store, lifecycleHost) |
| 634 | lifecycleCheck(t, "pre-delete observation", err) |
| 635 | if len(obs.Claim.Obligations) == 0 { |
| 636 | break |
| 637 | } |
| 638 | if time.Now().After(parkDeadline) { |
| 639 | lifecycleTraceParkState(t, providers, obs) |
| 640 | t.Fatal("pre-delete obligations did not drain", obs.Claim.Obligations) |
| 641 | } |
| 642 | if err := providers.Regional.(hostbox.RegionalWorker).Run(ctx); err != nil { |
| 643 | t.Log("pre-delete parks", err) |
| 644 | } |
| 645 | time.Sleep(100 * time.Millisecond) |
| 646 | } |
| 647 | } |
| 648 | var finishSibling func() |
| 649 | { |
| 650 | sibling, err := reg.Get(lifecycleSibling) |
| 651 | lifecycleCheck(t, "sibling route", err) |
| 652 | if sibling == nil { |
| 653 | t.Fatal("missing sibling installation") |
| 654 | } |
| 655 | finishSibling = lifecycleLaunch(t, ctx, providers.Wake, assembly.Wake, *sibling) |
| 656 | lifecycleCheck(t, "active sibling ready", os.WriteFile("/run/carlos/lifecycle-sibling", []byte("active"), 0644)) |
| 657 | } |
| 658 | lifecycleCheck(t, "request deletion", os.WriteFile("/run/carlos/lifecycle-delete", []byte("ready"), 0600)) |
| 659 | deadline := time.Now().Add(time.Minute) |
| 660 | for time.Now().Before(deadline) { |
| 661 | if e := provision.Reconcile(ctx, store, reg, cfg); e != nil { |
| 662 | t.Log("retirement reconcile", e) |
| 663 | } |
| 664 | if e := providers.Regional.(hostbox.RegionalWorker).Run(ctx); e != nil { |
| 665 | t.Log("regional retirement", e) |
| 666 | } |
| 667 | obs, e := hostclaim.Read(ctx, store, lifecycleHost) |
| 668 | lifecycleCheck(t, "retirement observation", e) |
| 669 | if obs.Claim.Free() { |
| 670 | lifecycleCheck(t, "agent checkpoint", os.WriteFile("/run/carlos/lifecycle-result", []byte("pass"), 0600)) |
| 671 | lifecycleAgentReinstall(t, ctx, store) |
| 672 | if finishSibling != nil { |
| 673 | finishSibling() |
| 674 | } |
| 675 | completed = true |
| 676 | return |
| 677 | } |
| 678 | time.Sleep(100 * time.Millisecond) |
| 679 | } |
| 680 | t.Fatal("retirement did not complete") |
| 681 | } |
| 682 | completed = true |
| 683 | } |
| 684 | |
| 685 | func lifecycleWake(t *testing.T, ctx context.Context, runtime *activator.ClaimRuntime, wake *ClaimWakeAssembly, route registry.Route) { |
| 686 | lifecycleLaunch(t, ctx, runtime, wake, route)() |
| 687 | } |
| 688 | |
| 689 | func lifecycleLaunch(t *testing.T, ctx context.Context, runtime *activator.ClaimRuntime, wake *ClaimWakeAssembly, route registry.Route) func() { |
| 690 | t.Helper() |
| 691 | observed, err := runtime.Read(ctx, route.Host) |
| 692 | lifecycleCheck(t, "read for wake", err) |
| 693 | policy := activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second} |
| 694 | prerequisiteDeadline := time.Now().Add(2 * time.Minute) |
| 695 | for { |
| 696 | e := runtime.Prerequisites(ctx, route, observed, policy) |
| 697 | if e == nil { |
| 698 | break |
| 699 | } |
| 700 | fresh, readErr := runtime.Read(ctx, route.Host) |
| 701 | retry := errors.Is(e, hostbox.ErrChanged) && readErr == nil && fresh.ETag != observed.ETag |
| 702 | if errors.Is(e, hostclaim.ErrWrongState) && readErr == nil && fresh.Claim.Holder.WakeRetry != nil && fresh.Claim.Holder.WakeRetry.State == "open" { |
| 703 | retry = true |
| 704 | } |
| 705 | if !retry || time.Now().After(prerequisiteDeadline) { |
| 706 | t.Logf("prerequisite state: holder=%+v route=%+v", observed.Claim.Holder, route) |
| 707 | lifecycleCheck(t, "wake prerequisites", e) |
| 708 | } |
| 709 | observed = fresh |
| 710 | time.Sleep(100 * time.Millisecond) |
| 711 | } |
| 712 | admitted, err := runtime.Admit(ctx, route, observed, policy) |
| 713 | lifecycleCheck(t, "cold admission", err) |
| 714 | start, err := runtime.Prepare(ctx, route, admitted, policy) |
| 715 | lifecycleCheck(t, "wake prepare", err) |
| 716 | lifecycleCheck(t, "restore", start.Restore(ctx)) |
| 717 | lifecycleCheck(t, "reserve", start.Reserve(ctx, 0)) |
| 718 | lifecycleCheck(t, "materialize", start.Materialize(ctx)) |
| 719 | launch, err := start.PrepareLaunch(ctx) |
| 720 | lifecycleCheck(t, "launch preparation", err) |
| 721 | cmd, _, settle, producer, err := launch.Start(ctx, "main", start.CurrentAttempt(), start.Build, func() error { return ctx.Err() }) |
| 722 | if err != nil && settle != nil { |
| 723 | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) |
| 724 | cleanupErr := lifecycleRetry(cleanup, settle) |
| 725 | cancel() |
| 726 | t.Log("failed launch settlement", cleanupErr) |
| 727 | } |
| 728 | lifecycleCheck(t, "root launch", err) |
| 729 | var stopMu sync.Mutex |
| 730 | stopped := make(chan struct{}) |
| 731 | stop := func(call context.Context) error { |
| 732 | stopMu.Lock() |
| 733 | defer stopMu.Unlock() |
| 734 | select { |
| 735 | case <-stopped: |
| 736 | return nil |
| 737 | default: |
| 738 | } |
| 739 | if err := settle(call); err != nil { |
| 740 | return err |
| 741 | } |
| 742 | if cmd.Process != nil { |
| 743 | _ = cmd.Process.Release() |
| 744 | } |
| 745 | close(stopped) |
| 746 | return nil |
| 747 | } |
| 748 | if producer.Context != nil { |
| 749 | go func() { |
| 750 | select { |
| 751 | case <-stopped: |
| 752 | return |
| 753 | case <-producer.Context.Done(): |
| 754 | } |
| 755 | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) |
| 756 | defer cancel() |
| 757 | _ = lifecycleRetry(cleanup, stop) |
| 758 | }() |
| 759 | } |
| 760 | lifecycleCheck(t, "tenant ready", start.Ready(ctx, cmd)) |
| 761 | prefix, err := hostbox.GenerationPrefix(*admitted.Claim.Holder) |
| 762 | lifecycleCheck(t, "generation prefix", err) |
| 763 | rep, err := start.Replicate(ctx, prefix) |
| 764 | lifecycleCheck(t, "replicate", err) |
| 765 | current, err := runtime.Read(ctx, route.Host) |
| 766 | lifecycleCheck(t, "read publication", err) |
| 767 | _, err = start.Publish(ctx, current) |
| 768 | lifecycleCheck(t, "publish", err) |
| 769 | lifecycleCheck(t, "healthy", start.Healthy(ctx, 0, cmd)) |
| 770 | transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| 771 | return (&net.Dialer{}).DialContext(ctx, "unix", route.Socket) |
| 772 | }} |
| 773 | defer transport.CloseIdleConnections() |
| 774 | response, err := (&http.Client{Transport: transport, Timeout: time.Second}).Get("http://tenant/") |
| 775 | lifecycleCheck(t, "serve", err) |
| 776 | raw, err := io.ReadAll(response.Body) |
| 777 | response.Body.Close() |
| 778 | lifecycleCheck(t, "read tenant", err) |
| 779 | if response.StatusCode != 200 || string(raw) != "lifecycle tenant" { |
| 780 | t.Fatalf("tenant response %d %q", response.StatusCode, raw) |
| 781 | } |
| 782 | return func() { |
| 783 | lifecycleCheck(t, "stop tenant", lifecycleRetry(ctx, stop)) |
| 784 | lifecycleCheck(t, "stop replication", rep.Close(ctx)) |
| 785 | current, err = runtime.Read(ctx, route.Host) |
| 786 | lifecycleCheck(t, "read stopped session", err) |
| 787 | lifecycleCheck(t, "stop session", lifecycleRetry(ctx, func(ctx context.Context) error { return wake.Stop(ctx, current) })) |
| 788 | } |
| 789 | } |
| 790 | |
| 791 | func lifecycleInstallUnits(t *testing.T) { |
| 792 | t.Helper() |
| 793 | for _, name := range []string{"carlos.slice", "carlos-instances.slice", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-instance@.service", "carlos-system-recover.service"} { |
| 794 | raw, err := unitsFS.ReadFile("units/" + name) |
| 795 | lifecycleCheck(t, "unit template", err) |
| 796 | path := filepath.Join("/etc/systemd/system", name) |
| 797 | f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) |
| 798 | lifecycleCheck(t, "exclusive unit", err) |
| 799 | _, err = f.Write(raw) |
| 800 | f.Close() |
| 801 | lifecycleCheck(t, "install unit", err) |
| 802 | t.Cleanup(func() { |
| 803 | exec.Command("systemctl", "stop", name).Run() |
| 804 | os.Remove(path) |
| 805 | exec.Command("systemctl", "daemon-reload").Run() |
| 806 | }) |
| 807 | } |
| 808 | t.Cleanup(func() { |
| 809 | exec.Command("systemctl", "stop", instancectl.UnitName(instancectl.RoleRun, lifecycleSibling)).Run() |
| 810 | exec.Command("systemctl", "stop", instancectl.SliceName(lifecycleSibling)).Run() |
| 811 | exec.Command("systemctl", "stop", instancectl.UnitName(instancectl.RoleRun, lifecycleHost)).Run() |
| 812 | exec.Command("systemctl", "stop", instancectl.SliceName(lifecycleHost)).Run() |
| 813 | for _, name := range []string{instancectl.UnitName(instancectl.RoleRun, lifecycleHost), instancectl.SliceName(lifecycleHost), instancectl.UnitName(instancectl.RoleRun, lifecycleSibling), instancectl.SliceName(lifecycleSibling)} { |
| 814 | os.RemoveAll(filepath.Join("/run/systemd/system", name+".d")) |
| 815 | os.RemoveAll(filepath.Join("/run/systemd/system.control", name+".d")) |
| 816 | } |
| 817 | }) |
| 818 | } |
| 819 | |
| 820 | type lifecycleActorEvidence struct { |
| 821 | box, console hostactors.Report |
| 822 | identity []byte |
| 823 | } |
| 824 | |
| 825 | func (e lifecycleActorEvidence) BoxReport(context.Context, string, hostactors.Box) (hostactors.Report, error) { |
| 826 | return e.box, nil |
| 827 | } |
| 828 | func (e lifecycleActorEvidence) ConsoleReport(context.Context, string, hostactors.Console) (hostactors.Report, error) { |
| 829 | return e.console, nil |
| 830 | } |
| 831 | func (e lifecycleActorEvidence) LocalConsole(context.Context) (hostactors.Report, error) { |
| 832 | return e.console, nil |
| 833 | } |
| 834 | func (e lifecycleActorEvidence) Identity(context.Context, string) ([]byte, error) { |
| 835 | return e.identity, nil |
| 836 | } |
| 837 | func (e lifecycleActorEvidence) Fence(context.Context, hostactors.Observed, string, time.Time) (bool, error) { |
| 838 | return false, nil |
| 839 | } |
| 840 | |
| 841 | func lifecycleCreate(t *testing.T, raw release.Store, key ed25519.PrivateKey, actor hostbox.Actor, pointer release.SystemPointer, rec provision.Record) func() error { |
| 842 | t.Helper() |
| 843 | ctx := t.Context() |
| 844 | signer := release.RootSigner(key) |
| 845 | directory := hostactors.Directory{Store: raw, PublicKey: key.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket} |
| 846 | lifecycleCheck(t, "publish test deployment floor", hostfloor.Publish(ctx, raw, signer, actor.ControlBucket, hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"})) |
| 847 | floor, err := hostfloor.Read(ctx, raw, directory.PublicKey, actor.ControlBucket) |
| 848 | lifecycleCheck(t, "read deployment floor", err) |
| 849 | roster, err := directory.Read(ctx) |
| 850 | lifecycleCheck(t, "read actor roster", err) |
| 851 | if roster.Roster.Publication != "published" { |
| 852 | roster.Roster.Publication, roster.Roster.FloorSHA256 = "published", floor.Digest |
| 853 | roster.Roster.Regions = map[string]hostactors.Region{actor.Region: {Account: actor.Cloud.Account, AWSRegion: actor.Cloud.Region, Namespaces: []string{actor.Namespace}, Pools: []string{"default"}}} |
| 854 | roster.Roster.Consoles["console"] = hostactors.Console{Enrollment: strings.Repeat("d", 32), InstanceID: "console", Namespaces: []string{actor.Namespace}, DeploymentBucket: actor.ControlBucket} |
| 855 | _, err = directory.Replace(ctx, signer, roster, roster.Roster) |
| 856 | lifecycleCheck(t, "publish actors", err) |
| 857 | } |
| 858 | controller, authority, control := lifecycleController(t, raw, key, actor, pointer) |
| 859 | data := controller.Store |
| 860 | appID, err := control.EnrollNewApp(ctx, rec.Account, rec.App) |
| 861 | lifecycleCheck(t, "enroll app", err) |
| 862 | before, err := hostclaim.Read(ctx, data, rec.Host) |
| 863 | lifecycleCheck(t, "read before registration", err) |
| 864 | id := "create-" + rec.App |
| 865 | start, err := control.RegisterStart(ctx, rec.Account, rec.App, appID, id, hostops.Registration{Kind: "create", Host: rec.Host, Owner: hostops.RegistrationOwner{Console: "console", Enrollment: strings.Repeat("d", 32), Controller: "console-controller"}, AppID: appID, Epoch: 1, Claim: hostops.SavedObservation(before)}, time.Now()) |
| 866 | lifecycleCheck(t, "register create", err) |
| 867 | rec.Acquisition = id |
| 868 | requested := rec |
| 869 | requested.CreatedAt = time.Time{} |
| 870 | record, err := hostops.PrepareInstance(requested, rec.CreatedAt, park.PlacementRegion(rec.Region)) |
| 871 | lifecycleCheck(t, "prepare create record", err) |
| 872 | incarnation, err := record.Incarnation() |
| 873 | lifecycleCheck(t, "create incarnation", err) |
| 874 | lifecycleCheck(t, "bind registration", start.Bind(ctx, rec.CreatedAt, incarnation, time.Now())) |
| 875 | createCtx := hostops.WithStartHandle(start.Lifecycle(ctx), start) |
| 876 | observed, err := authority.StartCreate(createCtx, before, record, id, "runner") |
| 877 | lifecycleCheck(t, "production begin create", err) |
| 878 | lifecycleCheck(t, "production record effects", authority.RecordEffects(createCtx, observed, record)) |
| 879 | created := false |
| 880 | return func() error { |
| 881 | if created { |
| 882 | return nil |
| 883 | } |
| 884 | _, err := controller.Complete(createCtx, rec.Host, id) |
| 885 | if err != nil { |
| 886 | return err |
| 887 | } |
| 888 | err = control.SettleRegistration(ctx, rec.Account, rec.App, appID, id, time.Now()) |
| 889 | created = err == nil |
| 890 | return err |
| 891 | } |
| 892 | } |
| 893 | |
| 894 | func TestPlan14LifecycleCreateController(t *testing.T) { |
| 895 | store := release.NewDirStore(t.TempDir()) |
| 896 | key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| 897 | actor := hostbox.Actor{Box: "box", Enrollment: strings.Repeat("a", 32), Namespace: "platform", Region: "ie", ControlBucket: "deployment", Cloud: hostbox.CloudIdentity{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}} |
| 898 | roster := hostactors.Roster{V: 1, HomeRegion: "ie", DeploymentBucket: actor.ControlBucket, Revision: 1, Boxes: map[string]hostactors.Box{actor.Box: {BoxID: actor.Box, Enrollment: actor.Enrollment, Namespace: actor.Namespace, Label: actor.Box, Region: actor.Region, Pool: "default", Cloud: hostactors.Cloud{Provider: actor.Cloud.Provider, Account: actor.Cloud.Account, Region: actor.Cloud.Region, InstanceID: actor.Cloud.InstanceID}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 899 | raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) |
| 900 | lifecycleCheck(t, "sign roster", err) |
| 901 | _, err = store.Put(t.Context(), hostactors.Key, raw, "") |
| 902 | lifecycleCheck(t, "write roster", err) |
| 903 | platform := release.AdoptPlatform() |
| 904 | pointer := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: "v2.0.0", ReleaseTag: "v2.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey("v2.0.0", platform), SHA256: strings.Repeat("a", 64)}}}} |
| 905 | lifecycleCreate(t, store, key, actor, pointer, provision.Record{Schema: 1, Host: lifecycleHost, Account: "bab", App: "lifecycle", Channel: "stable", Region: "", CreatedAt: time.Now().UTC()}) |
| 906 | record, err := hostops.ReadRecord(t.Context(), store, provision.RecordKey("bab", "lifecycle", lifecycleHost)) |
| 907 | lifecycleCheck(t, "read created record", err) |
| 908 | if record.Holder.Acquisition == "" { |
| 909 | t.Fatal("create did not retain acquisition") |
| 910 | } |
| 911 | } |
| 912 | |
| 913 | func lifecycleController(t *testing.T, raw release.Store, key ed25519.PrivateKey, actor hostbox.Actor, pointer release.SystemPointer) (hostops.Controller, *hostops.ProductionAuthority, *hostops.ControlWriter) { |
| 914 | t.Helper() |
| 915 | ctx := t.Context() |
| 916 | signer := release.RootSigner(key) |
| 917 | directory := hostactors.Directory{Store: raw, PublicKey: key.Public().(ed25519.PublicKey), Bucket: actor.ControlBucket} |
| 918 | floor, err := hostfloor.Read(ctx, raw, directory.PublicKey, actor.ControlBucket) |
| 919 | lifecycleCheck(t, "controller floor", err) |
| 920 | platform := release.AdoptPlatform() |
| 921 | pointer.Current.ConsoleArtifacts = map[string]release.SystemArtifact{platform: {Key: "bootstrap/console/v2.0.0/carlos-" + platform, SHA256: pointer.Current.Artifacts[platform].SHA256}} |
| 922 | signed, err := release.SignRootEnvelope(signer, pointer) |
| 923 | lifecycleCheck(t, "sign actor release", err) |
| 924 | report := hostactors.Report{Component: "router", GeneratedAt: time.Now(), Actor: actor.Box, Enrollment: actor.Enrollment, InstanceID: actor.Cloud.InstanceID, Release: "v2.0.0", ReleaseTag: "v2.0.0", Platform: platform, BinarySHA256: pointer.Current.Artifacts[platform].SHA256, MovesProtocol: 1, BootID: "external-box-boot", FloorSHA256: floor.Digest, FloorGuardReady: true, HostClaimEnabled: true} |
| 925 | console := report |
| 926 | console.Component, console.Actor, console.InstanceID, console.Enrollment, console.ProcessID = "console", "console", "console", strings.Repeat("d", 32), "console-process" |
| 927 | control, err := hostops.NewControlWriter(raw, directory.PublicKey, time.Now) |
| 928 | lifecycleCheck(t, "control writer", err) |
| 929 | check, err := hostops.NewProductionMutationCheck(raw, control) |
| 930 | lifecycleCheck(t, "mutation check", err) |
| 931 | records, err := hostops.NewConditionalRecordStore(raw, directory.PublicKey) |
| 932 | lifecycleCheck(t, "record boundary", err) |
| 933 | data, err := hostops.NewObservedMutationBoundary(true, records, check, nil) |
| 934 | lifecycleCheck(t, "mutation boundary", err) |
| 935 | directory.Store = data |
| 936 | authority := &hostops.ProductionAuthority{HomeRegion: "ie", Store: data, Control: control, Directory: directory, ConsoleID: "console", Enrollment: console.Enrollment, Namespace: actor.Namespace, Signer: signer, Now: time.Now, Evidence: lifecycleActorEvidence{report, console, signed}} |
| 937 | controller := hostops.Controller{Store: data, Control: control, Authority: authority, Now: time.Now, NewID: func() string { |
| 938 | var id [24]byte |
| 939 | if _, err := rand.Read(id[:]); err != nil { |
| 940 | t.Fatal(err) |
| 941 | } |
| 942 | return hex.EncodeToString(id[:]) |
| 943 | }} |
| 944 | return controller, authority, control |
| 945 | } |
| 946 | |
| 947 | func TestPlan14LifecycleControlProcessPayload(t *testing.T) { |
| 948 | lifecycleStackTrace(t) |
| 949 | if os.Getenv("PLAN14_LIFECYCLE_CONTROL") != "1" { |
| 950 | t.Fatal("controller helper") |
| 951 | } |
| 952 | lifecycleEnvironment(t, os.Getenv("CARLOS_S3_ENDPOINT")) |
| 953 | _, gid := generationRunnerRegistryUser(t) |
| 954 | store := release.NewDirStore(lifecycleHome + "/deployment") |
| 955 | lifecycleControlFaults(t) |
| 956 | reg, err := openServiceRegistry("/data/registry.db") |
| 957 | lifecycleCheck(t, "registry", err) |
| 958 | t.Cleanup(func() { reg.Close() }) |
| 959 | resources := ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: "/data", OptDir: "/opt/carlos", UnitsDir: "/etc/systemd/system"} |
| 960 | ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Store: store, Resources: resources}) |
| 961 | assembly, err := OpenProductionClaimAssembly(ctx, store, resources, lifecycleOwners{t}) |
| 962 | lifecycleCheck(t, "root assembly", err) |
| 963 | t.Cleanup(func() { assembly.Close(context.Background()) }) |
| 964 | ctx = WithClaimAssembly(ctx, assembly) |
| 965 | lifecycleCheck(t, "launch verifier", registerProductionLaunchVerifier()) |
| 966 | root, err := NewProductionRootClaimStop(ctx) |
| 967 | lifecycleCheck(t, "root constructor", err) |
| 968 | wrapped, err := WithProductionClaimLaunch(ctx, root) |
| 969 | lifecycleCheck(t, "launch constructor", err) |
| 970 | rootHTTP := wrapped.(ClaimRootWithLaunch) |
| 971 | // lifecycleOwners opens no cleanup enrollment, so production wiring |
| 972 | // leaves the stop numeral bound unset (every stop refuses); the fixture |
| 973 | // bounds by its identity's roster revision instead. |
| 974 | rootHTTP.Root.Stop.Coordinator.VerifiedRosterRevision = func(ctx context.Context) (uint64, error) { |
| 975 | actor, err := assembly.Identity.Verify(ctx) |
| 976 | return actor.Revision, err |
| 977 | } |
| 978 | rootHTTP.Root.Stop.Coordinator.Kernel = &lifecycleStopTrace{claimUnitStopKernel: rootHTTP.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel), t: t} |
| 979 | rootHTTP.Launch.Kernel = lifecycleKernelTrace{rootHTTP.Launch.Kernel, t} |
| 980 | rootHTTP.Launch.Units = lifecycleUnitsTrace{rootHTTP.Launch.Units, t} |
| 981 | begin := rootHTTP.Launch.generations.beginUpdate |
| 982 | rootHTTP.Launch.generations.beginUpdate = func(ctx context.Context, s *rootGenerationSource, b release.GenerationBinding) (func(string, error, func() error) error, func() error, error) { |
| 983 | lifecycleCrash(t, "reservation") |
| 984 | finish, close, err := begin(ctx, s, b) |
| 985 | t.Log("generation update opener", err) |
| 986 | return finish, close, err |
| 987 | } |
| 988 | t.Cleanup(func() { rootHTTP.Root.executionRuntime.Close(context.Background()) }) |
| 989 | deadline := time.Now().Add(time.Minute) |
| 990 | for { |
| 991 | out, _ := exec.Command("systemctl", "show", "carlos-edge.service", "-p", "MainPID", "--value").Output() |
| 992 | if strings.TrimSpace(string(out)) != "" && strings.TrimSpace(string(out)) != "0" { |
| 993 | break |
| 994 | } |
| 995 | if time.Now().After(deadline) { |
| 996 | t.Fatal("edge startup timeout") |
| 997 | } |
| 998 | time.Sleep(20 * time.Millisecond) |
| 999 | } |
| 1000 | if rootHTTP.Root.servingParks != nil { |
| 1001 | if p, ok := rootHTTP.Root.servingParks.authority.(*servingParkRoot); ok { |
| 1002 | fs := p.ledger.fs.(*servingParkFS) |
| 1003 | for _, name := range []string{"namespace-parent.json", "namespace.json", "current-namespace.json"} { |
| 1004 | raw, err := p.ledger.state.Read(name) |
| 1005 | t.Log("namespace before activation", name, string(raw), err) |
| 1006 | } |
| 1007 | parent, id, e := fs.parent() |
| 1008 | t.Log("namespace current parent", id, e) |
| 1009 | if e == nil { |
| 1010 | ph, pe := fs.handle(parent) |
| 1011 | t.Log("namespace parent proof", ph, pe) |
| 1012 | d, did, de := fs.privateDir(parent, ".serving-parked") |
| 1013 | t.Log("namespace current root", did, de) |
| 1014 | if de == nil { |
| 1015 | dh, he := fs.handle(d) |
| 1016 | t.Log("namespace root proof", dh, he) |
| 1017 | d.Close() |
| 1018 | } |
| 1019 | parent.Close() |
| 1020 | } |
| 1021 | } |
| 1022 | } |
| 1023 | startup, finishStartup := context.WithTimeout(ctx, 2*time.Minute) |
| 1024 | defer finishStartup() |
| 1025 | if err := activateClaimRoot(ctx, startup, rootHTTP); err != nil { |
| 1026 | lifecycleActivationDiagnostic(t, ctx) |
| 1027 | lifecycleCheck(t, "production activation gate", err) |
| 1028 | } |
| 1029 | finishStartup() |
| 1030 | listener, err := net.Listen("unix", controlsock.Path) |
| 1031 | lifecycleCheck(t, "root socket", err) |
| 1032 | lifecycleCheck(t, "root socket mode", os.Chmod(controlsock.Path, 0660)) |
| 1033 | lifecycleCheck(t, "root socket group", os.Chown(controlsock.Path, 0, gid)) |
| 1034 | controlCtx, cancelControl := context.WithCancel(ctx) |
| 1035 | defer cancelControl() |
| 1036 | control := &instancectl.Server{ClaimStop: lifecycleControlTrace{rootHTTP, t}, PeerOK: func(c net.Conn) error { _, err := rootHTTP.Root.Stop.Authenticate(ctx, c); return err }, Logf: t.Logf} |
| 1037 | done := make(chan error, 1) |
| 1038 | go func() { done <- control.Serve(controlCtx, listener) }() |
| 1039 | defer func() { cancelControl(); lifecycleCheck(t, "control shutdown", <-done) }() |
| 1040 | |
| 1041 | lifecycleCheck(t, "controller ready", os.WriteFile("/run/carlos-control/lifecycle-ready", []byte("ready"), 0644)) |
| 1042 | <-ctx.Done() |
| 1043 | } |
| 1044 | |
| 1045 | func lifecycleTraceWake(t *testing.T, w *ClaimWakeAssembly) { |
| 1046 | if old := w.AfterAdmission; old != nil { |
| 1047 | w.AfterAdmission = func(ctx context.Context, p, o hostclaim.Observed) error { |
| 1048 | e := old(ctx, p, o) |
| 1049 | t.Log("after admission", e) |
| 1050 | return e |
| 1051 | } |
| 1052 | } |
| 1053 | if old := w.Prelaunch; old != nil { |
| 1054 | w.Prelaunch = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) (*activator.RoleLaunch, error) { |
| 1055 | l, e := old(ctx, o, work, in) |
| 1056 | t.Log("prelaunch", e) |
| 1057 | return l, e |
| 1058 | } |
| 1059 | } |
| 1060 | |
| 1061 | if dispatch, ok := w.Inputs.(ClaimOwnerDispatch); ok { |
| 1062 | if serving, ok := dispatch.Serving.Inputs.(*claimServingOwners); ok && serving.emptyRecovery != nil { |
| 1063 | check := serving.emptyRecovery |
| 1064 | serving.emptyRecovery = func(ctx context.Context, o hostclaim.Observed, a []hostclaim.AdmissionRecord) (bool, error) { |
| 1065 | yes, err := check(ctx, o, a) |
| 1066 | t.Logf("empty recovery holder=%+v admissions=%+v yes=%v err=%v", o.Claim.Holder, a, yes, err) |
| 1067 | return yes, err |
| 1068 | } |
| 1069 | if f, ok := serving.Sources.Files.(*movesFiles); ok { |
| 1070 | f.emptyRecovery = serving.emptyRecovery |
| 1071 | } |
| 1072 | } |
| 1073 | } |
| 1074 | if old := w.ServingPrerequisites; old != nil { |
| 1075 | w.ServingPrerequisites = func(ctx context.Context, rt registry.Route, o hostclaim.Observed) error { |
| 1076 | e := old(ctx, rt, o) |
| 1077 | t.Log("files prerequisites", e) |
| 1078 | return e |
| 1079 | } |
| 1080 | } |
| 1081 | if old := w.recoveryPrior; old != nil { |
| 1082 | w.recoveryPrior = func(ctx context.Context, o hostclaim.Observed) (hostclaim.Observed, bool, error) { |
| 1083 | p, b, e := old(ctx, o) |
| 1084 | t.Logf("recovery prior=%+v pending=%v error=%v", p.Claim, b, e) |
| 1085 | return p, b, e |
| 1086 | } |
| 1087 | } |
| 1088 | |
| 1089 | recover := w.RecoverArtifacts |
| 1090 | if recover != nil { |
| 1091 | w.RecoverArtifacts = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| 1092 | e := recover(ctx, o, in) |
| 1093 | t.Log("recover artifacts", e) |
| 1094 | return e |
| 1095 | } |
| 1096 | } |
| 1097 | prepare := w.PrepareState |
| 1098 | if prepare != nil { |
| 1099 | w.PrepareState = func(ctx context.Context, o hostclaim.Observed, work hostwrite.Work, in ClaimLaunchInputs) error { |
| 1100 | e := prepare(ctx, o, work, in) |
| 1101 | t.Log("prepare state", e) |
| 1102 | return e |
| 1103 | } |
| 1104 | } |
| 1105 | before := w.BeforeLegacyInstall |
| 1106 | if before != nil { |
| 1107 | w.BeforeLegacyInstall = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| 1108 | e := before(ctx, o, in, work) |
| 1109 | t.Logf("legacy install empty=%v admission=%+v error=%v", in.Empty, in.Admission, e) |
| 1110 | return e |
| 1111 | } |
| 1112 | } |
| 1113 | enroll := w.BeforeInstallLocked |
| 1114 | if enroll != nil { |
| 1115 | w.BeforeInstallLocked = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| 1116 | e := enroll(ctx, o, in) |
| 1117 | t.Log("enroll", e) |
| 1118 | return e |
| 1119 | } |
| 1120 | } |
| 1121 | } |
| 1122 | |
| 1123 | func lifecycleRetry(ctx context.Context, fn func(context.Context) error) error { |
| 1124 | bounded, cancel := context.WithTimeout(ctx, 30*time.Second) |
| 1125 | defer cancel() |
| 1126 | for { |
| 1127 | err := fn(bounded) |
| 1128 | if err == nil { |
| 1129 | return nil |
| 1130 | } |
| 1131 | select { |
| 1132 | case <-bounded.Done(): |
| 1133 | return errors.Join(err, bounded.Err()) |
| 1134 | case <-time.After(100 * time.Millisecond): |
| 1135 | } |
| 1136 | } |
| 1137 | } |
| 1138 | |