//go:build linux package host import ( "context" "errors" "fmt" "net" "net/http" "os" "os/exec" "path/filepath" "sync/atomic" "testing" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/lease/leasetest" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) func TestClaimServingAssemblyWithoutMoves(t *testing.T) { fixtureWithoutRootMigrationCensus(t) t.Setenv("CARLOS_HOST_CLAIM", "1") t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") t.Setenv("CARLOS_MOVES_PHASE1", "") for _, ha := range []string{"", "1"} { t.Run("region-ha="+ha, func(t *testing.T) { t.Setenv("CARLOS_REGION_HA", ha) store, resources, owners, _ := fix4JournalFixture(t) ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil }) if err != nil { t.Fatal(err) } actor, err := owners.Identity.Verify(ctx) if err != nil { t.Fatal(err) } data := leasetest.NewVersionedBucket() data.AddBucket(actor.Bucket) rt := registry.Route{Host: filesHost, Kind: "instance", Hibernate: true, DBPath: provision.NestedDBPath(resources.DataDir, filesHost), Socket: provision.NestedSocket(filesHost)} dir := filepath.Dir(rt.DBPath) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, filesHost+".env"), []byte("K=1\n"), 0600); err != nil { t.Fatal(err) } source := &movesFilesSource{DataDir: resources.DataDir, Routes: testClaimRoutes{rows: map[string]registry.Route{filesHost: rt}}, newFiles: func(context.Context, string, string) (release.Store, error) { return casBucketStore{b: data, bucket: actor.Bucket}, nil }} files, err := source.Open(ctx, store, data, actor) if err != nil { t.Fatal(err) } d5 := plan9D5Fixture(t) owners.Data = data owners.Serving = d5.owners.Sources owners.Serving.Files = files owners.Serving.Markers = plan9D5Markers{data} assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners) if err != nil { t.Fatal(err) } defer assembly.Close(ctx) if assembly.wakeUnavailable != nil { t.Fatal(assembly.wakeUnavailable) } dispatch, ok := assembly.Wake.Inputs.(ClaimOwnerDispatch) if !ok || dispatch.Moves != nil || dispatch.Relocation != nil || assembly.Wake.Operations != nil || assembly.Wake.ProbeReady != nil { t.Fatalf("serving-only assembly: %#v", assembly.Wake) } if digest, err := assembly.Wake.Retries.Inputs(ctx, rt); err != nil || digest == "" { t.Fatalf("digest %q: %v", digest, err) } o, err := hostclaim.Read(ctx, store, filesHost) if err != nil { t.Fatal(err) } n := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + filesHost + ".json", Account: "bab", App: "jam", Host: filesHost, Region: actor.Region, CreatedAt: filesCreated} o, err = hostclaim.Update(ctx, store, o, hostclaim.BeginCreate(n, []byte(`{"host":"shop.example.com","account":"bab","app":"jam","created_at":"2026-10-01T00:00:00Z"}`), "create", "console", time.Now())) if err != nil { t.Fatal(err) } o, err = hostclaim.Update(ctx, store, o, hostclaim.CompleteCreate("create", actor.Box)) if err != nil { t.Fatal(err) } in, err := dispatch.Select(ctx, rt, o, recoveryPolicy) if err != nil || !in.Empty { t.Fatalf("empty selection: %+v %v", in, err) } writer := hostbox.Writer{Claims: store, Box: actor.Box, Region: actor.Region, AdmissionEvidence: assembly.AdmissionEvidence} admitted, err := writer.Admit(ctx, o, in.Admission, in.Baseline) if err != nil { t.Fatal(err) } work := &filesFixture{t: t} restore := hostbox.ColdRestore{Generation: in.Admission.NewGeneration, Work: work, Identity: in.Identity} if err := restore.InstallEmpty(ctx, rt.DBPath, func(ctx context.Context) error { return dispatch.EmptyAuthority(ctx, admitted, in) }); err != nil { t.Fatal(err) } if err := dispatch.Materialize(ctx, admitted, in, work); err != nil { t.Fatal(err) } key := movesFilesPrefix(admitted.Claim.Holder.Location, in.Admission.NewGeneration) + "manifest.json" if data.LatestID(actor.Bucket, key) == "" { t.Fatal("production C8 did not commit") } if assembly.Wake.BeforeLegacyInstall == nil || assembly.Wake.RecoverArtifacts == nil || assembly.Wake.BeforeInstallLocked == nil || assembly.Wake.Retries.BeforeReserve == nil { t.Fatal("serving retirement or capacity not wired") } rootKey := fmt.Sprintf("%s/g-%s/0000/0000000000000001-0000000000000001.ltx", admitted.Claim.Holder.Location, in.Admission.NewGeneration) syncs := 0 publication := ClaimPublication{Sync: func(context.Context) error { syncs++; data.Put(actor.Bucket, rootKey, plan9D5RootBody(t)); return nil }, Check: func(context.Context) error { return nil }} receipt, err := assembly.Wake.Publication.PublishFenced(ctx, admitted, in, publication) if err != nil || receipt.MarkerVersion == "" || receipt.Root.Key != rootKey || syncs != 1 { t.Fatal("moves-off publication", receipt, err, syncs) } writer.PublicationEvidence = assembly.PublicationEvidence writer.NewID = func() string { return "serving-published" } published, err := writer.Published(ctx, admitted) if err != nil || published.Claim.Holder.Generation != in.Admission.NewGeneration { t.Fatal("moves-off publication evidence", err) } sockdir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "seed-ready-") if err != nil { t.Fatal(err) } defer os.RemoveAll(sockdir) rt.Socket = filepath.Join(sockdir, "ready.sock") listener, err := net.Listen("unix", rt.Socket) if err != nil { t.Fatal(err) } var requests atomic.Int32 server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); w.WriteHeader(http.StatusOK) })} done := make(chan struct{}) go func() { defer close(done); _ = server.Serve(listener) }() defer func() { server.Close(); <-done }() process, err := os.FindProcess(os.Getpid()) if err != nil { t.Fatal(err) } readyCtx, cancel := context.WithTimeout(ctx, time.Second) err = assembly.Wake.Ready(readyCtx, &exec.Cmd{Process: process}, rt, in) cancel() if err != nil || requests.Load() != 1 { t.Fatal("moves-off readiness", err, requests.Load()) } wrong, _ := os.FindProcess(os.Getpid() + 1000000) readyCtx, cancel = context.WithTimeout(ctx, 40*time.Millisecond) err = assembly.Wake.Ready(readyCtx, &exec.Cmd{Process: wrong}, rt, in) cancel() if err == nil || requests.Load() != 1 { t.Fatal("readiness accepted another process", err, requests.Load()) } for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} { if err := plan9D5Call(dispatch, plan9D5Kind(kind)); err == nil { t.Fatalf("moves-off accepted %s", kind) } } }) } } func TestClaimServingMissingSourcesRefusesBeforeSelection(t *testing.T) { for _, field := range []string{"launch", "markers", "files"} { t.Run(field, func(t *testing.T) { f := plan9D5Fixture(t) switch field { case "launch": f.owners.Sources.Launch = nil case "markers": f.owners.Sources.Markers = nil case "files": f.owners.Sources.Files = nil } var dependency *ClaimDependencyError if err := checkClaimServingSources(f.owners.Sources); !errors.As(err, &dependency) { t.Fatalf("missing %s: %v", field, err) } _, err := f.owners.Select(t.Context(), registry.Route{}, plan9D5Observed(hostclaim.Holder{}), recoveryPolicy) if !errors.As(err, &dependency) { t.Fatalf("selection with missing %s: %v", field, err) } if len(f.files) != 0 || f.seq != 0 { t.Fatal("missing dependency had effects") } }) } } func TestClaimServingAssemblyFlagMatrix(t *testing.T) { fixtureWithoutRootMigrationCensus(t) for _, claims := range []string{"", "1"} { for _, moves := range []string{"", "1"} { for _, partial := range []bool{false, true} { name := "claims=" + claims + "/moves=" + moves if partial { name += "/partial" } t.Run(name, func(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", claims) t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") t.Setenv("CARLOS_MOVES_PHASE1", moves) t.Setenv("CARLOS_REGION_HA", "1") store, resources, owners, _ := fix4JournalFixture(t) ctx, err := claimEntryContext(t.Context(), os.Getenv, func(context.Context) (release.Store, error) { return store, nil }) if err != nil { t.Fatal(err) } f := plan9D5Fixture(t) owners.Serving = f.owners.Sources var calls []string owners.Moves = ClaimMovesOwners{ClaimKindOwners: plan9D5Owner{"moves", &calls}.owners(), Operations: &plan9D2Attempts{}} if partial { owners.Moves.PublicationEvidence = nil } assembly, err := OpenProductionClaimAssembly(ctx, store, resources, owners) if err != nil { t.Fatal(err) } defer assembly.Close(ctx) dispatch, bound := assembly.Wake.Inputs.(ClaimOwnerDispatch) if bound != (claims != "") { t.Fatalf("serving binding %v, claims %q", bound, claims) } wantMoves := claims != "" && moves != "" && !partial if bound && (dispatch.Moves != nil) != wantMoves { t.Fatalf("moves binding %v, want %v", dispatch.Moves != nil, wantMoves) } if (assembly.Wake.Operations != nil) != wantMoves { t.Fatal("operation gate changed") } wantMissing := claims != "" && moves != "" && partial if (assembly.wakeUnavailable != nil) != wantMissing { t.Fatalf("partial owner refusal %v, want %v", assembly.wakeUnavailable, wantMissing) } if bound && dispatch.Relocation != nil { t.Fatal("region HA bypassed absent relocation owners") } if len(calls) != 0 || len(f.files) != 0 || f.seq != 0 { t.Fatal("assembly ran a host operation") } }) } } } }