carlos / platform Public

stopevidence: Reconstruct, stop evidence from cgroup, proc and lease state (§5.3)

stopevidence.Reconstruct runs §5.3 steps 1 to 4 from durable state: kill
and drain carlos-exec@<host> (an absent group is an empty tree), no stray
pid in the agent's own leaf (read under the cgroup2 mount that
/proc/self/mountinfo names, ruling G32), no process holding the database,
WAL or SHM, and the lease free. Step 5 is the caller's. !linux returns
ErrUnsupported.

moves.StoppedReport carries the steps, with TreeStopped (steps 1 to 4)
and Complete (every step, StepReplica included, failing closed on any
other failed step).

lease.LockState reads <location>/lock.json through Acquire's client; the
location is the claim holder's (ruling G3). A lock counts as released only
once expired by more than RenewMargin; anything unreadable is an error.

Copy rows MV-N159 to MV-N162 (pending); the four new files join
movesScanFiles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2a4056998e3c031da42c1b89a9c1a8da7d52ecee parent 87a20a3
10 files changed, +1203 −1
  • approved_moves_copy_test.go +6 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +4 −0
  • internal/lease/lockstate.go +95 −0
  • internal/lease/lockstate_test.go +109 −0
  • internal/moves/evidence.go +81 −0
  • internal/moves/evidence_test.go +76 −0
  • internal/stopevidence/reconstruct.go +25 −0
  • internal/stopevidence/reconstruct_linux.go +323 −0
  • internal/stopevidence/reconstruct_linux_test.go +469 −0
  • internal/stopevidence/reconstruct_other.go +15 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 74df2e507..9eb8afdc7 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -65,7 +65,7 @@ var movesCopyIDs = []string{
"MV-N139", "MV-N140", "MV-N141", "MV-N142", "MV-N143", "MV-N144",
"MV-N145", "MV-N146", "MV-N147", "MV-N148", "MV-N149", "MV-N150",
"MV-N151", "MV-N152", "MV-N153", "MV-N154", "MV-N155", "MV-N156",
- "MV-N157", "MV-N158",
+ "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162",
}
// copyRow is one row of the record. The File cell is the file the sentence is
@@ -569,6 +569,7 @@ var movesScanFiles = []string{
"internal/lease/lease.go",
"internal/lease/leasetest/bucket.go",
"internal/lease/leasetest/s3server.go",
+ "internal/lease/lockstate.go",
"internal/lease/mirror.go",
"internal/lease/staging.go",
"internal/moves/archive/archive.go",
@@ -583,6 +584,7 @@ var movesScanFiles = []string{
"internal/moves/budget.go",
"internal/moves/check.go",
"internal/moves/envreport.go",
+ "internal/moves/evidence.go",
"internal/moves/floor.go",
"internal/moves/ids.go",
"internal/moves/lease.go",
@@ -597,6 +599,9 @@ var movesScanFiles = []string{
"internal/release/materializeheld.go",
"internal/release/proofgate.go",
"internal/release/refusal.go",
+ "internal/stopevidence/reconstruct.go",
+ "internal/stopevidence/reconstruct_linux.go",
+ "internal/stopevidence/reconstruct_other.go",
}
// The scan's base is the main commit moves-phase1 last merged: the diff from
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 011cea1fe..58de9a907 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -198,6 +198,10 @@ Columns: id, file, text, status.
| MV-N156 | internal/console/apiinstancemove.go | the source box is not eligible to move from | pending |
| MV-N157 | internal/console/apiinstancemove.go | could not read the host roster. try again | pending |
| MV-N158 | internal/provision/provision.go | stop evidence budget | pending |
+| MV-N159 | internal/stopevidence/reconstruct_linux.go | the instance's processes could not be stopped | pending |
+| MV-N160 | internal/stopevidence/reconstruct_linux.go | pid <pid> in the agent's cgroup is not the agent | pending |
+| MV-N161 | internal/stopevidence/reconstruct_linux.go | <count> process(es) hold the database open | pending |
+| MV-N162 | internal/stopevidence/reconstruct_linux.go | lock.json still held | pending |
## Plan 2
diff --git a/internal/lease/lockstate.go b/internal/lease/lockstate.go
new file mode 100644
index 000000000..53d268104
--- /dev/null
+++ b/internal/lease/lockstate.go
@@ -0,0 +1,95 @@
+package lease
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "path"
+ "strings"
+ "time"
+
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/benbjohnson/litestream"
+)
+
+// ErrLockLocation is LockState's answer for a bucket or location it will
+// not read: empty, absolute, or not in clean form.
+var ErrLockLocation = errors.New("lease: invalid lock location")
+
+// lockBodyLimit bounds the lock.json read. The leaser writes a few dozen
+// bytes; anything near this is not a lock.
+const lockBodyLimit = 64 << 10
+
+// LockState reads the single-writer lease at s3://bucket/<location>/lock.json
+// as the litestream leaser writes it (Acquire, lease.go), through the same
+// client Acquire builds, so the read signs for the region and reaches the
+// endpoint the writer used. It is §5.3 step 4's observation: it holds no
+// lease and writes nothing.
+//
+// location is the claim holder's Location from a fresh claim read (the
+// location moves.ReplicaSource derives its prefix from, ruling G3), never
+// activator.HibernatePrefix and never the g-<generation>/ prefix: the lock
+// sits at the location and Held.BindGeneration only fixes the data under
+// it. One trailing slash is accepted, so ReplicaSource's root prefix reads
+// the same lock.
+//
+// held is true while the lock is present and its expiry has not passed by
+// more than RenewMargin, so a holder whose clock runs slightly behind
+// still counts as holding. An absent lock (never acquired, or released:
+// the leaser deletes it) is held=false with a zero owner and expiry and a
+// nil error. An expired lock is held=false with its owner and expiry, for
+// the record. Anything else (a missing bucket, an unreadable or garbled
+// body, a lock with no expiry) is an error, never "not held".
+func LockState(ctx context.Context, bucket, location, region string) (held bool, owner string, expires time.Time, err error) {
+ key, err := lockKey(bucket, location)
+ if err != nil {
+ return false, "", time.Time{}, err
+ }
+ client, _, err := newLeaserClient(ctx, region)
+ if err != nil {
+ return false, "", time.Time{}, err
+ }
+ out, err := client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &bucket, Key: &key})
+ if err != nil {
+ if errors.Is(mapReadErr(err), ErrMirrorNotFound) {
+ return false, "", time.Time{}, nil
+ }
+ return false, "", time.Time{}, fmt.Errorf("lease: read %s: %w", key, err)
+ }
+ defer out.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(out.Body, lockBodyLimit+1))
+ if err != nil {
+ return false, "", time.Time{}, fmt.Errorf("lease: read %s: %w", key, err)
+ }
+ if len(body) > lockBodyLimit {
+ return false, "", time.Time{}, fmt.Errorf("lease: %s is larger than a lock", key)
+ }
+ var lock litestream.Lease
+ if err := json.Unmarshal(body, &lock); err != nil {
+ return false, "", time.Time{}, fmt.Errorf("lease: decode %s: %w", key, err)
+ }
+ if lock.ExpiresAt.IsZero() {
+ return false, "", time.Time{}, fmt.Errorf("lease: %s has no expiry", key)
+ }
+ return lockHeld(lock.ExpiresAt, time.Now()), lock.Owner, lock.ExpiresAt, nil
+}
+
+// lockHeld is §5.3 step 4's rule: a lock is released only once its expiry
+// has passed by more than the renewal margin.
+func lockHeld(expires, now time.Time) bool {
+ return !now.After(expires.Add(RenewMargin))
+}
+
+// lockKey is the leaser's key for location (lss3.Leaser.lockKey with Path
+// set, as AcquireTTL sets it), after refusing what Acquire would never be
+// given: an empty bucket or location, an absolute one, or one not in clean
+// form.
+func lockKey(bucket, location string) (string, error) {
+ loc := strings.TrimSuffix(location, "/")
+ if bucket == "" || loc == "" || strings.HasPrefix(loc, "/") || strings.Contains(loc, `\`) || path.Clean(loc) != loc || loc == "." || strings.HasPrefix(loc, "../") || loc == ".." {
+ return "", ErrLockLocation
+ }
+ return loc + "/lock.json", nil
+}
diff --git a/internal/lease/lockstate_test.go b/internal/lease/lockstate_test.go
new file mode 100644
index 000000000..4aaa2dc2d
--- /dev/null
+++ b/internal/lease/lockstate_test.go
@@ -0,0 +1,109 @@
+package lease_test
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+)
+
+// lockFixture points the package's S3 clients at leasetest's local store,
+// with credentials the AWS chain resolves without touching the machine's.
+func lockFixture(t *testing.T) *leasetest.VersionedBucket {
+ t.Helper()
+ bucket := leasetest.NewVersionedBucket()
+ bucket.AddBucket("data")
+ handler, _ := leasetest.NewS3Server(bucket)
+ srv := httptest.NewServer(handler)
+ t.Cleanup(srv.Close)
+ t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
+ t.Setenv("AWS_SESSION_TOKEN", "")
+ t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
+ t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent")
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent")
+ t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
+ return bucket
+}
+
+func TestLockStateFollowsAcquireAndRelease(t *testing.T) {
+ lockFixture(t)
+ ctx := context.Background()
+ held, err := lease.Acquire(ctx, "data", "instances/h.example.com", "test-region")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = held.Release(context.Background()) })
+ got, _, expires, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region")
+ if err != nil || !got || !expires.After(time.Now()) {
+ t.Fatalf("after acquire: held %v expires %v err %v, want held with an expiry in the future", got, expires, err)
+ }
+ // ReplicaSource's prefix carries a trailing slash; the lock is the same.
+ if got, _, _, err := lease.LockState(ctx, "data", "instances/h.example.com/", "test-region"); err != nil || !got {
+ t.Fatalf("trailing slash: held %v err %v", got, err)
+ }
+ if err := held.Release(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if got, owner, expires, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region"); err != nil || got || owner != "" || !expires.IsZero() {
+ t.Fatalf("after release: held %v owner %q expires %v err %v", got, owner, expires, err)
+ }
+}
+
+func TestLockStateAbsentPrefixIsNotHeld(t *testing.T) {
+ lockFixture(t)
+ got, owner, expires, err := lease.LockState(context.Background(), "data", "instances/never.example.com/inc-1", "test-region")
+ if err != nil || got || owner != "" || !expires.IsZero() {
+ t.Fatalf("absent: held %v owner %q expires %v err %v", got, owner, expires, err)
+ }
+}
+
+// §5.3 step 4: a lock counts as released only once it has expired by more
+// than the lease's renewal margin, so a holder whose clock runs a little
+// behind is still counted as holding.
+func TestLockStateExpiredWithinTheMarginIsHeld(t *testing.T) {
+ bucket := lockFixture(t)
+ ctx := context.Background()
+ put := func(loc string, expires time.Time) {
+ bucket.Put("data", loc+"/lock.json", []byte(`{"generation":3,"expires_at":"`+expires.UTC().Format(time.RFC3339Nano)+`","owner":"box-a"}`))
+ }
+ recent := time.Now().Add(-lease.RenewMargin / 2)
+ put("instances/recent.example.com", recent)
+ got, owner, expires, err := lease.LockState(ctx, "data", "instances/recent.example.com", "test-region")
+ if err != nil || !got || owner != "box-a" || !expires.Equal(recent) {
+ t.Fatalf("expired within the margin: held %v owner %q expires %v err %v", got, owner, expires, err)
+ }
+ old := time.Now().Add(-2 * lease.RenewMargin)
+ put("instances/old.example.com", old)
+ got, owner, expires, err = lease.LockState(ctx, "data", "instances/old.example.com", "test-region")
+ if err != nil || got || owner != "box-a" || !expires.Equal(old) {
+ t.Fatalf("expired past the margin: held %v owner %q expires %v err %v", got, owner, expires, err)
+ }
+}
+
+// Anything the reader cannot positively call absent or released is an
+// error, never "not held".
+func TestLockStateFailsClosed(t *testing.T) {
+ bucket := lockFixture(t)
+ ctx := context.Background()
+ bucket.Put("data", "instances/garbled.example.com/lock.json", []byte(`not json`))
+ bucket.Put("data", "instances/noexpiry.example.com/lock.json", []byte(`{"generation":1,"owner":"x"}`))
+ for _, c := range []struct{ name, bucket, location string }{
+ {"garbled body", "data", "instances/garbled.example.com"},
+ {"no expiry", "data", "instances/noexpiry.example.com"},
+ {"missing bucket", "nobucket", "instances/h.example.com"},
+ {"empty location", "data", ""},
+ {"absolute location", "data", "/instances/h.example.com"},
+ {"dot-dot location", "data", "instances/../h.example.com"},
+ {"double slash", "data", "instances//h.example.com"},
+ {"empty bucket", "", "instances/h.example.com"},
+ } {
+ got, _, _, err := lease.LockState(ctx, c.bucket, c.location, "test-region")
+ if err == nil || got {
+ t.Fatalf("%s: held %v err %v, want an error", c.name, got, err)
+ }
+ }
+}
diff --git a/internal/moves/evidence.go b/internal/moves/evidence.go
new file mode 100644
index 000000000..95e39bab8
--- /dev/null
+++ b/internal/moves/evidence.go
@@ -0,0 +1,81 @@
+package moves
+
+import "time"
+
+// The steps of a StoppedReport, in §5.3's order. StepTree to StepLease
+// are what stopevidence.Reconstruct observes from the box's durable state;
+// StepReplica is the caller's (the cut's step 5: the final checkpoint and
+// the digests), set after the checkpoint.
+const (
+ // StepTree: the host's cgroup was killed and drained (or never existed).
+ StepTree = "tree"
+ // StepStray: the agent's own cgroup leaf holds only the agent.
+ StepStray = "stray"
+ // StepFDs: no visible process holds the database, its WAL or its SHM.
+ StepFDs = "fds"
+ // StepLease: the host's lock.json is absent, released, or expired by
+ // more than the lease's renewal margin.
+ StepLease = "lease"
+ // StepReplica: the final checkpoint reached the replica.
+ StepReplica = "replica"
+)
+
+// treeSteps are §5.3 steps 1 to 4, what `stopping` needs before the final
+// checkpoint. allSteps adds the replica.
+var (
+ treeSteps = [...]string{StepTree, StepStray, StepFDs, StepLease}
+ allSteps = [...]string{StepTree, StepStray, StepFDs, StepLease, StepReplica}
+)
+
+// StoppedReport is a role's stop evidence for one move operation (§5.3),
+// written to moves/<acct>/<app>/<host>/<op id>/stopped-<role>.json. Every
+// observation is from durable, observable state, so a restarted agent
+// writes the same report. Steps holds one Check per step id; a step that
+// never ran is absent, and an absent step is not OK. Stray lists the pids
+// StepStray found in the agent's leaf besides the agent, for the alert.
+type StoppedReport struct {
+ V int `json:"v"`
+ Role string `json:"role"`
+ Box string `json:"box"`
+ Incarnation string `json:"incarnation"`
+ ClaimGen uint64 `json:"claim_gen"`
+ Host string `json:"host"`
+ OpID string `json:"op_id"`
+ Steps map[string]Check `json:"steps"`
+ Stray []int `json:"stray,omitempty"`
+ FinalTXID uint64 `json:"final_txid"`
+ DBSHA256 string `json:"db_sha256"`
+ FilesPlain string `json:"files_plain"`
+ TimersSHA256 string `json:"timers_sha256"`
+ CheckedAt time.Time `json:"checked_at"`
+}
+
+// TreeStopped reports §5.3 steps 1 to 4 all present and OK: the writer has
+// fully stopped, so the final checkpoint may run.
+func (r StoppedReport) TreeStopped() bool {
+ return r.stepsOK(treeSteps[:])
+}
+
+// Complete reports every step OK, StepReplica included. It fails closed on
+// any other step recorded in the report and not OK, so a step a later
+// version adds cannot be ignored by an older reader.
+func (r StoppedReport) Complete() bool {
+ if !r.stepsOK(allSteps[:]) {
+ return false
+ }
+ for _, c := range r.Steps {
+ if !c.OK {
+ return false
+ }
+ }
+ return true
+}
+
+func (r StoppedReport) stepsOK(ids []string) bool {
+ for _, id := range ids {
+ if c, ok := r.Steps[id]; !ok || !c.OK {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/moves/evidence_test.go b/internal/moves/evidence_test.go
new file mode 100644
index 000000000..01f28c88e
--- /dev/null
+++ b/internal/moves/evidence_test.go
@@ -0,0 +1,76 @@
+package moves
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func stoppedWith(ok map[string]bool) StoppedReport {
+ r := StoppedReport{V: 1, Steps: map[string]Check{}}
+ for id, pass := range ok {
+ if pass {
+ r.Steps[id] = Check{OK: true}
+ } else {
+ r.Steps[id] = Check{Reason: "no"}
+ }
+ }
+ return r
+}
+
+func TestStoppedReportTreeStoppedNeedsStepsOneToFour(t *testing.T) {
+ all := map[string]bool{StepTree: true, StepStray: true, StepFDs: true, StepLease: true}
+ if r := stoppedWith(all); !r.TreeStopped() || r.Complete() {
+ t.Fatalf("steps 1 to 4 OK: TreeStopped %v Complete %v, want true false", r.TreeStopped(), r.Complete())
+ }
+ for _, step := range []string{StepTree, StepStray, StepFDs, StepLease} {
+ failed := map[string]bool{StepTree: true, StepStray: true, StepFDs: true, StepLease: true, StepReplica: true}
+ failed[step] = false
+ if r := stoppedWith(failed); r.TreeStopped() || r.Complete() {
+ t.Fatalf("%s failed but TreeStopped %v Complete %v", step, r.TreeStopped(), r.Complete())
+ }
+ missing := map[string]bool{StepTree: true, StepStray: true, StepFDs: true, StepLease: true, StepReplica: true}
+ delete(missing, step)
+ if r := stoppedWith(missing); r.TreeStopped() || r.Complete() {
+ t.Fatalf("%s never ran but TreeStopped %v Complete %v", step, r.TreeStopped(), r.Complete())
+ }
+ }
+ if (StoppedReport{}).TreeStopped() || (StoppedReport{}).Complete() {
+ t.Fatal("the zero report passed")
+ }
+}
+
+func TestStoppedReportCompleteNeedsTheReplicaAndEveryStep(t *testing.T) {
+ all := map[string]bool{StepTree: true, StepStray: true, StepFDs: true, StepLease: true, StepReplica: true}
+ if r := stoppedWith(all); !r.Complete() || !r.TreeStopped() {
+ t.Fatalf("every step OK: Complete %v TreeStopped %v", r.Complete(), r.TreeStopped())
+ }
+ all[StepReplica] = false
+ if r := stoppedWith(all); r.Complete() || !r.TreeStopped() {
+ t.Fatalf("replica failed: Complete %v TreeStopped %v, want false true", r.Complete(), r.TreeStopped())
+ }
+ // A step this version does not know about still has to pass: Complete
+ // fails closed on anything a newer writer recorded as failed.
+ all[StepReplica] = true
+ all["later"] = false
+ if r := stoppedWith(all); r.Complete() {
+ t.Fatal("a failed extra step passed Complete")
+ }
+}
+
+func TestStoppedReportWireNames(t *testing.T) {
+ r := StoppedReport{V: 1, Role: "source", Box: "b", Incarnation: "i", ClaimGen: 7, Host: "h.example.com", OpID: "op",
+ Steps: map[string]Check{StepTree: {OK: true}}, Stray: []int{9}, FinalTXID: 12,
+ DBSHA256: "d", FilesPlain: "f", TimersSHA256: "t"}
+ raw, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{`"v":1`, `"role":"source"`, `"box":"b"`, `"incarnation":"i"`, `"claim_gen":7`,
+ `"host":"h.example.com"`, `"op_id":"op"`, `"steps":{"tree":{"ok":true,"reason":""}}`, `"stray":[9]`,
+ `"final_txid":12`, `"db_sha256":"d"`, `"files_plain":"f"`, `"timers_sha256":"t"`, `"checked_at":`} {
+ if !strings.Contains(string(raw), name) {
+ t.Fatalf("%s missing from %s", name, raw)
+ }
+ }
+}
diff --git a/internal/stopevidence/reconstruct.go b/internal/stopevidence/reconstruct.go
new file mode 100644
index 000000000..46466ac20
--- /dev/null
+++ b/internal/stopevidence/reconstruct.go
@@ -0,0 +1,25 @@
+package stopevidence
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+)
+
+// ReconstructInputs is what Reconstruct observes for one host.
+//
+// Cgroups is the exec backend's manager (activator.ExecBackend.Cgroups),
+// handed in by the host package when it arms the moves pass; the activator
+// cannot call Reconstruct itself because this package imports it. DBPaths
+// are the host's database files, absolute; each one's -wal and -shm are
+// checked with it. ProcRoot is "/proc" on a box ("" means that) and a
+// fixture in tests. LeaseFree reports whether the host's lock.json is
+// absent, released or expired past the renewal margin: the caller binds
+// lease.LockState to the location of a fresh claim read (ruling G3).
+type ReconstructInputs struct {
+ Host string
+ Cgroups *cgroup.Manager
+ DBPaths []string
+ ProcRoot string
+ LeaseFree func(context.Context) (bool, error)
+}
diff --git a/internal/stopevidence/reconstruct_linux.go b/internal/stopevidence/reconstruct_linux.go
new file mode 100644
index 000000000..bce6e089c
--- /dev/null
+++ b/internal/stopevidence/reconstruct_linux.go
@@ -0,0 +1,323 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// treeDrainLimit bounds step 1's kill-and-drain when the caller's context
+// has no earlier deadline (KillAndEmpty refuses a context without one). It
+// matches the activator's strictDrainTimeout.
+const treeDrainLimit = 30 * time.Second
+
+// treeGroup is the host cgroup as step 1 uses it: *cgroup.Cleanup, or a
+// fake in tests.
+type treeGroup interface {
+ KillAndEmpty(context.Context) error
+ Close() error
+}
+
+// Reconstruct is §5.3's evidence from durable, observable state: it reads
+// nothing from the agent's memory, so a restarted agent gives the same
+// answer. It runs steps 1 to 4 and records each in the report's Steps; the
+// caller does step 5 (the final checkpoint and the digests, then
+// StepReplica), since only it knows the replica it closes. Role, Box,
+// Incarnation, ClaimGen and OpID are the caller's to fill in too.
+//
+// A step that observed a failure is a Check with OK false and a reason;
+// the pass retries until its stop evidence budget runs out. A step that
+// could not observe (an unreadable /proc or cgroup file, a lease read
+// error, the caller's context ending) is a returned error, and the report
+// beside it is partial and must not be written.
+//
+// Reconstruct takes neither per-host gate. schedule.HostGate is the timers
+// drain's gate (the runner, the converger and moveCfg.TimersDrained); the
+// activator's strictGates (ExecBackend.gate) serialise a strict spawn with
+// its own exit cleanup. By the time the cut calls this, the registry hold
+// and the activator's stop fence have closed admission, so nothing spawns
+// for the host; the kill works on the group whoever last owned it, and a
+// strict owner's concurrent cleanup only kills and drains the same group.
+func Reconstruct(ctx context.Context, in ReconstructInputs) (moves.StoppedReport, error) {
+ if in.Cgroups == nil {
+ return moves.StoppedReport{V: 1, Host: in.Host}, ErrInvalidInput
+ }
+ m := in.Cgroups
+ return reconstruct(ctx, in, func(host string) (treeGroup, error) {
+ c, err := m.OpenCleanup(host)
+ if err != nil {
+ return nil, err
+ }
+ return c, nil
+ })
+}
+
+func reconstruct(ctx context.Context, in ReconstructInputs, open func(string) (treeGroup, error)) (moves.StoppedReport, error) {
+ rep := moves.StoppedReport{V: 1, Host: in.Host, Steps: map[string]moves.Check{}}
+ if ctx == nil || open == nil || in.LeaseFree == nil || !controlname.ValidHostShape(in.Host) {
+ return rep, ErrInvalidInput
+ }
+ procRoot := in.ProcRoot
+ if procRoot == "" {
+ procRoot = "/proc"
+ }
+ if !filepath.IsAbs(procRoot) {
+ return rep, ErrInvalidInput
+ }
+ dbFiles, err := dbPathsWithSidecars(in.DBPaths)
+ if err != nil {
+ return rep, err
+ }
+ if err := ctx.Err(); err != nil {
+ return rep, err
+ }
+
+ // 1. Kill and drain the tree: cgroup.kill on carlos-exec@<host>, then
+ // wait for it to empty. Phase 1 moves flat exec instances only (S1
+ // refuses a nested one), so this is the whole of step 1.
+ tree, err := stopTree(ctx, in.Host, open)
+ if err != nil {
+ return rep, err
+ }
+ rep.Steps[moves.StepTree] = tree
+
+ // 2. No stray process in the agent's own leaf. A spawn that escaped its
+ // host cgroup (best-effort placement before the move went strict) lands
+ // here, which is why an absent host cgroup counts as an empty tree only
+ // alongside this step. So does any short-lived child the agent forks
+ // for itself: it fails StepStray for this pass, and the cut's retry
+ // until its deadline is the intended behaviour. Only a repeat is worth
+ // the alert (M-C20).
+ pids, err := ownLeafProcs(procRoot)
+ if err != nil {
+ return rep, err
+ }
+ self := os.Getpid()
+ if !slices.Contains(pids, self) {
+ return rep, fmt.Errorf("stopevidence: the agent (pid %d) is not in the cgroup its /proc entry names", self)
+ }
+ rep.Steps[moves.StepStray] = strayCheck(pids, self)
+ for _, p := range pids {
+ if p != self {
+ rep.Stray = append(rep.Stray, p)
+ }
+ }
+
+ // 3. No open database. The scan covers every process /proc shows. On a
+ // box the agent runs under ProtectProc=invisible (carlos-edge.service),
+ // so /proc shows only the carlos uid's processes: §5.3's "restricted to
+ // the carlos uid". A process it can see but not read makes the scan
+ // incomplete, which is an error, never a pass.
+ holders, err := scanFDs(ctx, procRoot, dbFiles)
+ if err != nil {
+ return rep, err
+ }
+ if len(holders) == 0 {
+ rep.Steps[moves.StepFDs] = moves.Check{OK: true}
+ } else {
+ rep.Steps[moves.StepFDs] = moves.Check{Reason: fmt.Sprintf("%d process(es) hold the database open", len(holders))}
+ }
+
+ // 4. Lease released.
+ free, err := in.LeaseFree(ctx)
+ if err != nil {
+ return rep, err
+ }
+ if free {
+ rep.Steps[moves.StepLease] = moves.Check{OK: true}
+ } else {
+ rep.Steps[moves.StepLease] = moves.Check{Reason: "lock.json still held"}
+ }
+
+ // The evidence time is when the last step was observed, not when the
+ // pass began: §5.3's late-completion horizon runs from it, and a later
+ // time is the safe direction.
+ rep.CheckedAt = time.Now().UTC()
+ return rep, nil
+}
+
+// stopTree is step 1. A group that never existed, or that its last strict
+// owner removed, is an empty tree. A drain that runs out of time is an
+// observed failure; any other failure to open, kill or read the group is
+// an error.
+func stopTree(ctx context.Context, host string, open func(string) (treeGroup, error)) (moves.Check, error) {
+ c, err := open(host)
+ if errors.Is(err, cgroup.ErrNoCgroup) {
+ return moves.Check{OK: true}, nil
+ }
+ if err != nil {
+ return moves.Check{}, err
+ }
+ defer c.Close()
+ drain, cancel := context.WithTimeout(ctx, treeDrainLimit)
+ defer cancel()
+ err = c.KillAndEmpty(drain)
+ switch {
+ case err == nil:
+ return moves.Check{OK: true}, nil
+ case ctx.Err() != nil:
+ return moves.Check{}, ctx.Err()
+ case errors.Is(err, context.DeadlineExceeded):
+ return moves.Check{Reason: "the instance's processes could not be stopped"}, nil
+ }
+ return moves.Check{}, err
+}
+
+func strayCheck(pids []int, self int) moves.Check {
+ for _, p := range pids {
+ if p != self {
+ return moves.Check{Reason: fmt.Sprintf("pid %d in the agent's cgroup is not the agent", p)}
+ }
+ }
+ return moves.Check{OK: true}
+}
+
+// dbPathsWithSidecars is every database file with its -wal and -shm, clean,
+// sorted and without repeats. Each path must be absolute, and there must be
+// at least one: a cut always has a database path, so an empty list is a
+// caller's mistake, not "nothing to hold open".
+func dbPathsWithSidecars(paths []string) ([]string, error) {
+ if len(paths) == 0 {
+ return nil, ErrInvalidInput
+ }
+ seen := map[string]bool{}
+ for _, p := range paths {
+ if !filepath.IsAbs(p) {
+ return nil, ErrInvalidInput
+ }
+ p = filepath.Clean(p)
+ seen[p], seen[p+"-wal"], seen[p+"-shm"] = true, true, true
+ }
+ out := make([]string, 0, len(seen))
+ for p := range seen {
+ out = append(out, p)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// ownLeafProcs reads the pids in the agent's own cgroup leaf: the v2 path
+// <procRoot>/self/cgroup names, under the cgroup2 mount
+// <procRoot>/self/mountinfo names (ruling G32: never a hard-coded
+// /sys/fs/cgroup).
+func ownLeafProcs(procRoot string) ([]int, error) {
+ self, err := os.ReadFile(filepath.Join(procRoot, "self", "cgroup"))
+ if err != nil {
+ return nil, err
+ }
+ leaf, ok := cgroup.ParseSelfCgroup(string(self))
+ if !ok {
+ return nil, errors.New("stopevidence: own cgroup unreadable")
+ }
+ mounts, err := os.ReadFile(filepath.Join(procRoot, "self", "mountinfo"))
+ if err != nil {
+ return nil, err
+ }
+ dir, err := cgroupLeafDir(string(mounts), leaf)
+ if err != nil {
+ return nil, err
+ }
+ return readProcs(filepath.Join(dir, "cgroup.procs"))
+}
+
+// cgroupLeafDir is the directory of the v2 cgroup leaf (as
+// ParseSelfCgroup returns it, relative to the hierarchy's root) under the
+// cgroup2 mount that contains it. A mount's root field is the part of the
+// hierarchy it shows; the mount whose root holds the leaf most closely
+// wins, and of equal roots the first listed. A leaf that climbs out of
+// the hierarchy (a cgroup outside the agent's cgroup namespace shows as
+// "/../..") or that no cgroup2 mount shows is an error.
+func cgroupLeafDir(mountinfo, leaf string) (string, error) {
+ abs := "/" + leaf
+ if path.Clean(abs) != abs {
+ return "", fmt.Errorf("stopevidence: own cgroup %q is outside the hierarchy", leaf)
+ }
+ best, bestRoot := "", -1
+ for _, line := range strings.Split(mountinfo, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 7 {
+ continue
+ }
+ sep := slices.Index(fields[6:], "-")
+ if sep < 0 || 6+sep+1 >= len(fields) || fields[6+sep+1] != "cgroup2" {
+ continue
+ }
+ root, mnt := unescapeMount(fields[3]), unescapeMount(fields[4])
+ if !filepath.IsAbs(mnt) {
+ continue
+ }
+ var rel string
+ switch {
+ case root == "/":
+ rel = abs
+ case abs == root:
+ rel = "/"
+ case strings.HasPrefix(abs, root+"/"):
+ rel = strings.TrimPrefix(abs, root)
+ default:
+ continue
+ }
+ if len(root) > bestRoot {
+ best, bestRoot = filepath.Join(mnt, rel), len(root)
+ }
+ }
+ if bestRoot < 0 {
+ return "", fmt.Errorf("stopevidence: no cgroup2 mount shows own cgroup %q", leaf)
+ }
+ return best, nil
+}
+
+// unescapeMount undoes mountinfo's octal escapes (\040 for a space, \011,
+// \012, \134).
+func unescapeMount(s string) string {
+ if !strings.Contains(s, `\`) {
+ return s
+ }
+ var b strings.Builder
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\\' && i+4 <= len(s) {
+ if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
+ b.WriteByte(byte(v))
+ i += 3
+ continue
+ }
+ }
+ b.WriteByte(s[i])
+ }
+ return b.String()
+}
+
+// readProcs parses cgroup.procs: one pid per line.
+func readProcs(file string) ([]int, error) {
+ raw, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ var pids []int
+ for _, line := range strings.Split(string(raw), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ pid, err := strconv.Atoi(line)
+ if err != nil || pid <= 0 {
+ return nil, fmt.Errorf("stopevidence: %s: invalid pid %q", file, line)
+ }
+ pids = append(pids, pid)
+ }
+ return pids, nil
+}
diff --git a/internal/stopevidence/reconstruct_linux_test.go b/internal/stopevidence/reconstruct_linux_test.go
new file mode 100644
index 000000000..d33058aca
--- /dev/null
+++ b/internal/stopevidence/reconstruct_linux_test.go
@@ -0,0 +1,469 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// §14.1: Reconstruct against a real cgroup v2 hierarchy with a forked
+// descendant that closes its descriptors and outlives its parent (witness
+// wEvidenceWholeTree). Needs a delegated cgroup root: CARLOS_CGROUP_TEST=1
+// and CARLOS_CGROUP_ROOT=<writable cgroup2 dir>, for instance from inside
+// `systemd-run --user --scope -p Delegate=yes`.
+func TestReconstructStopEvidenceKillsTheWholeTree(t *testing.T) {
+ root := os.Getenv("CARLOS_CGROUP_ROOT")
+ if os.Getenv("CARLOS_CGROUP_TEST") != "1" || root == "" {
+ t.Skip("needs CARLOS_CGROUP_TEST=1 and a delegated cgroup v2 root")
+ }
+ m, err := cgroup.SetupAt(root, t.Logf)
+ if err != nil {
+ t.Skip(err)
+ }
+ host := "ev.go.example.com"
+ // A parent that forks a child which detaches and sleeps, then exits.
+ cmd, err := m.Start(host, cgroup.Limit{}, func() *exec.Cmd {
+ return exec.Command("sh", "-c", "(sleep 300 </dev/null >/dev/null 2>&1 &) ; exit 0")
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd.Wait()
+ c, err := m.OpenCleanup(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+ if pop, err := c.Populated(); err != nil || !pop {
+ t.Fatalf("the descendant should still be alive before Reconstruct: populated %v err %v", pop, err)
+ }
+ // The real /proc entry of this process, projected into an otherwise
+ // empty scan root (as TestFDHoldersRealProc does): the whole-machine
+ // scan needs every uid's /proc, which the agent has under
+ // ProtectProc=invisible and a test usually does not.
+ procRoot := projectedProc(t)
+ db := filepath.Join(t.TempDir(), "ev.go.example.com.db")
+ rep, err := Reconstruct(context.Background(), ReconstructInputs{Host: host, Cgroups: m, DBPaths: []string{db}, ProcRoot: procRoot,
+ LeaseFree: func(context.Context) (bool, error) { return true, nil }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rep.Steps[moves.StepTree].OK || !rep.TreeStopped() || rep.Complete() {
+ t.Fatalf("tree evidence after the parent exited: %+v (Complete needs the caller's replica step)", rep)
+ }
+ if pop, err := c.Populated(); err != nil || pop {
+ t.Fatalf("the descendant survived: populated %v err %v", pop, err)
+ }
+}
+
+// Step 2 against the running kernel: the leaf /proc/self/cgroup names is
+// found under the cgroup2 mount /proc/self/mountinfo names, and its
+// cgroup.procs holds this process. Whether StepStray passes depends on
+// what else shares the test's cgroup (alone in a fresh scope, it passes).
+func TestReconstructReadsTheRealOwnLeaf(t *testing.T) {
+ if _, ok := cgroup.ParseSelfCgroup(readOrSkip(t, "/proc/self/cgroup")); !ok {
+ t.Skip("no cgroup v2 leaf for this process")
+ }
+ db := filepath.Join(t.TempDir(), "h.example.com.db")
+ rep, err := reconstruct(context.Background(), ReconstructInputs{Host: "h.example.com", DBPaths: []string{db}, ProcRoot: projectedProc(t),
+ LeaseFree: leaseFree}, opener(&fakeTree{}, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := rep.Steps[moves.StepStray]; !ok {
+ t.Fatal("no stray step")
+ }
+ for _, p := range rep.Stray {
+ if p == os.Getpid() {
+ t.Fatal("the agent counted itself as a stray")
+ }
+ }
+ t.Logf("stray step %+v, %d others in the leaf", rep.Steps[moves.StepStray], len(rep.Stray))
+}
+
+func readOrSkip(t *testing.T, path string) string {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Skip(err)
+ }
+ return string(b)
+}
+
+func TestReconstructStopEvidenceRefusesAStrayProcess(t *testing.T) {
+ // A fake self-cgroup listing with a second pid refuses with StepStray.
+ rep := strayCheck([]int{os.Getpid(), 99999}, os.Getpid())
+ if rep.OK {
+ t.Fatal("a stray pid passed")
+ }
+ if rep.Reason != "pid 99999 in the agent's cgroup is not the agent" {
+ t.Fatalf("reason %q", rep.Reason)
+ }
+ if got := strayCheck([]int{os.Getpid()}, os.Getpid()); !got.OK {
+ t.Fatalf("only the agent: %+v", got)
+ }
+}
+
+// projectedProc is a scan root holding this process's real /proc entry,
+// under its pid and as self.
+func projectedProc(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ pid := strconv.Itoa(os.Getpid())
+ for _, name := range []string{pid, "self"} {
+ if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(root, name)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return root
+}
+
+// boxFixture is a fake box: a proc root whose self/cgroup names the
+// agent's leaf, a self/mountinfo mounting a cgroup2 hierarchy at a temp
+// directory (never /sys/fs/cgroup), and that leaf's cgroup.procs.
+type boxFixture struct {
+ proc, mount, leafDir, db string
+}
+
+func newBoxFixture(t *testing.T, leafPIDs ...int) *boxFixture {
+ t.Helper()
+ dir := t.TempDir()
+ f := &boxFixture{proc: filepath.Join(dir, "proc"), mount: filepath.Join(dir, "cg fs"), db: filepath.Join(dir, "data", "h.example.com.db")}
+ f.leafDir = filepath.Join(f.mount, "system.slice", "carlos-edge.service", "supervise")
+ for _, d := range []string{filepath.Join(f.proc, "self"), f.leafDir, filepath.Dir(f.db)} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(t, filepath.Join(f.proc, "self", "cgroup"), "0::/system.slice/carlos-edge.service/supervise\n")
+ write(t, filepath.Join(f.proc, "self", "mountinfo"),
+ "22 1 0:21 / /proc rw,nosuid,nodev,noexec,relatime shared:5 - proc proc rw\n"+
+ "32 24 0:27 / "+escapeMount(f.mount)+" rw,nosuid,nodev,noexec,relatime shared:8 - cgroup2 cgroup2 rw,nsdelegate\n")
+ if leafPIDs == nil {
+ leafPIDs = []int{os.Getpid()}
+ }
+ f.setLeaf(t, leafPIDs...)
+ return f
+}
+
+func (f *boxFixture) setLeaf(t *testing.T, pids ...int) {
+ t.Helper()
+ var body string
+ for _, p := range pids {
+ body += strconv.Itoa(p) + "\n"
+ }
+ write(t, filepath.Join(f.leafDir, "cgroup.procs"), body)
+}
+
+// holdDB makes pid a process with the path open as descriptor 7.
+func (f *boxFixture) holdDB(t *testing.T, pid int, path string) {
+ t.Helper()
+ fd := filepath.Join(f.proc, strconv.Itoa(pid), "task", strconv.Itoa(pid), "fd")
+ if err := os.MkdirAll(fd, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(path, filepath.Join(fd, "7")); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func escapeMount(p string) string {
+ out := ""
+ for _, r := range p {
+ if r == ' ' {
+ out += `\040`
+ continue
+ }
+ out += string(r)
+ }
+ return out
+}
+
+func write(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fakeTree is the host cgroup as reconstruct sees it.
+type fakeTree struct {
+ killErr error
+ deadline bool
+ killed bool
+ closed bool
+}
+
+func (g *fakeTree) KillAndEmpty(ctx context.Context) error {
+ g.killed = true
+ _, g.deadline = ctx.Deadline()
+ return g.killErr
+}
+func (g *fakeTree) Close() error { g.closed = true; return nil }
+
+func opener(g *fakeTree, err error) func(string) (treeGroup, error) {
+ return func(string) (treeGroup, error) {
+ if err != nil {
+ return nil, err
+ }
+ return g, nil
+ }
+}
+
+func leaseFree(context.Context) (bool, error) { return true, nil }
+
+func (f *boxFixture) inputs() ReconstructInputs {
+ return ReconstructInputs{Host: "h.example.com", DBPaths: []string{f.db}, ProcRoot: f.proc, LeaseFree: leaseFree}
+}
+
+func TestReconstructAllStepsPass(t *testing.T) {
+ f := newBoxFixture(t)
+ g := &fakeTree{}
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(g, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, step := range []string{moves.StepTree, moves.StepStray, moves.StepFDs, moves.StepLease} {
+ if c := rep.Steps[step]; !c.OK || c.Reason != "" {
+ t.Fatalf("%s: %+v", step, c)
+ }
+ }
+ if !rep.TreeStopped() || rep.Complete() || len(rep.Stray) != 0 || rep.V != 1 || rep.Host != "h.example.com" || rep.CheckedAt.IsZero() {
+ t.Fatalf("report %+v", rep)
+ }
+ if !g.killed || !g.closed {
+ t.Fatalf("the tree was not killed and closed: %+v", g)
+ }
+ // KillAndEmpty refuses a context with no deadline, so Reconstruct
+ // bounds the drain itself when its caller did not.
+ if !g.deadline {
+ t.Fatal("the drain ran without a deadline")
+ }
+}
+
+func TestReconstructNoHostCgroupIsAnEmptyTree(t *testing.T) {
+ f := newBoxFixture(t)
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(nil, cgroup.ErrNoCgroup))
+ if err != nil || !rep.Steps[moves.StepTree].OK || !rep.TreeStopped() {
+ t.Fatalf("no cgroup: %+v %v", rep, err)
+ }
+}
+
+func TestReconstructTreeThatWillNotEmpty(t *testing.T) {
+ f := newBoxFixture(t)
+ g := &fakeTree{killErr: context.DeadlineExceeded}
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(g, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := rep.Steps[moves.StepTree]; c.OK || c.Reason != "the instance's processes could not be stopped" || rep.TreeStopped() {
+ t.Fatalf("tree: %+v", rep)
+ }
+ if !g.closed {
+ t.Fatal("the cleanup handle leaked")
+ }
+ // The later steps still ran, so the report says everything it saw.
+ if !rep.Steps[moves.StepStray].OK || !rep.Steps[moves.StepFDs].OK || !rep.Steps[moves.StepLease].OK {
+ t.Fatalf("later steps: %+v", rep.Steps)
+ }
+}
+
+func TestReconstructOpenFailureIsAnError(t *testing.T) {
+ f := newBoxFixture(t)
+ boom := errors.New("boom")
+ if _, err := reconstruct(context.Background(), f.inputs(), opener(nil, boom)); !errors.Is(err, boom) {
+ t.Fatalf("err %v", err)
+ }
+ // A kill that fails for any reason but the drain running out of time
+ // could not observe the tree: an error, not a recorded step.
+ g := &fakeTree{killErr: boom}
+ if _, err := reconstruct(context.Background(), f.inputs(), opener(g, nil)); !errors.Is(err, boom) || !g.closed {
+ t.Fatalf("kill error: %v, closed %v", err, g.closed)
+ }
+}
+
+func TestReconstructCallerCancelledIsAnError(t *testing.T) {
+ f := newBoxFixture(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ g := &fakeTree{}
+ g.killErr = context.Canceled
+ open := func(string) (treeGroup, error) { cancel(); return g, nil }
+ if _, err := reconstruct(ctx, f.inputs(), open); !errors.Is(err, context.Canceled) {
+ t.Fatalf("err %v", err)
+ }
+}
+
+func TestReconstructStrayInTheAgentsLeaf(t *testing.T) {
+ f := newBoxFixture(t, os.Getpid(), 4243, 4244)
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := rep.Steps[moves.StepStray]; c.OK || c.Reason != "pid 4243 in the agent's cgroup is not the agent" || rep.TreeStopped() {
+ t.Fatalf("stray: %+v", rep)
+ }
+ if !reflect.DeepEqual(rep.Stray, []int{4243, 4244}) {
+ t.Fatalf("Stray %v", rep.Stray)
+ }
+}
+
+// The leaf is read where /proc/self/mountinfo mounts the hierarchy, so a
+// leaf that names the agent but is not the agent's is an error, not a pass.
+func TestReconstructRefusesALeafWithoutTheAgent(t *testing.T) {
+ f := newBoxFixture(t, 4243)
+ if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil {
+ t.Fatal("a leaf without the agent passed")
+ }
+}
+
+func TestReconstructOwnCgroupUnreadable(t *testing.T) {
+ for name, mutate := range map[string]func(f *boxFixture){
+ "no v2 line": func(f *boxFixture) {
+ os.WriteFile(filepath.Join(f.proc, "self", "cgroup"), []byte("1:name=systemd:/x\n"), 0o644)
+ },
+ "root cgroup": func(f *boxFixture) { os.WriteFile(filepath.Join(f.proc, "self", "cgroup"), []byte("0::/\n"), 0o644) },
+ "no mount": func(f *boxFixture) { os.WriteFile(filepath.Join(f.proc, "self", "mountinfo"), []byte(""), 0o644) },
+ "no procs": func(f *boxFixture) { os.Remove(filepath.Join(f.leafDir, "cgroup.procs")) },
+ "garbled pid": func(f *boxFixture) { os.WriteFile(filepath.Join(f.leafDir, "cgroup.procs"), []byte("12x\n"), 0o644) },
+ "no mountinfo": func(f *boxFixture) { os.Remove(filepath.Join(f.proc, "self", "mountinfo")) },
+ } {
+ f := newBoxFixture(t)
+ mutate(f)
+ if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil {
+ t.Fatalf("%s: passed", name)
+ }
+ }
+}
+
+func TestReconstructDatabaseHolders(t *testing.T) {
+ for _, path := range []string{"", "-wal", "-shm"} {
+ f := newBoxFixture(t)
+ f.holdDB(t, 5150, f.db+path)
+ rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := rep.Steps[moves.StepFDs]; c.OK || c.Reason != "1 process(es) hold the database open" || rep.TreeStopped() {
+ t.Fatalf("holder of %q: %+v", path, rep.Steps)
+ }
+ }
+ // A descriptor on some other file is not a holder.
+ f := newBoxFixture(t)
+ f.holdDB(t, 5150, filepath.Join(filepath.Dir(f.db), "other.db"))
+ if rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err != nil || !rep.Steps[moves.StepFDs].OK {
+ t.Fatalf("other file: %+v %v", rep.Steps, err)
+ }
+}
+
+func TestReconstructLeaseStep(t *testing.T) {
+ f := newBoxFixture(t)
+ in := f.inputs()
+ in.LeaseFree = func(context.Context) (bool, error) { return false, nil }
+ rep, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := rep.Steps[moves.StepLease]; c.OK || c.Reason != "lock.json still held" || rep.TreeStopped() {
+ t.Fatalf("lease held: %+v", rep.Steps)
+ }
+ boom := errors.New("boom")
+ in.LeaseFree = func(context.Context) (bool, error) { return true, boom }
+ if _, err := reconstruct(context.Background(), in, opener(&fakeTree{}, nil)); !errors.Is(err, boom) {
+ t.Fatalf("lease read error: %v", err)
+ }
+}
+
+func TestReconstructInvalidInputs(t *testing.T) {
+ f := newBoxFixture(t)
+ for name, mutate := range map[string]func(*ReconstructInputs){
+ "bad host": func(in *ReconstructInputs) { in.Host = "../etc" },
+ "no lease reader": func(in *ReconstructInputs) { in.LeaseFree = nil },
+ "no database": func(in *ReconstructInputs) { in.DBPaths = nil },
+ "relative db": func(in *ReconstructInputs) { in.DBPaths = []string{"h.example.com.db"} },
+ "relative proc": func(in *ReconstructInputs) { in.ProcRoot = "proc" },
+ "empty db in list": func(in *ReconstructInputs) { in.DBPaths = []string{f.db, ""} },
+ } {
+ in := f.inputs()
+ mutate(&in)
+ g := &fakeTree{}
+ if _, err := reconstruct(context.Background(), in, opener(g, nil)); !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("%s: err %v", name, err)
+ }
+ if g.killed {
+ t.Fatalf("%s: killed the tree before refusing the input", name)
+ }
+ }
+ in := f.inputs()
+ if _, err := Reconstruct(context.Background(), in); !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("nil manager: err %v", err)
+ }
+ if _, err := reconstruct(nil, f.inputs(), opener(&fakeTree{}, nil)); !errors.Is(err, ErrInvalidInput) { //nolint:staticcheck // a nil context is the input under test
+ t.Fatalf("nil context: err %v", err)
+ }
+}
+
+func TestCgroupLeafDir(t *testing.T) {
+ line := func(root, mnt, fstype string) string {
+ return "40 30 0:27 " + root + " " + mnt + " rw,relatime shared:8 - " + fstype + " " + fstype + " rw\n"
+ }
+ for _, c := range []struct {
+ name, mountinfo, leaf, want string
+ ok bool
+ }{
+ {"root mount", line("/", "/sys/fs/cgroup", "cgroup2"), "a.service/supervise", "/sys/fs/cgroup/a.service/supervise", true},
+ {"mounted elsewhere", line("/", "/run/cg", "cgroup2"), "a.service/supervise", "/run/cg/a.service/supervise", true},
+ {"escaped space", line("/", `/run/c\040g`, "cgroup2"), "a/b", "/run/c g/a/b", true},
+ {"subtree bind", line("/a.service", "/mnt/a", "cgroup2"), "a.service/supervise", "/mnt/a/supervise", true},
+ {"subtree is the leaf", line("/a.service/supervise", "/mnt/s", "cgroup2"), "a.service/supervise", "/mnt/s", true},
+ {"longest root wins", line("/", "/sys/fs/cgroup", "cgroup2") + line("/a.service", "/mnt/a", "cgroup2"), "a.service/x", "/mnt/a/x", true},
+ {"first of equal roots", line("/", "/sys/fs/cgroup", "cgroup2") + line("/", "/other", "cgroup2"), "a/b", "/sys/fs/cgroup/a/b", true},
+ {"sibling subtree only", line("/a.serviceX", "/mnt/x", "cgroup2"), "a.service/supervise", "", false},
+ {"v1 only", line("/", "/sys/fs/cgroup/memory", "cgroup"), "a/b", "", false},
+ {"no mounts", "", "a/b", "", false},
+ {"outside the namespace", line("/", "/sys/fs/cgroup", "cgroup2"), "../../x", "", false},
+ {"garbled line", "40 30 0:27 / /sys/fs/cgroup\n", "a/b", "", false},
+ } {
+ got, err := cgroupLeafDir(c.mountinfo, c.leaf)
+ if c.ok != (err == nil) || got != c.want {
+ t.Fatalf("%s: %q %v, want %q ok=%v", c.name, got, err, c.want, c.ok)
+ }
+ }
+}
+
+// The drain bound never extends the caller's own deadline.
+func TestReconstructKeepsTheCallersDeadline(t *testing.T) {
+ f := newBoxFixture(t)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ want, _ := ctx.Deadline()
+ var got time.Time
+ open := func(string) (treeGroup, error) {
+ return deadlineTree(func(d time.Time) { got = d }), nil
+ }
+ if _, err := reconstruct(ctx, f.inputs(), open); err != nil {
+ t.Fatal(err)
+ }
+ if got.After(want) || got.IsZero() {
+ t.Fatalf("drain deadline %v, caller's %v", got, want)
+ }
+}
+
+type deadlineTree func(time.Time)
+
+func (d deadlineTree) KillAndEmpty(ctx context.Context) error {
+ dl, _ := ctx.Deadline()
+ d(dl)
+ return nil
+}
+func (deadlineTree) Close() error { return nil }
diff --git a/internal/stopevidence/reconstruct_other.go b/internal/stopevidence/reconstruct_other.go
new file mode 100644
index 000000000..9b71b5e41
--- /dev/null
+++ b/internal/stopevidence/reconstruct_other.go
@@ -0,0 +1,15 @@
+//go:build !linux
+
+package stopevidence
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// Reconstruct needs cgroup v2 and /proc: on any other platform it observes
+// nothing and returns ErrUnsupported, so no move can cut from such a box.
+func Reconstruct(context.Context, ReconstructInputs) (moves.StoppedReport, error) {
+ return moves.StoppedReport{}, ErrUnsupported
+}