//go:build linux && rootrunner package host import ( "context" "encoding/json" "io" "net" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfiles" "github.com/carlosframework/platform/internal/registry" ) func TestRootFilesSeedSocketDisconnectExecutor(t *testing.T) { runRootFilesSeedRealOriginPreparation(t, true) } func TestRootFilesSeedSocketShutdownExecutor(t *testing.T) { runRootFilesSeedRealOriginPreparation(t, true) } func TestRootFilesSeedSocketCompleteExecutor(t *testing.T) { runRootFilesSeedRealOriginPreparation(t, true) } func rootFilesSeedLifetime(t *testing.T, p *servingPreparation, e *filesSeedExecutor) { blocked := !strings.Contains(t.Name(), "Complete") entered := make(chan struct{}) factory := e.assembly.Wake.seedFactory e.assembly.Wake.seedFactory = func(ctx context.Context, op *filesSeedOperation, token *activator.SeedExclusion) (*activator.ClaimRuntime, *ClaimWakeAssembly, error) { runtime, wake, err := factory(ctx, op, token) if err != nil { return runtime, wake, err } prepare := runtime.Prepare runtime.Prepare = func(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) (activator.ClaimStart, error) { start, err := prepare(ctx, rt, o, policy) if err == nil && blocked { start.Materialize = func(ctx context.Context) error { close(entered); <-ctx.Done(); return ctx.Err() } } return start, err } return runtime, wake, nil } ready := make(chan struct{}) close(ready) path := filepath.Join(t.TempDir(), "seed.sock") if err := os.Chmod(filepath.Dir(path), 0700); err != nil { t.Fatal(err) } // Peer and delegated service authentication have their own installed witness. // Here the real executor is attached to the production connection lifetime. open := func() *filesSeedServer { s, err := startFilesSeedSocket(t.Context(), ready, path, func(context.Context, *net.UnixConn) error { return nil }, e.run) if err != nil { t.Fatal(err) } s.finalize = e.close return s } s := open() shutdown := func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.close(ctx); err != nil { t.Fatal("seed server did not join executor", err) } } defer func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() s.close(ctx) }() request := func() net.Conn { c, err := net.DialTimeout("unix", path, time.Second) if err != nil { t.Fatal(err) } c.SetDeadline(time.Now().Add(10 * time.Second)) if err := json.NewEncoder(c).Encode(filesSeedRequest{Verb: "seed", Host: recoveryHost}); err != nil { t.Fatal(err) } return c } c := request() defer c.Close() if blocked { select { case <-entered: case <-time.After(10 * time.Second): t.Fatal("seed did not reach precommit") } if err := e.run(t.Context(), recoveryHost, io.Discard); err != activator.ErrFenceHeld { t.Fatal("second executor escaped exclusion", err) } if strings.Contains(t.Name(), "Disconnect") { c.Close() deadline := time.Now().Add(5 * time.Second) for { e.mu.Lock() running := e.running e.mu.Unlock() if !running { break } if time.Now().After(deadline) { t.Fatal("executor survived client disconnect") } time.Sleep(10 * time.Millisecond) } } shutdown() e.mu.Lock() retained, running := len(e.pending), e.running e.mu.Unlock() if retained != 0 || running { t.Fatal("executor outlived server", retained, running) } o := p.f.f.read() if o.Claim.Holder.WakeRetry == nil || o.Claim.Holder.WakeRetry.State != "open" || o.Claim.Holder.WakeRetry.AttemptsReserved != 1 { t.Fatal("cancellation lost settlement", o) } files, err := e.source.Open(t.Context(), e.assembly.Claims, e.assembly.Data, p.f.actor) if err != nil { t.Fatal(err) } if _, found, err := hostfiles.Load(t.Context(), files.(*movesFiles).Files, movesFilesPrefix(o.Claim.Holder.Location, o.Claim.Holder.Generation)); err != nil || found { t.Fatal("canceled seed committed", found, err) } blocked = false p.f.f.now = p.f.f.now.Add(time.Minute) s = open() c = request() defer c.Close() } decoder := json.NewDecoder(c) var text strings.Builder for { var reply filesSeedReply if err := decoder.Decode(&reply); err != nil { t.Fatal("seed reply", err, text.String()) } text.WriteString(reply.Text) if reply.Code != nil { if *reply.Code != 0 { t.Fatal("seed failed", *reply.Code, text.String()) } break } } shutdown() o := p.f.f.read() files, err := e.source.Open(t.Context(), e.assembly.Claims, e.assembly.Data, p.f.actor) if err != nil { t.Fatal(err) } manifest, found, err := hostfiles.Load(t.Context(), files.(*movesFiles).Files, movesFilesPrefix(o.Claim.Holder.Location, o.Claim.Holder.Generation)) if err != nil || !found || manifest.Entries["payload"].SHA256 == "" { t.Fatal("seed did not publish", found, err) } if !strings.Contains(text.String(), "seeded.") { t.Fatal(text.String()) } if _, err := os.Lstat(path); !os.IsNotExist(err) { t.Fatal("socket outlived server", err) } } func TestFilesSeedExecutorLifetime(t *testing.T) { // The runner's /tmp is a small tmpfs. Keep the real start-floor check, // and place this direct fixture on its disk, as the installed /data tests do. if os.Geteuid() == 0 { t.Setenv("TMPDIR", "/var/tmp") } for _, mode := range []string{"Complete", "Disconnect", "Shutdown"} { t.Run(mode, func(t *testing.T) { runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", nil, []filesSeedLiveWitness{{seed: rootFilesSeedLifetime}}) }) } }