cgroup, instancectl, hostguard, host, hostbox: root fences and exact typed inventory retirement (claim Plan 6 Task 8)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 files changed,
+2287
−21
internal/cgroup/role_linux.go+91 −0internal/cgroup/role_linux_test.go+143 −0internal/cgroup/role_other.go+10 −0internal/cgroup/strict_linux.go+26 −3internal/cgroup/strict_linux_test.go+1 −1internal/host/bootstrap.go+2 −1internal/host/claimdirs_test.go+13 −0internal/host/claimstop.go+315 −0internal/host/claimstop_test.go+225 −0internal/hostbox/typedpark_linux.go+200 −0internal/hostbox/typedpark_linux_test.go+139 −0internal/hostbox/typedpark_other.go+18 −0internal/hostbox/typedretire.go+293 −0internal/hostbox/typedretire_test.go+173 −0internal/hostguard/fence.go+245 −0internal/hostguard/fence_test.go+229 −0internal/hostguard/guard.go+32 −11internal/hostguard/record.go+20 −5internal/instancectl/peer_tuple_linux.go+64 −0internal/instancectl/peer_tuple_linux_test.go+30 −0internal/instancectl/peer_tuple_other.go+18 −0
diff --git a/internal/cgroup/role_linux.go b/internal/cgroup/role_linux.go| new file mode 100644 |
| index 000000000..708e5f931 |
| --- /dev/null |
| +++ b/internal/cgroup/role_linux.go |
| @@ -0,0 +1,91 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "os/exec" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type ContainmentIdentity struct{ Device, Inode uint64 } |
| + |
| +func (p *Prepared) Identity() (ContainmentIdentity, error) { |
| + if p == nil || p.strictPreparedState == nil { |
| + return ContainmentIdentity{}, ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.identityOK(); err != nil { |
| + return ContainmentIdentity{}, err |
| + } |
| + return ContainmentIdentity{Device: p.identity.device, Inode: p.identity.inode}, nil |
| +} |
| + |
| +// PrepareRoleChild pins a newly empty role group below the host parent. It |
| +// never reuses the parent's populated group and inherits its aggregate limits. |
| +func (p *Prepared) PrepareRoleChild(role string) (out *Prepared, err error) { |
| + if p == nil || p.strictPreparedState == nil || !controlname.ValidLabel(role) { |
| + return nil, ErrStrictRefused |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.parent != nil || p.empty || p.draining { |
| + return nil, ErrStrictState |
| + } |
| + if err = p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + name := "role-" + role |
| + if err = unix.Mkdirat(int(p.dir.Fd()), name, 0755); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return nil, ErrStrictRefused |
| + } |
| + child := &Prepared{strictPreparedState: &strictPreparedState{parent: p, host: p.host, limits: map[string]*os.File{}, expected: map[string]string{}, entryName: name, start: func(cmd *exec.Cmd) error { return cmd.Start() }}} |
| + defer func() { |
| + if err != nil { |
| + child.closeFiles() |
| + } |
| + }() |
| + fd, e := unix.FcntlInt(p.dir.Fd(), unix.F_DUPFD_CLOEXEC, 0) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + child.entryParent = os.NewFile(uintptr(fd), name+"-parent") |
| + child.dir, e = strictOpen(p.dir, name, unix.O_RDONLY|unix.O_DIRECTORY) |
| + if e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + child.identity, e = strictID(child.dir) |
| + if e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + strictReservations.Lock() |
| + if strictReservations.held[child.identity] { |
| + strictReservations.Unlock() |
| + return nil, ErrStrictState |
| + } |
| + strictReservations.held[child.identity] = true |
| + strictReservations.Unlock() |
| + defer func() { |
| + if err != nil { |
| + strictReservations.Lock() |
| + delete(strictReservations.held, child.identity) |
| + strictReservations.Unlock() |
| + } |
| + }() |
| + child.events, e = strictOpen(child.dir, "cgroup.events", unix.O_RDONLY) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + child.kill, e = strictOpen(child.dir, "cgroup.kill", unix.O_WRONLY) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if populated, e := strictPopulation(child.events); e != nil || populated { |
| + return nil, ErrStrictRefused |
| + } |
| + return child, nil |
| +} |
diff --git a/internal/cgroup/role_linux_test.go b/internal/cgroup/role_linux_test.go| new file mode 100644 |
| index 000000000..dfecd0aa5 |
| --- /dev/null |
| +++ b/internal/cgroup/role_linux_test.go |
| @@ -0,0 +1,143 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func makeRoleFixture(t *testing.T, p *Prepared, role, events string) { |
| + t.Helper() |
| + d := filepath.Join(p.dir.Name(), "role-"+role) |
| + if err := os.Mkdir(d, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for n, v := range map[string]string{"cgroup.events": events, "cgroup.kill": ""} { |
| + if err := os.WriteFile(filepath.Join(d, n), []byte(v), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestPrepareRoleChildEmptyAndIdentity(t *testing.T) { |
| + p := strictFixture(t) |
| + makeRoleFixture(t, p, "seed", "populated 0\n") |
| + child, err := p.PrepareRoleChild("seed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + flags, err := unix.FcntlInt(child.entryParent.Fd(), unix.F_GETFD, 0) |
| + if err != nil || flags&unix.FD_CLOEXEC == 0 { |
| + t.Fatalf("parent descriptor may escape exec: flags=%d err=%v", flags, err) |
| + } |
| + id, err := child.Identity() |
| + if err != nil || id.Device == 0 || id.Inode == 0 { |
| + t.Fatalf("identity=%+v %v", id, err) |
| + } |
| + if err := child.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRoleChildCannotOutliveParentCleanup(t *testing.T) { |
| + for _, action := range []string{"kill", "close", "failed-kill", "failed-close"} { |
| + t.Run(action, func(t *testing.T) { |
| + p := strictFixture(t) |
| + makeRoleFixture(t, p, "seed", "populated 0\n") |
| + child, err := p.PrepareRoleChild("seed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer child.Close() |
| + starts := 0 |
| + child.start = func(*exec.Cmd) error { starts++; return nil } |
| + if action == "failed-close" { |
| + if err := strictWrite(p.events, "populated 1\n"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Close(); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("failed close=%v", err) |
| + } |
| + // Even once the parent's population drops, cleanup remains latched. |
| + if err := strictWrite(p.events, "populated 0\n"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if action == "close" { |
| + if err := p.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else { |
| + if action == "failed-kill" { |
| + p.kill.Close() |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + err := p.KillAndEmpty(ctx) |
| + if (err != nil) != (action == "failed-kill") { |
| + t.Fatalf("cleanup: %v", err) |
| + } |
| + } |
| + if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictState) || starts != 0 { |
| + t.Fatalf("child reopened drained parent: %v, starts=%d", err, starts) |
| + } |
| + if _, err := p.PrepareRoleChild("another"); !errors.Is(err, ErrStrictState) { |
| + t.Fatalf("new role after cleanup: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRoleChildRechecksParentLimits(t *testing.T) { |
| + p := strictFixture(t) |
| + makeRoleFixture(t, p, "seed", "populated 0\n") |
| + child, err := p.PrepareRoleChild("seed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer child.Close() |
| + if err := strictWrite(p.limits["memory.max"], "0"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := child.Start(exec.Command("unused")); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("changed aggregate limit: %v", err) |
| + } |
| +} |
| + |
| +func TestPrepareRoleChildRefusals(t *testing.T) { |
| + t.Run("nonempty", func(t *testing.T) { |
| + p := strictFixture(t) |
| + makeRoleFixture(t, p, "seed", "populated 1\n") |
| + if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("err=%v", err) |
| + } |
| + }) |
| + t.Run("symlink", func(t *testing.T) { |
| + p := strictFixture(t) |
| + if err := os.Symlink(t.TempDir(), filepath.Join(p.dir.Name(), "role-seed")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := p.PrepareRoleChild("seed"); err == nil { |
| + t.Fatal("symlink accepted") |
| + } |
| + }) |
| + t.Run("no-delegation", func(t *testing.T) { |
| + p := strictFixture(t) |
| + if _, err := p.PrepareRoleChild("seed"); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatalf("err=%v", err) |
| + } |
| + }) |
| + t.Run("absent-role", func(t *testing.T) { |
| + p := strictFixture(t) |
| + if _, err := p.PrepareRoleChild(""); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("err=%v", err) |
| + } |
| + }) |
| +} |
diff --git a/internal/cgroup/role_other.go b/internal/cgroup/role_other.go| new file mode 100644 |
| index 000000000..6f1b17fbc |
| --- /dev/null |
| +++ b/internal/cgroup/role_other.go |
| @@ -0,0 +1,10 @@ |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +type ContainmentIdentity struct{ Device, Inode uint64 } |
| + |
| +func (p *Prepared) Identity() (ContainmentIdentity, error) { |
| + return ContainmentIdentity{}, ErrStrictUnsupported |
| +} |
| +func (p *Prepared) PrepareRoleChild(string) (*Prepared, error) { return nil, ErrStrictUnsupported } |
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go| index 35ace0591..3914e32e2 100644 |
| --- a/internal/cgroup/strict_linux.go |
| +++ b/internal/cgroup/strict_linux.go |
| @@ -36,6 +36,10 @@ type strictPreparedState struct { |
| limits map[string]*os.File |
| expected map[string]string |
| identity strictIdentity |
| + entryParent *os.File |
| + entryName string |
| + parent *Prepared |
| + draining bool |
| host string |
| attempted, empty, closed bool |
| start func(*exec.Cmd) error |
| @@ -112,7 +116,11 @@ func (p *Prepared) identityOK() error { |
| return ErrStrictRefused |
| } |
| var st unix.Stat_t |
| - if e = unix.Fstatat(int(p.root.Fd()), ExecName(p.host), &st, unix.AT_SYMLINK_NOFOLLOW); e != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || (strictIdentity{uint64(st.Dev), st.Ino}) != p.identity { |
| + parent, name := p.root, ExecName(p.host) |
| + if p.entryParent != nil { |
| + parent, name = p.entryParent, p.entryName |
| + } |
| + if e = unix.Fstatat(int(parent.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); e != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || (strictIdentity{uint64(st.Dev), st.Ino}) != p.identity { |
| return ErrStrictRefused |
| } |
| return nil |
| @@ -130,7 +138,7 @@ func (p *Prepared) closeFiles() { |
| for _, f := range p.limits { |
| f.Close() |
| } |
| - for _, f := range []*os.File{p.events, p.kill, p.dir, p.root} { |
| + for _, f := range []*os.File{p.events, p.kill, p.dir, p.entryParent, p.root} { |
| if f != nil { |
| f.Close() |
| } |
| @@ -234,9 +242,22 @@ func (p *Prepared) Start(cmd *exec.Cmd) error { |
| if p == nil || p.strictPreparedState == nil { |
| return ErrStrictState |
| } |
| + if p.parent != nil { |
| + p.parent.mu.Lock() |
| + defer p.parent.mu.Unlock() |
| + if p.parent.closed || p.parent.empty || p.parent.draining { |
| + return ErrStrictState |
| + } |
| + if err := p.parent.identityOK(); err != nil { |
| + return err |
| + } |
| + if err := p.parent.limitsOK(); err != nil { |
| + return err |
| + } |
| + } |
| p.mu.Lock() |
| defer p.mu.Unlock() |
| - if p.closed || p.attempted || p.empty { |
| + if p.closed || p.attempted || p.empty || p.draining { |
| return ErrStrictState |
| } |
| p.attempted = true |
| @@ -311,6 +332,7 @@ func (p *Prepared) KillAndEmpty(ctx context.Context) error { |
| if e := p.identityOK(); e != nil { |
| return e |
| } |
| + p.draining = true |
| p.attempted = true |
| if e := strictWrite(p.kill, "1"); e != nil { |
| return ErrStrictRefused |
| @@ -351,6 +373,7 @@ func (p *Prepared) Close() error { |
| } |
| p.mu.Lock() |
| defer p.mu.Unlock() |
| + p.draining = true |
| if p.closed { |
| return ErrStrictState |
| } |
diff --git a/internal/cgroup/strict_linux_test.go b/internal/cgroup/strict_linux_test.go| index 03f687a55..d8d8f3908 100644 |
| --- a/internal/cgroup/strict_linux_test.go |
| +++ b/internal/cgroup/strict_linux_test.go |
| @@ -210,7 +210,7 @@ func TestStrictStaleHandleCannotKillReplacement(t *testing.T) { |
| if e != nil || string(raw) != "replacement" { |
| t.Fatal("replacement group touched", e) |
| } |
| - if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictRefused) { |
| + if e := p.Start(exec.Command("/not-executed")); !errors.Is(e, ErrStrictState) { |
| t.Fatal("stale spawn accepted", e) |
| } |
| } |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 5831faf10..db43a6bd7 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -58,7 +58,8 @@ const socketsGroup = "carlos-sockets" |
| // 0600), tenants never need path access (the fd is passed in) — it stays |
| // carlos:carlos, so consumer enrollment deliberately does not reach unit |
| // tenants. other=0 on both dirs keeps every unenrolled uid out. |
| -const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| +const tmpfilesConf = "d /data/.parked 2770 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /run/carlos/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /data/hostguard 2770 " + serviceUser + " " + serviceUser + " -\n" + |
diff --git a/internal/host/claimdirs_test.go b/internal/host/claimdirs_test.go| new file mode 100644 |
| index 000000000..f21ceed8d |
| --- /dev/null |
| +++ b/internal/host/claimdirs_test.go |
| @@ -0,0 +1,13 @@ |
| +package host |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestHostClaimParkedTmpfiles(t *testing.T) { |
| + line := "d /data/.parked 2770 " + serviceUser + " " + serviceUser + " -\n" |
| + if strings.Count(tmpfilesConf, line) != 1 { |
| + t.Fatal("parked directory missing or duplicated") |
| + } |
| +} |
diff --git a/internal/host/claimstop.go b/internal/host/claimstop.go| new file mode 100644 |
| index 000000000..83100ede8 |
| --- /dev/null |
| +++ b/internal/host/claimstop.go |
| @@ -0,0 +1,315 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + ErrClaimStopUnavailable = errors.New("claim stop: required root provider unavailable") |
| + ErrClaimStopIdentity = errors.New("claim stop: authenticated identity mismatch") |
| + ErrClaimStopInventory = errors.New("claim stop: inventory does not match request") |
| + ErrClaimStopReceipt = errors.New("claim stop: immutable receipt conflict") |
| +) |
| + |
| +type StopPeer struct { |
| + UID uint32 |
| + PID int |
| + ProcessStart string |
| + BootID string |
| + ControllerID string |
| +} |
| + |
| +type StopPeerProvider interface { |
| + AuthenticatedPeer(context.Context) (StopPeer, error) |
| +} |
| + |
| +type StopInventoryEntry struct { |
| + Kind string |
| + ArtifactID string |
| + ManagedRoot string |
| + RelativePath string |
| + RowIdentity string |
| + Ownership string |
| + Materialization string |
| +} |
| + |
| +type StopInventory struct { |
| + Host string |
| + Incarnation string |
| + InstallationStamp uint64 |
| + Box string |
| + Enrollment string |
| + DirectoryRevision string |
| + Entries []StopInventoryEntry |
| +} |
| + |
| +type StopInventoryReader interface { |
| + ReadStopInventory(context.Context, string) (StopInventory, error) |
| +} |
| + |
| +type StopRequest struct { |
| + Host, Incarnation string |
| + InstallationStamp, WriterSession uint64 |
| + OperationID, Phase, ObligationID string |
| + Box, Enrollment string |
| + BootID, ControllerID string |
| + ExpectedClaimETag string |
| + ExpectedDirectoryRevision string |
| +} |
| + |
| +type StopClaimReader interface { |
| + Read(context.Context, string) (hostclaim.Observed, error) |
| +} |
| + |
| +type DrainAcknowledgement struct { |
| + FenceID string |
| + ControllerID string |
| + ProcessStart string |
| + Digest string |
| +} |
| + |
| +type StopDrainer interface { |
| + DrainClaim(context.Context, StopRequest, string) (DrainAcknowledgement, error) |
| +} |
| + |
| +type StopKernelEvidence struct { |
| + InventorySHA256 string |
| + LocalJournalSHA256 string |
| + CgroupIdentities []string |
| + UnitInvocationIDs []string |
| +} |
| + |
| +type StopKernelVerifier interface { |
| + VerifyStopped(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) |
| +} |
| + |
| +type StopReceipt struct { |
| + V int `json:"v"` |
| + ID string `json:"id"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + InstallationStamp uint64 `json:"installation_stamp"` |
| + WriterSession uint64 `json:"writer_session"` |
| + OperationID string `json:"operation_id"` |
| + Phase string `json:"phase"` |
| + ObligationID string `json:"obligation_id"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + BootID string `json:"boot_id"` |
| + ControllerID string `json:"controller_id"` |
| + ClaimGen uint64 `json:"claim_gen"` |
| + DirectoryRevision string `json:"directory_revision"` |
| + Kind string `json:"kind"` |
| + EvidenceAt time.Time `json:"evidence_at"` |
| + FenceID string `json:"fence_id"` |
| + InventorySHA256 string `json:"inventory_sha256"` |
| + LocalJournalSHA256 string `json:"local_journal_sha256"` |
| + ProducerDrainDigest string `json:"producer_drain_digest"` |
| + CgroupIdentities []string `json:"cgroup_identities"` |
| + UnitInvocationIDs []string `json:"unit_invocation_ids"` |
| +} |
| + |
| +type StopResult struct { |
| + Key string |
| + Digest string |
| + Bytes []byte |
| + EvidenceAt time.Time |
| +} |
| + |
| +type ClaimStopCoordinator struct { |
| + Guard hostguard.Guard |
| + Claims StopClaimReader |
| + Peers StopPeerProvider |
| + Inventory StopInventoryReader |
| + Drainer StopDrainer |
| + Kernel StopKernelVerifier |
| + Evidence release.Store |
| + CarlosUID uint32 |
| + NewID func() string |
| + Now func() time.Time |
| +} |
| + |
| +func stopComponent(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| + |
| +func stopDigest(s string) bool { |
| + if len(s) != sha256.Size*2 { |
| + return false |
| + } |
| + _, err := hex.DecodeString(s) |
| + return err == nil |
| +} |
| + |
| +func (c ClaimStopCoordinator) validateRequest(r StopRequest) error { |
| + if c.CarlosUID == 0 || c.Peers == nil || c.Claims == nil || c.Inventory == nil || c.Drainer == nil || c.Kernel == nil || c.Evidence == nil || c.NewID == nil || c.Now == nil || |
| + !stopComponent(r.Host) || !stopComponent(r.OperationID) || !stopComponent(r.Phase) || !stopComponent(r.ObligationID) || !stopComponent(r.Box) || |
| + !stopComponent(r.Enrollment) || r.Incarnation == "" || r.InstallationStamp == 0 || r.WriterSession == 0 || |
| + r.BootID == "" || r.ControllerID == "" || r.ExpectedClaimETag == "" || r.ExpectedDirectoryRevision == "" { |
| + return ErrClaimStopUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +func (c ClaimStopCoordinator) FenceAndStop(ctx context.Context, req StopRequest) (StopResult, error) { |
| + if ctx == nil || c.validateRequest(req) != nil { |
| + return StopResult{}, ErrClaimStopUnavailable |
| + } |
| + peer, err := c.Peers.AuthenticatedPeer(ctx) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + if peer.UID != c.CarlosUID || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID != req.BootID || peer.ControllerID != req.ControllerID { |
| + return StopResult{}, ErrClaimStopIdentity |
| + } |
| + observed, err := c.Claims.Read(ctx, req.Host) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || observed.ETag != req.ExpectedClaimETag || |
| + observed.Claim.Host != req.Host || observed.Claim.Holder.Incarnation != req.Incarnation || |
| + observed.Claim.Holder.Stamp != req.InstallationStamp || observed.Claim.Holder.Session != req.WriterSession || |
| + observed.Claim.Op.ID != req.OperationID || observed.Claim.Op.Phase != req.Phase || observed.Claim.Op.Exec != req.Box { |
| + return StopResult{}, ErrClaimStopIdentity |
| + } |
| + inv, err := c.Inventory.ReadStopInventory(ctx, req.Host) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + if inv.Host != req.Host || inv.Incarnation != req.Incarnation || inv.InstallationStamp != req.InstallationStamp || |
| + inv.Box != req.Box || inv.Enrollment != req.Enrollment || inv.DirectoryRevision != req.ExpectedDirectoryRevision { |
| + return StopResult{}, ErrClaimStopInventory |
| + } |
| + fenceID, receiptID := c.NewID(), c.NewID() |
| + if !stopComponent(fenceID) || !stopComponent(receiptID) { |
| + return StopResult{}, ErrClaimStopUnavailable |
| + } |
| + target := hostguard.FenceTarget{FenceID: fenceID, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, |
| + Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment, |
| + BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision} |
| + target, err = c.Guard.BeginFence(ctx, target) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + fenceID = target.FenceID |
| + if durable, err := c.Guard.FenceReceiptFor(ctx, target); err == nil { |
| + result, err := stopResultFromDurable(req, durable) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + if err := c.Publish(ctx, result); err != nil { |
| + return result, err |
| + } |
| + return result, nil |
| + } else if !errors.Is(err, hostguard.ErrNoFenceReceipt) { |
| + return StopResult{}, err |
| + } |
| + ack, err := c.Drainer.DrainClaim(ctx, req, fenceID) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + if ack.FenceID != fenceID || ack.ControllerID != req.ControllerID || ack.ProcessStart != peer.ProcessStart || !stopDigest(ack.Digest) { |
| + return StopResult{}, ErrClaimStopIdentity |
| + } |
| + kernel, err := c.Kernel.VerifyStopped(ctx, target, inv, ack) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + evidenceAt := c.Now().UTC() |
| + if evidenceAt.IsZero() || !stopDigest(kernel.InventorySHA256) || !stopDigest(kernel.LocalJournalSHA256) || len(kernel.CgroupIdentities) == 0 { |
| + return StopResult{}, ErrClaimStopReceipt |
| + } |
| + receipt := StopReceipt{V: 1, ID: receiptID, Host: req.Host, Incarnation: req.Incarnation, |
| + InstallationStamp: req.InstallationStamp, WriterSession: req.WriterSession, OperationID: req.OperationID, |
| + Phase: req.Phase, ObligationID: req.ObligationID, Box: req.Box, Enrollment: req.Enrollment, |
| + BootID: req.BootID, ControllerID: req.ControllerID, ClaimGen: observed.Claim.Gen, |
| + DirectoryRevision: req.ExpectedDirectoryRevision, Kind: "stopped", EvidenceAt: evidenceAt, FenceID: fenceID, |
| + InventorySHA256: kernel.InventorySHA256, LocalJournalSHA256: kernel.LocalJournalSHA256, |
| + ProducerDrainDigest: ack.Digest, CgroupIdentities: append([]string(nil), kernel.CgroupIdentities...), |
| + UnitInvocationIDs: append([]string(nil), kernel.UnitInvocationIDs...)} |
| + body, err := json.Marshal(receipt) |
| + if err != nil { |
| + return StopResult{}, err |
| + } |
| + verify := func(_ context.Context, got hostguard.FenceTarget, exact []byte, at time.Time) error { |
| + if got != target || !at.Equal(evidenceAt) || !bytes.Equal(exact, body) { |
| + return ErrClaimStopReceipt |
| + } |
| + return nil |
| + } |
| + if err := c.Guard.CompleteFence(ctx, target, body, evidenceAt, verify); err != nil { |
| + return StopResult{}, err |
| + } |
| + key := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", req.Host, req.OperationID, req.Box, receiptID) |
| + sum := sha256.Sum256(body) |
| + result := StopResult{Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), body...), EvidenceAt: evidenceAt} |
| + if err := c.Publish(ctx, result); err != nil { |
| + return result, err |
| + } |
| + return result, nil |
| +} |
| + |
| +func stopResultFromDurable(req StopRequest, durable hostguard.FenceReceipt) (StopResult, error) { |
| + var receipt StopReceipt |
| + if err := json.Unmarshal(durable.Bytes, &receipt); err != nil { |
| + return StopResult{}, ErrClaimStopReceipt |
| + } |
| + if receipt.V != 1 || receipt.ID == "" || receipt.Host != req.Host || receipt.Incarnation != req.Incarnation || |
| + receipt.InstallationStamp != req.InstallationStamp || receipt.WriterSession != req.WriterSession || |
| + receipt.OperationID != req.OperationID || receipt.Phase != req.Phase || receipt.ObligationID != req.ObligationID || |
| + receipt.Box != req.Box || receipt.Enrollment != req.Enrollment || receipt.BootID != req.BootID || |
| + receipt.ControllerID != req.ControllerID || receipt.FenceID != durable.Target.FenceID || |
| + !receipt.EvidenceAt.Equal(durable.EvidenceAt) || !bytes.Equal(durable.Bytes, mustMarshalStopReceipt(receipt)) { |
| + return StopResult{}, ErrClaimStopReceipt |
| + } |
| + key := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", req.Host, req.OperationID, req.Box, receipt.ID) |
| + sum := sha256.Sum256(durable.Bytes) |
| + return StopResult{Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), durable.Bytes...), EvidenceAt: durable.EvidenceAt}, nil |
| +} |
| + |
| +func mustMarshalStopReceipt(receipt StopReceipt) []byte { |
| + b, _ := json.Marshal(receipt) |
| + return b |
| +} |
| + |
| +// Publish retries only the exact bytes and key returned by FenceAndStop. |
| +func (c ClaimStopCoordinator) Publish(ctx context.Context, result StopResult) error { |
| + if ctx == nil || c.Evidence == nil || result.Key == "" || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() { |
| + return ErrClaimStopUnavailable |
| + } |
| + var receipt StopReceipt |
| + if err := json.Unmarshal(result.Bytes, &receipt); err != nil { |
| + return ErrClaimStopReceipt |
| + } |
| + wantKey := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", receipt.Host, receipt.OperationID, receipt.Box, receipt.ID) |
| + sum := sha256.Sum256(result.Bytes) |
| + if result.Key != wantKey || result.Digest != hex.EncodeToString(sum[:]) || !result.EvidenceAt.Equal(receipt.EvidenceAt) || |
| + !bytes.Equal(result.Bytes, mustMarshalStopReceipt(receipt)) { |
| + return ErrClaimStopReceipt |
| + } |
| + if _, err := c.Evidence.Put(ctx, result.Key, result.Bytes, ""); err == nil { |
| + return nil |
| + } else if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + body, _, err := c.Evidence.Get(ctx, result.Key) |
| + if err != nil { |
| + return err |
| + } |
| + if !bytes.Equal(body, result.Bytes) { |
| + return ErrClaimStopReceipt |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/claimstop_test.go b/internal/host/claimstop_test.go| new file mode 100644 |
| index 000000000..eab7c2189 |
| --- /dev/null |
| +++ b/internal/host/claimstop_test.go |
| @@ -0,0 +1,225 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type stopPeerFunc func(context.Context) (StopPeer, error) |
| + |
| +func (f stopPeerFunc) AuthenticatedPeer(ctx context.Context) (StopPeer, error) { return f(ctx) } |
| + |
| +type stopInventoryFunc func(context.Context, string) (StopInventory, error) |
| + |
| +func (f stopInventoryFunc) ReadStopInventory(ctx context.Context, host string) (StopInventory, error) { |
| + return f(ctx, host) |
| +} |
| + |
| +type stopDrainFunc func(context.Context, StopRequest, string) (DrainAcknowledgement, error) |
| + |
| +func (f stopDrainFunc) DrainClaim(ctx context.Context, req StopRequest, fence string) (DrainAcknowledgement, error) { |
| + return f(ctx, req, fence) |
| +} |
| + |
| +type stopKernelFunc func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) |
| + |
| +func (f stopKernelFunc) VerifyStopped(ctx context.Context, target hostguard.FenceTarget, inv StopInventory, ack DrainAcknowledgement) (StopKernelEvidence, error) { |
| + return f(ctx, target, inv, ack) |
| +} |
| + |
| +type stopClaimReader struct{ obs hostclaim.Observed } |
| + |
| +func (r stopClaimReader) Read(context.Context, string) (hostclaim.Observed, error) { return r.obs, nil } |
| + |
| +type failPutStore struct { |
| + release.Store |
| + err error |
| +} |
| + |
| +func (s *failPutStore) Put(ctx context.Context, key string, body []byte, match string) (string, error) { |
| + if s.err != nil { |
| + err := s.err |
| + s.err = nil |
| + return "", err |
| + } |
| + return s.Store.Put(ctx, key, body, match) |
| +} |
| + |
| +func claimStopFixture(t *testing.T) (ClaimStopCoordinator, StopRequest, *failPutStore) { |
| + t.Helper() |
| + host := "shop.example" |
| + data, locks := t.TempDir(), t.TempDir() |
| + if err := os.Mkdir(filepath.Join(data, "hostguard"), 0770); err != nil { |
| + t.Fatal(err) |
| + } |
| + claim := &hostclaim.Claim{V: 2, Host: host, Gen: 19, |
| + Holder: &hostclaim.Holder{Incarnation: "inc-1", Stamp: 11, Session: 13}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindMoving, ID: "operation-1", Phase: "stopping", Exec: "box-1"}} |
| + obs := hostclaim.Observed{Host: host, Claim: claim, ETag: "etag-1"} |
| + guard := hostguard.Guard{DataDir: data, LockDir: locks, Claims: stopClaimReader{obs}} |
| + if err := guard.Activate(t.Context(), host, "box-1", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + req := StopRequest{Host: host, Incarnation: "inc-1", InstallationStamp: 11, WriterSession: 13, |
| + OperationID: "operation-1", Phase: "stopping", ObligationID: "obligation-1", Box: "box-1", |
| + Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", |
| + ExpectedClaimETag: "etag-1", ExpectedDirectoryRevision: "directory-1"} |
| + base := release.NewDirStore(t.TempDir()) |
| + store := &failPutStore{Store: base} |
| + ids := []string{"fence-1", "receipt-1", "fence-retry", "receipt-retry"} |
| + now := time.Date(2026, 10, 6, 17, 0, 0, 123, time.UTC) |
| + c := ClaimStopCoordinator{Guard: guard, Claims: stopClaimReader{obs}, CarlosUID: 1001, Evidence: store, |
| + Peers: stopPeerFunc(func(context.Context) (StopPeer, error) { |
| + return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}, nil |
| + }), |
| + Inventory: stopInventoryFunc(func(_ context.Context, got string) (StopInventory, error) { |
| + return StopInventory{Host: got, Incarnation: "inc-1", InstallationStamp: 11, Box: "box-1", Enrollment: "enrollment-1", DirectoryRevision: "directory-1", Entries: []StopInventoryEntry{{Kind: "path", ArtifactID: "artifact-1", ManagedRoot: "data", RelativePath: "derived/by/root"}}}, nil |
| + }), |
| + Drainer: stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { |
| + return DrainAcknowledgement{FenceID: fence, ControllerID: "controller-1", ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil |
| + }), |
| + Kernel: stopKernelFunc(func(_ context.Context, _ hostguard.FenceTarget, _ StopInventory, _ DrainAcknowledgement) (StopKernelEvidence, error) { |
| + return StopKernelEvidence{InventorySHA256: strings.Repeat("b", 64), LocalJournalSHA256: strings.Repeat("c", 64), CgroupIdentities: []string{"cg-1"}, UnitInvocationIDs: []string{"unit-1"}}, nil |
| + }), |
| + NewID: func() string { id := ids[0]; ids = ids[1:]; return id }, Now: func() time.Time { return now }} |
| + return c, req, store |
| +} |
| + |
| +func TestClaimStopCollectsAndPublishesExactReceipt(t *testing.T) { |
| + c, req, _ := claimStopFixture(t) |
| + result, err := c.FenceAndStop(t.Context(), req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wantKey := "control/hosts/shop.example/evidence/operation-1/box-1/receipt-1.json" |
| + if result.Key != wantKey || result.Digest == "" { |
| + t.Fatalf("result = %+v", result) |
| + } |
| + stored, _, err := c.Evidence.Get(t.Context(), result.Key) |
| + if err != nil || !reflect.DeepEqual(stored, result.Bytes) { |
| + t.Fatalf("stored exact bytes = %v, %v", reflect.DeepEqual(stored, result.Bytes), err) |
| + } |
| + var receipt StopReceipt |
| + if err := json.Unmarshal(stored, &receipt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if receipt.FenceID != "fence-1" || receipt.ID != "receipt-1" || receipt.EvidenceAt != result.EvidenceAt || |
| + receipt.ControllerID != req.ControllerID || receipt.Phase != req.Phase || receipt.ProducerDrainDigest != strings.Repeat("a", 64) || |
| + !reflect.DeepEqual(receipt.CgroupIdentities, []string{"cg-1"}) || !reflect.DeepEqual(receipt.UnitInvocationIDs, []string{"unit-1"}) { |
| + t.Fatalf("receipt = %+v", receipt) |
| + } |
| +} |
| + |
| +func TestClaimStopAuthenticatesBeforeFenceOrDrain(t *testing.T) { |
| + c, req, _ := claimStopFixture(t) |
| + drained := false |
| + c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { |
| + return StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "wrong", ControllerID: "controller-1"}, nil |
| + }) |
| + c.Drainer = stopDrainFunc(func(context.Context, StopRequest, string) (DrainAcknowledgement, error) { |
| + drained = true |
| + return DrainAcknowledgement{}, nil |
| + }) |
| + if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopIdentity) || drained { |
| + t.Fatalf("unauthenticated stop = %v drained=%v", err, drained) |
| + } |
| + called := false |
| + err := c.Guard.With(t.Context(), req.Host, hostguard.Captured{Incarnation: req.Incarnation, Stamp: req.InstallationStamp}, func(*hostclaim.Claim) error { return nil }, func() error { called = true; return nil }) |
| + if err != nil || !called { |
| + t.Fatalf("authentication installed fence: %v called=%v", err, called) |
| + } |
| +} |
| + |
| +func TestClaimStopPublicationRetryKeepsBytesAndEvidenceTime(t *testing.T) { |
| + c, req, store := claimStopFixture(t) |
| + transport := errors.New("temporary publication failure") |
| + store.err = transport |
| + result, err := c.FenceAndStop(t.Context(), req) |
| + if !errors.Is(err, transport) || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() { |
| + t.Fatalf("first publication = %+v, %v", result, err) |
| + } |
| + original := append([]byte(nil), result.Bytes...) |
| + at := result.EvidenceAt |
| + if err := c.Publish(t.Context(), result); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(result.Bytes, original) || result.EvidenceAt != at { |
| + t.Fatal("retry changed durable receipt") |
| + } |
| + if err := c.Publish(t.Context(), result); err != nil { |
| + t.Fatalf("create-only exact replay = %v", err) |
| + } |
| +} |
| + |
| +func TestClaimStopCrashAfterBeginReusesDurableFenceID(t *testing.T) { |
| + c, req, _ := claimStopFixture(t) |
| + original := hostguard.FenceTarget{FenceID: "fence-before-crash", Host: req.Host, Incarnation: req.Incarnation, |
| + Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, |
| + Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, |
| + ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision} |
| + if _, err := c.Guard.BeginFence(t.Context(), original); err != nil { |
| + t.Fatal(err) |
| + } |
| + seen := "" |
| + c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { |
| + seen = fence |
| + return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil |
| + }) |
| + result, err := c.FenceAndStop(t.Context(), req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var receipt StopReceipt |
| + if err := json.Unmarshal(result.Bytes, &receipt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if seen != original.FenceID || receipt.FenceID != original.FenceID { |
| + t.Fatalf("retry used fence %q, receipt %q, want durable %q", seen, receipt.FenceID, original.FenceID) |
| + } |
| +} |
| + |
| +func TestClaimStopCompletedRetryPublishesOriginalEvidence(t *testing.T) { |
| + c, req, _ := claimStopFixture(t) |
| + drains := 0 |
| + c.Drainer = stopDrainFunc(func(_ context.Context, _ StopRequest, fence string) (DrainAcknowledgement, error) { |
| + drains++ |
| + return DrainAcknowledgement{FenceID: fence, ControllerID: req.ControllerID, ProcessStart: "start-42", Digest: strings.Repeat("a", 64)}, nil |
| + }) |
| + first, err := c.FenceAndStop(t.Context(), req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + second, err := c.FenceAndStop(t.Context(), req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if drains != 1 || !reflect.DeepEqual(first.Bytes, second.Bytes) || first.Key != second.Key || !first.EvidenceAt.Equal(second.EvidenceAt) { |
| + t.Fatalf("completed retry recollected evidence: drains=%d first=%+v second=%+v", drains, first, second) |
| + } |
| +} |
| + |
| +func TestClaimStopRefusesIncompleteProvidersAndEvidence(t *testing.T) { |
| + c, req, _ := claimStopFixture(t) |
| + c.Kernel = nil |
| + if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopUnavailable) { |
| + t.Fatalf("nil kernel = %v", err) |
| + } |
| + c, req, _ = claimStopFixture(t) |
| + c.Kernel = stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { |
| + return StopKernelEvidence{}, nil |
| + }) |
| + if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, ErrClaimStopReceipt) { |
| + t.Fatalf("incomplete evidence = %v", err) |
| + } |
| +} |
diff --git a/internal/hostbox/typedpark_linux.go b/internal/hostbox/typedpark_linux.go| new file mode 100644 |
| index 000000000..d545b3fa3 |
| --- /dev/null |
| +++ b/internal/hostbox/typedpark_linux.go |
| @@ -0,0 +1,200 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +var ErrTypedParkUnsupported = errors.New("hostbox: typed park unsupported") |
| + |
| +// TypedParker resolves inventory root IDs only from trusted configuration. |
| +// ParkedRoot is the provisioned filesystem root containing .parked. |
| +type TypedParker struct { |
| + Roots map[string]string |
| + ParkedRoots map[string]string |
| + ParkedRoot, Incarnation, Host, Retirement string |
| + syncParents func(int, int) error |
| +} |
| + |
| +func (p TypedParker) validate() error { |
| + if (p.ParkedRoot == "" && len(p.ParkedRoots) == 0) || !component(p.Incarnation) || !component(p.Host) || !component(p.Retirement) || len(p.Roots) == 0 { |
| + return ErrTypedInventory |
| + } |
| + return nil |
| +} |
| + |
| +// CaptureArtifact records the no-follow inode identity used at retirement. |
| +func (p TypedParker) CaptureArtifact(rootID, name, owner string, kind ArtifactKind) (Artifact, error) { |
| + if err := p.validate(); err != nil { |
| + return Artifact{}, err |
| + } |
| + rootPath, ok := p.Roots[rootID] |
| + if !ok || !managedName(name) || owner == "" { |
| + return Artifact{}, ErrTypedInventory |
| + } |
| + fd, err := openParent(rootPath, name, false) |
| + if err != nil { |
| + return Artifact{}, err |
| + } |
| + defer unix.Close(fd.dir) |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(fd.dir, fd.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return Artifact{}, err |
| + } |
| + return Artifact{Kind: kind, Root: rootID, Name: name, Owner: owner, Dev: uint64(st.Dev), Ino: st.Ino, Type: st.Mode & unix.S_IFMT}, nil |
| +} |
| + |
| +func (p TypedParker) Park(ctx context.Context, id string, a Artifact) error { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if err := p.validate(); err != nil { |
| + return err |
| + } |
| + if id == "" || !managedName(a.Name) || a.Dev == 0 || a.Ino == 0 || a.Type == 0 { |
| + return ErrTypedInventory |
| + } |
| + srcRoot, ok := p.Roots[a.Root] |
| + if !ok { |
| + return ErrTypedInventory |
| + } |
| + src, err := openParent(srcRoot, a.Name, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(src.dir) |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(src.dir, src.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + if errors.Is(err, unix.ENOENT) { |
| + return p.verifyDestination(a, src.dir) |
| + } |
| + return err |
| + } |
| + if !sameArtifact(a, &st) { |
| + return ErrChanged |
| + } |
| + dstRel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name) |
| + parkedRoot := p.ParkedRoots[a.Root] |
| + if parkedRoot == "" { |
| + parkedRoot = p.ParkedRoot |
| + } |
| + if parkedRoot == "" { |
| + return ErrTypedInventory |
| + } |
| + dst, err := openParent(parkedRoot, dstRel, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(dst.dir) |
| + var srcFS, dstFS unix.Stat_t |
| + if err := unix.Fstat(src.dir, &srcFS); err != nil { |
| + return err |
| + } |
| + if err := unix.Fstat(dst.dir, &dstFS); err != nil { |
| + return err |
| + } |
| + if srcFS.Dev != dstFS.Dev { |
| + return unix.EXDEV |
| + } |
| + if err := unix.Renameat2(src.dir, src.base, dst.dir, dst.base, unix.RENAME_NOREPLACE); err != nil { |
| + if errors.Is(err, unix.EEXIST) { |
| + return ErrChanged |
| + } |
| + return err |
| + } |
| + return p.sync(src.dir, dst.dir) |
| +} |
| + |
| +func (p TypedParker) verifyDestination(a Artifact, srcParent int) error { |
| + rel := filepath.Join(".parked", p.Incarnation, p.Host, p.Retirement, a.Root, a.Name) |
| + parkedRoot := p.ParkedRoots[a.Root] |
| + if parkedRoot == "" { |
| + parkedRoot = p.ParkedRoot |
| + } |
| + if parkedRoot == "" { |
| + return ErrTypedInventory |
| + } |
| + fd, err := openParent(parkedRoot, rel, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(fd.dir) |
| + var st unix.Stat_t |
| + if err := unix.Fstatat(fd.dir, fd.base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { |
| + return err |
| + } |
| + if !sameArtifact(a, &st) { |
| + return ErrChanged |
| + } |
| + return p.sync(srcParent, fd.dir) |
| +} |
| + |
| +func (p TypedParker) sync(a, b int) error { |
| + if p.syncParents != nil { |
| + return p.syncParents(a, b) |
| + } |
| + return syncFDs(a, b) |
| +} |
| + |
| +type parentFD struct { |
| + dir int |
| + base string |
| +} |
| + |
| +func openParent(rootPath, name string, create bool) (parentFD, error) { |
| + root, err := unix.Open(rootPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + if err != nil { |
| + return parentFD{}, err |
| + } |
| + parts := strings.Split(filepath.Clean(name), string(filepath.Separator)) |
| + cur := root |
| + for _, part := range parts[:len(parts)-1] { |
| + next, er := unix.Openat(cur, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + if er != nil && create && errors.Is(er, unix.ENOENT) { |
| + if er = unix.Mkdirat(cur, part, 0o700); er == nil || errors.Is(er, unix.EEXIST) { |
| + if er == nil { |
| + er = unix.Fsync(cur) |
| + } |
| + if er != nil { |
| + if cur != root { |
| + unix.Close(cur) |
| + } |
| + unix.Close(root) |
| + return parentFD{}, er |
| + } |
| + next, er = unix.Openat(cur, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) |
| + } |
| + } |
| + if cur != root { |
| + unix.Close(cur) |
| + } |
| + if er != nil { |
| + unix.Close(root) |
| + return parentFD{}, er |
| + } |
| + cur = next |
| + } |
| + if cur != root { |
| + unix.Close(root) |
| + } |
| + return parentFD{cur, parts[len(parts)-1]}, nil |
| +} |
| +func sameArtifact(a Artifact, st *unix.Stat_t) bool { |
| + return a.Dev == uint64(st.Dev) && a.Ino == st.Ino && a.Type == st.Mode&unix.S_IFMT |
| +} |
| +func syncFDs(a, b int) error { |
| + if err := unix.Fsync(a); err != nil { |
| + return fmt.Errorf("sync source: %w", err) |
| + } |
| + if err := unix.Fsync(b); err != nil { |
| + return fmt.Errorf("sync destination: %w", err) |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostbox/typedpark_linux_test.go b/internal/hostbox/typedpark_linux_test.go| new file mode 100644 |
| index 000000000..a40c1a110 |
| --- /dev/null |
| +++ b/internal/hostbox/typedpark_linux_test.go |
| @@ -0,0 +1,139 @@ |
| +//go:build linux |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestTypedParkerMovesExactFileAndSymlinkWithoutFollowing(t *testing.T) { |
| + root := t.TempDir() |
| + data := filepath.Join(root, "data") |
| + if err := os.Mkdir(data, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(data, "db"), []byte("db"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink("db", filepath.Join(data, "live")); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"} |
| + file, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := p.CaptureArtifact("data", "live", "owner", ArtifactLiveLink) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if file.Ino == link.Ino || file.Type == link.Type { |
| + t.Fatal("capture followed symlink") |
| + } |
| + if err := p.Park(context.Background(), "ignored", file); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Park(context.Background(), "ignored", link); err != nil { |
| + t.Fatal(err) |
| + } |
| + parked := filepath.Join(root, ".parked", "inc", "shop.example", "ret", "data") |
| + if got, err := os.Readlink(filepath.Join(parked, "live")); err != nil || got != "db" { |
| + t.Fatalf("parked symlink=%q err=%v", got, err) |
| + } |
| + // Missing source replay accepts only the exact recorded destination inode. |
| + if err := p.Park(context.Background(), "ignored", file); err != nil { |
| + t.Fatalf("idempotent replay: %v", err) |
| + } |
| +} |
| + |
| +func TestTypedParkerRefusesSuccessorAndSymlinkIntermediate(t *testing.T) { |
| + root := t.TempDir() |
| + data := filepath.Join(root, "data") |
| + other := filepath.Join(root, "other") |
| + os.Mkdir(data, 0o700) |
| + os.Mkdir(other, 0o700) |
| + if err := os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"} |
| + a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + held, err := os.Open(filepath.Join(data, "db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer held.Close() |
| + if err := os.Remove(filepath.Join(data, "db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(data, "db"), []byte("successor"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Park(context.Background(), "ignored", a); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("successor error=%v", err) |
| + } |
| + if err := os.Symlink(other, filepath.Join(data, "escape")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := p.CaptureArtifact("data", "escape/file", "owner", ArtifactPath); err == nil { |
| + t.Fatal("symlink intermediate accepted") |
| + } |
| +} |
| + |
| +func TestTypedParkerNoReplacePreservesExistingDestination(t *testing.T) { |
| + root := t.TempDir() |
| + data := filepath.Join(root, "data") |
| + dest := filepath.Join(root, ".parked", "inc", "shop.example", "ret", "data") |
| + os.Mkdir(data, 0o700) |
| + os.MkdirAll(dest, 0o700) |
| + os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600) |
| + os.WriteFile(filepath.Join(dest, "db"), []byte("occupied"), 0o600) |
| + p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret"} |
| + a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Park(context.Background(), "ignored", a); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("error=%v", err) |
| + } |
| + if string(mustReadTyped(t, filepath.Join(data, "db"))) != "old" || string(mustReadTyped(t, filepath.Join(dest, "db"))) != "occupied" { |
| + t.Fatal("no-replace modified source or destination") |
| + } |
| +} |
| + |
| +func TestTypedParkerRetryAfterRenameFsyncFailureSyncsBothParents(t *testing.T) { |
| + root := t.TempDir() |
| + data := filepath.Join(root, "data") |
| + os.Mkdir(data, 0o700) |
| + os.WriteFile(filepath.Join(data, "db"), []byte("old"), 0o600) |
| + calls := 0 |
| + p := TypedParker{Roots: map[string]string{"data": data}, ParkedRoot: root, Incarnation: "inc", Host: "shop.example", Retirement: "ret", syncParents: func(a, b int) error { |
| + calls++ |
| + if calls == 1 { |
| + return errors.New("injected fsync failure") |
| + } |
| + return syncFDs(a, b) |
| + }} |
| + a, err := p.CaptureArtifact("data", "db", "owner", ArtifactPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Park(context.Background(), "ignored", a); err == nil { |
| + t.Fatal("first park did not report fsync failure") |
| + } |
| + if _, err := os.Stat(filepath.Join(data, "db")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("rename did not happen: %v", err) |
| + } |
| + if err := p.Park(context.Background(), "ignored", a); err != nil { |
| + t.Fatalf("retry: %v", err) |
| + } |
| + if calls != 2 { |
| + t.Fatalf("sync calls=%d, want initial and replay", calls) |
| + } |
| +} |
diff --git a/internal/hostbox/typedpark_other.go b/internal/hostbox/typedpark_other.go| new file mode 100644 |
| index 000000000..635032cd3 |
| --- /dev/null |
| +++ b/internal/hostbox/typedpark_other.go |
| @@ -0,0 +1,18 @@ |
| +//go:build !linux |
| + |
| +package hostbox |
| + |
| +import "context" |
| + |
| +var ErrTypedParkUnsupported = ErrUnsupported |
| + |
| +type TypedParker struct { |
| + Roots map[string]string |
| + ParkedRoots map[string]string |
| + ParkedRoot, Incarnation, Host, Retirement string |
| +} |
| + |
| +func (TypedParker) CaptureArtifact(string, string, string, ArtifactKind) (Artifact, error) { |
| + return Artifact{}, ErrTypedParkUnsupported |
| +} |
| +func (TypedParker) Park(context.Context, string, Artifact) error { return ErrTypedParkUnsupported } |
diff --git a/internal/hostbox/typedretire.go b/internal/hostbox/typedretire.go| new file mode 100644 |
| index 000000000..ce0b3b199 |
| --- /dev/null |
| +++ b/internal/hostbox/typedretire.go |
| @@ -0,0 +1,293 @@ |
| +package hostbox |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +var ( |
| + ErrTypedInventory = errors.New("hostbox: invalid typed retirement inventory") |
| + ErrTypedJournal = errors.New("hostbox: invalid typed retirement journal") |
| +) |
| + |
| +type ArtifactKind string |
| + |
| +const ( |
| + ArtifactPath ArtifactKind = "path" |
| + ArtifactLiveLink ArtifactKind = "live-link" |
| + ArtifactUnitState ArtifactKind = "unit-state" |
| + ArtifactRoute ArtifactKind = "route" |
| + ArtifactTimer ArtifactKind = "timer" |
| +) |
| + |
| +// Artifact binds a managed name to the installation identity which created it. |
| +// Root is a configured root identifier, never an arbitrary absolute pathname. |
| +type Artifact struct { |
| + Kind ArtifactKind `json:"kind"` |
| + Root string `json:"root"` |
| + Name string `json:"name"` |
| + Owner string `json:"owner"` |
| + RowID string `json:"row_id,omitempty"` |
| + Dev uint64 `json:"dev,omitempty"` |
| + Ino uint64 `json:"ino,omitempty"` |
| + Type uint32 `json:"type,omitempty"` |
| +} |
| + |
| +type RetirementInventory struct { |
| + Host string `json:"host"` |
| + Captured hostguard.Captured `json:"captured"` |
| + Ownership string `json:"ownership"` |
| + Artifacts []Artifact `json:"artifacts"` |
| +} |
| + |
| +type RetirementStep struct { |
| + Artifact Artifact `json:"artifact"` |
| + Done bool `json:"done"` |
| +} |
| +type RetirementJournal struct { |
| + V int `json:"v"` |
| + ID string `json:"id"` |
| + Inventory RetirementInventory `json:"inventory"` |
| + Steps []RetirementStep `json:"steps"` |
| + Complete bool `json:"complete"` |
| +} |
| + |
| +type FilesystemParker interface { |
| + Park(context.Context, string, Artifact) error |
| +} |
| +type ConditionalRows interface { |
| + RetireRows(context.Context, string, []Artifact) error |
| +} |
| +type RetirementAuthority interface { |
| + WithCleanup(context.Context, hostguard.FenceTarget, func() error) error |
| +} |
| + |
| +type TypedRetirement struct { |
| + JournalDir string |
| + Authority RetirementAuthority |
| + Filesystem FilesystemParker |
| + Rows ConditionalRows |
| + Ack func(context.Context, RetirementJournal) error |
| +} |
| + |
| +func component(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| +} |
| +func managedName(s string) bool { |
| + if s == "" || filepath.IsAbs(s) || filepath.Clean(s) != s || strings.ContainsAny(s, "\\\x00") { |
| + return false |
| + } |
| + for _, p := range strings.Split(s, "/") { |
| + if !component(p) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func validateInventory(in RetirementInventory) error { |
| + if !component(in.Host) || in.Captured.Incarnation == "" || in.Captured.Stamp == 0 || !component(in.Ownership) || len(in.Artifacts) == 0 { |
| + return ErrTypedInventory |
| + } |
| + seen := map[string]bool{} |
| + for _, a := range in.Artifacts { |
| + if !component(a.Root) || !managedName(a.Name) || a.Owner != in.Ownership { |
| + return ErrTypedInventory |
| + } |
| + switch a.Kind { |
| + case ArtifactPath, ArtifactLiveLink, ArtifactUnitState: |
| + if a.RowID != "" || a.Dev == 0 || a.Ino == 0 || a.Type == 0 { |
| + return ErrTypedInventory |
| + } |
| + case ArtifactRoute, ArtifactTimer: |
| + if !component(a.RowID) || a.Dev != 0 || a.Ino != 0 || a.Type != 0 { |
| + return ErrTypedInventory |
| + } |
| + default: |
| + return ErrTypedInventory |
| + } |
| + key := string(a.Kind) + "\x00" + a.Root + "\x00" + a.Name + "\x00" + a.RowID |
| + if seen[key] { |
| + return ErrTypedInventory |
| + } |
| + seen[key] = true |
| + } |
| + return nil |
| +} |
| + |
| +func NewRetirementJournal(id string, in RetirementInventory) (RetirementJournal, error) { |
| + if !component(id) || validateInventory(in) != nil { |
| + return RetirementJournal{}, ErrTypedInventory |
| + } |
| + a := append([]Artifact(nil), in.Artifacts...) |
| + sort.Slice(a, func(i, j int) bool { return fmt.Sprint(a[i]) < fmt.Sprint(a[j]) }) |
| + in.Artifacts = a |
| + j := RetirementJournal{V: 1, ID: id, Inventory: in, Steps: make([]RetirementStep, len(a))} |
| + for i := range a { |
| + j.Steps[i].Artifact = a[i] |
| + } |
| + return j, nil |
| +} |
| + |
| +func (r TypedRetirement) Run(ctx context.Context, target hostguard.FenceTarget, intent RetirementJournal) error { |
| + if ctx == nil || r.JournalDir == "" || r.Authority == nil || r.Filesystem == nil || r.Rows == nil || r.Ack == nil || intent.V != 1 || !component(intent.ID) || validateInventory(intent.Inventory) != nil || target.Host != intent.Inventory.Host || target.Incarnation != intent.Inventory.Captured.Incarnation || target.Stamp != intent.Inventory.Captured.Stamp { |
| + return ErrTypedInventory |
| + } |
| + canonical, err := NewRetirementJournal(intent.ID, intent.Inventory) |
| + if err != nil { |
| + return err |
| + } |
| + // Callers provide immutable intent only. Progress is trusted exclusively |
| + // from the durable journal this controller wrote itself. |
| + if intent.Complete || len(intent.Steps) != len(canonical.Steps) { |
| + return ErrTypedJournal |
| + } |
| + for i := range intent.Steps { |
| + if intent.Steps[i].Done || intent.Steps[i].Artifact != canonical.Steps[i].Artifact { |
| + return ErrTypedJournal |
| + } |
| + } |
| + intent = canonical |
| + path := filepath.Join(r.JournalDir, intent.ID+".json") |
| + j, err := r.loadOrCreate(path, intent) |
| + if err != nil { |
| + return err |
| + } |
| + if !j.Complete { |
| + err = r.Authority.WithCleanup(ctx, target, func() error { |
| + var rows []Artifact |
| + for i := range j.Steps { |
| + if j.Steps[i].Done { |
| + continue |
| + } |
| + a := j.Steps[i].Artifact |
| + switch a.Kind { |
| + case ArtifactRoute, ArtifactTimer: |
| + rows = append(rows, a) |
| + default: |
| + if err := r.Filesystem.Park(ctx, j.ID, a); err != nil { |
| + return err |
| + } |
| + j.Steps[i].Done = true |
| + if err := writeJournal(path, j); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + if len(rows) > 0 { |
| + if err := r.Rows.RetireRows(ctx, j.Inventory.Ownership, rows); err != nil { |
| + return err |
| + } |
| + for i := range j.Steps { |
| + if j.Steps[i].Artifact.Kind == ArtifactRoute || j.Steps[i].Artifact.Kind == ArtifactTimer { |
| + j.Steps[i].Done = true |
| + } |
| + } |
| + if err := writeJournal(path, j); err != nil { |
| + return err |
| + } |
| + } |
| + for _, s := range j.Steps { |
| + if !s.Done { |
| + return ErrTypedJournal |
| + } |
| + } |
| + j.Complete = true |
| + return writeJournal(path, j) |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + } |
| + // Acknowledgement is deliberately after the complete journal and outside |
| + // the cleanup guard. A crash here replays only this idempotent call. |
| + return r.Ack(ctx, j) |
| +} |
| + |
| +func (r TypedRetirement) loadOrCreate(path string, want RetirementJournal) (RetirementJournal, error) { |
| + b, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + if err := os.MkdirAll(r.JournalDir, 0o700); err != nil { |
| + return RetirementJournal{}, err |
| + } |
| + if err := writeJournal(path, want); err != nil { |
| + return RetirementJournal{}, err |
| + } |
| + return want, nil |
| + } |
| + if err != nil { |
| + return RetirementJournal{}, err |
| + } |
| + var got RetirementJournal |
| + if err := decodeStrict(b, &got); err != nil { |
| + return RetirementJournal{}, ErrTypedJournal |
| + } |
| + a, _ := json.Marshal(want.Inventory) |
| + c, _ := json.Marshal(got.Inventory) |
| + if got.V != 1 || got.ID != want.ID || !bytes.Equal(a, c) || len(got.Steps) != len(want.Steps) { |
| + return RetirementJournal{}, ErrTypedJournal |
| + } |
| + allDone := true |
| + for i := range got.Steps { |
| + if got.Steps[i].Artifact != want.Steps[i].Artifact { |
| + return RetirementJournal{}, ErrTypedJournal |
| + } |
| + allDone = allDone && got.Steps[i].Done |
| + } |
| + if got.Complete && !allDone { |
| + return RetirementJournal{}, ErrTypedJournal |
| + } |
| + return got, nil |
| +} |
| + |
| +func decodeStrict(b []byte, v any) error { |
| + d := json.NewDecoder(bytes.NewReader(b)) |
| + d.DisallowUnknownFields() |
| + if err := d.Decode(v); err != nil { |
| + return err |
| + } |
| + if err := d.Decode(new(any)); err != io.EOF { |
| + return ErrTypedJournal |
| + } |
| + return nil |
| +} |
| +func writeJournal(path string, j RetirementJournal) error { |
| + b, err := json.Marshal(j) |
| + if err != nil { |
| + return err |
| + } |
| + tmp, err := os.CreateTemp(filepath.Dir(path), ".retire-*") |
| + if err != nil { |
| + return err |
| + } |
| + name := tmp.Name() |
| + defer os.Remove(name) |
| + if _, err = tmp.Write(b); err == nil { |
| + err = tmp.Sync() |
| + } |
| + if ce := tmp.Close(); err == nil { |
| + err = ce |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if err = os.Rename(name, path); err != nil { |
| + return err |
| + } |
| + d, err := os.Open(filepath.Dir(path)) |
| + if err != nil { |
| + return err |
| + } |
| + defer d.Close() |
| + return d.Sync() |
| +} |
diff --git a/internal/hostbox/typedretire_test.go b/internal/hostbox/typedretire_test.go| new file mode 100644 |
| index 000000000..2ca4253a5 |
| --- /dev/null |
| +++ b/internal/hostbox/typedretire_test.go |
| @@ -0,0 +1,173 @@ |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +) |
| + |
| +type cleanupAuthority struct{ calls int } |
| + |
| +func (a *cleanupAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, fn func() error) error { |
| + a.calls++ |
| + return fn() |
| +} |
| + |
| +// rootParker uses descriptor-relative operations and link-before-remove. Link |
| +// is the no-replace claim: an existing destination refuses without touching |
| +// the source. Retirement directories are provisioned before the run. |
| +type rootParker struct { |
| + root *os.Root |
| + calls int |
| + crashAfter int |
| +} |
| + |
| +func (p *rootParker) Park(_ context.Context, id string, a Artifact) error { |
| + p.calls++ |
| + dst := filepath.Join("parked", id, a.Root, a.Name) |
| + src := filepath.Join(a.Root, a.Name) |
| + if err := p.root.Link(src, dst); err != nil { |
| + srcInfo, srcErr := p.root.Lstat(src) |
| + dstInfo, dstErr := p.root.Lstat(dst) |
| + if srcErr != nil || dstErr != nil || !os.SameFile(srcInfo, dstInfo) { |
| + return err |
| + } |
| + } |
| + if p.crashAfter == p.calls { |
| + return errors.New("crash after durable destination") |
| + } |
| + return p.root.Remove(src) |
| +} |
| + |
| +type rowRetirer struct { |
| + calls int |
| + rows map[string]bool |
| +} |
| + |
| +func (r *rowRetirer) RetireRows(_ context.Context, owner string, a []Artifact) error { |
| + r.calls++ |
| + for _, x := range a { |
| + key := string(x.Kind) + ":" + x.RowID |
| + if r.rows[key] != true { |
| + return errors.New("conditional row identity changed") |
| + } |
| + delete(r.rows, key) |
| + } |
| + return nil |
| +} |
| + |
| +func TestTypedRetirementJournalsBeforeEffectsAndRetriesWithoutTouchingSuccessor(t *testing.T) { |
| + rootPath := t.TempDir() |
| + mustMkdir(t, filepath.Join(rootPath, "data")) |
| + mustMkdir(t, filepath.Join(rootPath, "live")) |
| + mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "data")) |
| + mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "live")) |
| + mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "old-db") |
| + mustWrite(t, filepath.Join(rootPath, "live", "app"), "old-link") |
| + root, err := os.OpenRoot(rootPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer root.Close() |
| + p := &rootParker{root: root, crashAfter: 1} |
| + rows := &rowRetirer{rows: map[string]bool{"route:route-7": true, "timer:timer-9": true}} |
| + auth := &cleanupAuthority{} |
| + acks := 0 |
| + in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 7}, Ownership: "install-7", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "app.db", Owner: "install-7", Dev: 1, Ino: 1, Type: 1}, {Kind: ArtifactLiveLink, Root: "live", Name: "app", Owner: "install-7", Dev: 1, Ino: 2, Type: 1}, {Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "install-7", RowID: "route-7"}, {Kind: ArtifactTimer, Root: "registry", Name: "shop.example/daily", Owner: "install-7", RowID: "timer-9"}}} |
| + j, err := NewRetirementJournal("ret-1", in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + target := hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 7} |
| + r := TypedRetirement{JournalDir: filepath.Join(rootPath, "journals"), Authority: auth, Filesystem: p, Rows: rows, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }} |
| + if err := r.Run(context.Background(), target, j); err == nil { |
| + t.Fatal("first run survived injected crash") |
| + } |
| + if _, err := os.Stat(filepath.Join(rootPath, "journals", "ret-1.json")); err != nil { |
| + t.Fatalf("intent was not durable before effect: %v", err) |
| + } |
| + // Retry sees the no-replace destination. The parker accepts it only when it |
| + // is the same inode, removes the old source name, then continues the journal. |
| + p.crashAfter = 0 |
| + if err := r.Run(context.Background(), target, j); err != nil { |
| + t.Fatal(err) |
| + } |
| + if acks != 1 || rows.calls != 1 { |
| + t.Fatalf("acks=%d row txns=%d", acks, rows.calls) |
| + } |
| + // A successor at the old path appears after completion. Ack retry performs |
| + // no filesystem or SQL effect and therefore preserves it. |
| + mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "successor") |
| + calls := p.calls |
| + if err := r.Run(context.Background(), target, j); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.calls != calls || string(mustReadTyped(t, filepath.Join(rootPath, "data", "app.db"))) != "successor" { |
| + t.Fatal("completed retry touched successor") |
| + } |
| +} |
| + |
| +func TestTypedRetirementRefusesIncompleteOrUntypedInventory(t *testing.T) { |
| + bad := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "own"}}} |
| + if _, err := NewRetirementJournal("r", bad); !errors.Is(err, ErrTypedInventory) { |
| + t.Fatalf("error=%v", err) |
| + } |
| +} |
| + |
| +func TestTypedRetirementRejectsForgedCallerProgress(t *testing.T) { |
| + dir := t.TempDir() |
| + in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "db", Owner: "own", Dev: 1, Ino: 2, Type: 3}}} |
| + canonical, err := NewRetirementJournal("ret", in) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + acks := 0 |
| + r := TypedRetirement{JournalDir: dir, Authority: &cleanupAuthority{}, Filesystem: &rootParker{}, Rows: &rowRetirer{}, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }} |
| + for name, forged := range map[string]RetirementJournal{"complete": canonical, "empty-steps": canonical, "done": canonical} { |
| + switch name { |
| + case "complete": |
| + forged.Complete = true |
| + for i := range forged.Steps { |
| + forged.Steps[i].Done = true |
| + } |
| + case "empty-steps": |
| + forged.Steps = nil |
| + case "done": |
| + forged.Steps[0].Done = true |
| + } |
| + if err := r.Run(context.Background(), hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 1}, forged); !errors.Is(err, ErrTypedJournal) { |
| + t.Fatalf("%s error=%v", name, err) |
| + } |
| + } |
| + if acks != 0 { |
| + t.Fatalf("forged progress acked %d times", acks) |
| + } |
| + if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 { |
| + t.Fatalf("forgery poisoned journal dir: entries=%v err=%v", entries, err) |
| + } |
| +} |
| + |
| +func mustMkdir(t *testing.T, p string) { |
| + t.Helper() |
| + if err := os.MkdirAll(p, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func mustWrite(t *testing.T, p, s string) { |
| + t.Helper() |
| + if err := os.WriteFile(p, []byte(s), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func mustReadTyped(t *testing.T, p string) []byte { |
| + t.Helper() |
| + b, e := os.ReadFile(p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return b |
| +} |
diff --git a/internal/hostguard/fence.go b/internal/hostguard/fence.go| new file mode 100644 |
| index 000000000..bf6752107 |
| --- /dev/null |
| +++ b/internal/hostguard/fence.go |
| @@ -0,0 +1,245 @@ |
| +package hostguard |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +var ( |
| + ErrFenceActive = errors.New("hostguard: another fence is active") |
| + ErrFenceTarget = errors.New("hostguard: fence target does not match authority") |
| + ErrNoFenceReceipt = errors.New("hostguard: no fence receipt for target") |
| + ErrReceiptChanged = errors.New("hostguard: fence receipt bytes changed") |
| +) |
| + |
| +// FenceTarget contains authority identities only. Filesystem paths, PIDs and |
| +// unit names are discovered and authenticated by the trusted root verifier. |
| +type FenceTarget struct { |
| + FenceID string `json:"fence_id"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Stamp uint64 `json:"stamp"` |
| + Session uint64 `json:"session"` |
| + Op string `json:"op,omitempty"` |
| + Phase string `json:"phase,omitempty"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + BootID string `json:"boot_id"` |
| + ControllerID string `json:"controller_id"` |
| + ClaimETag string `json:"claim_etag"` |
| + DirectoryRevision string `json:"directory_revision"` |
| +} |
| + |
| +func (t FenceTarget) validate(host string) error { |
| + if validateFileHost(host) != nil || t.FenceID == "" || t.Host != host || t.Incarnation == "" || t.Stamp == 0 || t.Session == 0 || |
| + t.Box == "" || t.Enrollment == "" || t.BootID == "" || t.ControllerID == "" || t.ClaimETag == "" || t.DirectoryRevision == "" { |
| + return ErrMalformedRecord |
| + } |
| + return nil |
| +} |
| + |
| +func (t FenceTarget) sameAuthority(other FenceTarget) bool { |
| + t.FenceID, other.FenceID = "", "" |
| + // The fresh request has already revalidated this session and operation. |
| + // A bookkeeping CAS must not replace the persisted fence or its evidence. |
| + t.ClaimETag, other.ClaimETag = "", "" |
| + return t == other |
| +} |
| + |
| +func (t FenceTarget) matchesSession(c *hostclaim.Claim, captured Captured) bool { |
| + return c != nil && c.Holder != nil && t.Host == c.Host && t.Incarnation == captured.Incarnation && |
| + t.Incarnation == c.Holder.Incarnation && t.Stamp == captured.Stamp && t.Stamp == c.Holder.Stamp && |
| + t.Session == c.Holder.Session |
| +} |
| + |
| +func (t FenceTarget) matchesAuthority(c *hostclaim.Claim, captured Captured) bool { |
| + return t.matchesSession(c, captured) && t.Op == c.Op.ID && t.Phase == c.Op.Phase |
| +} |
| + |
| +type FenceReceipt struct { |
| + Target FenceTarget `json:"target"` |
| + Bytes []byte `json:"bytes"` |
| + EvidenceAt time.Time `json:"evidence_at"` |
| +} |
| + |
| +func (r FenceReceipt) validate(host string) error { |
| + if err := r.Target.validate(host); err != nil || len(r.Bytes) == 0 || r.EvidenceAt.IsZero() { |
| + return ErrMalformedRecord |
| + } |
| + return nil |
| +} |
| + |
| +// BeginFence checks fresh claim authority while holding the host lock, then |
| +// durably installs the deny before any privileged stop work begins. |
| +func (g Guard) BeginFence(ctx context.Context, target FenceTarget) (FenceTarget, error) { |
| + var authoritative FenceTarget |
| + err := g.locked(ctx, target.Host, func(d *recordDir) error { |
| + if err := target.validate(target.Host); err != nil { |
| + return ErrInvalidInput |
| + } |
| + obs, err := g.freshObserved(ctx, target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + c := obs.Claim |
| + if obs.ETag != target.ClaimETag || !target.matchesAuthority(c, Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) || c.Op.Exec != target.Box { |
| + return ErrFenceTarget |
| + } |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Incarnation != target.Incarnation || r.ActiveStamp != target.Stamp { |
| + return ErrFenceTarget |
| + } |
| + if r.ActiveFence != nil { |
| + if *r.ActiveFence == target { |
| + authoritative = *r.ActiveFence |
| + return d.sync(target.Host) |
| + } |
| + if r.ActiveFence.sameAuthority(target) { |
| + authoritative = *r.ActiveFence |
| + return d.sync(target.Host) |
| + } |
| + // A completed predecessor remains independently cleanable from its |
| + // immutable journal entry. An unsettled predecessor keeps the deny |
| + // slot and cannot be overwritten by a successor request. |
| + completed := false |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target == *r.ActiveFence { |
| + completed = true |
| + break |
| + } |
| + } |
| + if !completed { |
| + return ErrFenceActive |
| + } |
| + } |
| + r.ActiveFence = &target |
| + authoritative = target |
| + return d.write(r) |
| + }) |
| + return authoritative, err |
| +} |
| + |
| +// FenceReceiptFor returns the immutable completed evidence for exactly target. |
| +// It does not consult the claim and is safe for transport recovery. |
| +func (g Guard) FenceReceiptFor(ctx context.Context, target FenceTarget) (FenceReceipt, error) { |
| + var found FenceReceipt |
| + err := g.locked(ctx, target.Host, func(d *recordDir) error { |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target == target { |
| + found = receipt |
| + return nil |
| + } |
| + } |
| + return ErrNoFenceReceipt |
| + }) |
| + return found, err |
| +} |
| + |
| +type FenceVerify func(context.Context, FenceTarget, []byte, time.Time) error |
| + |
| +// CompleteFence verifies root evidence without holding the host lock, then |
| +// journals its exact bytes. Replaying the same receipt is idempotent. |
| +func (g Guard) CompleteFence(ctx context.Context, target FenceTarget, receipt []byte, evidenceAt time.Time, verify FenceVerify) error { |
| + if ctx == nil || verify == nil || len(receipt) == 0 || evidenceAt.IsZero() { |
| + return ErrInvalidInput |
| + } |
| + if err := verify(ctx, target, receipt, evidenceAt); err != nil { |
| + return err |
| + } |
| + return g.locked(ctx, target.Host, func(d *recordDir) error { |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + for _, old := range r.FenceJournal { |
| + if old.Target == target { |
| + if old.EvidenceAt.Equal(evidenceAt) && bytes.Equal(old.Bytes, receipt) { |
| + return d.sync(target.Host) |
| + } |
| + return ErrReceiptChanged |
| + } |
| + } |
| + if r.ActiveFence == nil || *r.ActiveFence != target { |
| + return ErrFenceTarget |
| + } |
| + r.FenceJournal = append(r.FenceJournal, FenceReceipt{Target: target, Bytes: append([]byte(nil), receipt...), EvidenceAt: evidenceAt}) |
| + return d.write(r) |
| + }) |
| +} |
| + |
| +type CleanupVerify func(context.Context, FenceReceipt) error |
| + |
| +// CleanupWith authorizes from the persisted fence journal, so claim loss, |
| +// control-store outage, a free claim, or a successor cannot widen its target. |
| +func (g Guard) CleanupWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error) error { |
| + return g.cleanupWith(ctx, target, verify, fn, false) |
| +} |
| + |
| +// CleanupRetireInstallationWith retires only an exact still-current target |
| +// before cleanup effects. A successor record is preserved byte-for-byte. |
| +func (g Guard) CleanupRetireInstallationWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error) error { |
| + return g.cleanupWith(ctx, target, verify, fn, true) |
| +} |
| + |
| +func (g Guard) cleanupWith(ctx context.Context, target FenceTarget, verify CleanupVerify, fn func() error, retire bool) error { |
| + if ctx == nil || verify == nil || fn == nil { |
| + return ErrInvalidInput |
| + } |
| + var receipt FenceReceipt |
| + err := g.locked(ctx, target.Host, func(d *recordDir) error { |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + for _, candidate := range r.FenceJournal { |
| + if candidate.Target == target { |
| + receipt = candidate |
| + return nil |
| + } |
| + } |
| + return ErrNoFenceReceipt |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if err := verify(ctx, receipt); err != nil { |
| + return err |
| + } |
| + return g.locked(ctx, target.Host, func(d *recordDir) error { |
| + r, err := d.read(target.Host) |
| + if err != nil { |
| + return err |
| + } |
| + found := false |
| + for _, candidate := range r.FenceJournal { |
| + if candidate.Target == target && candidate.EvidenceAt.Equal(receipt.EvidenceAt) && bytes.Equal(candidate.Bytes, receipt.Bytes) { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + return ErrNoFenceReceipt |
| + } |
| + if retire && r.Incarnation == target.Incarnation && r.ActiveStamp == target.Stamp { |
| + r.retire() |
| + if err := d.write(r); err != nil { |
| + return err |
| + } |
| + } |
| + if err := fn(); err != nil { |
| + return err |
| + } |
| + return d.sync(target.Host) |
| + }) |
| +} |
diff --git a/internal/hostguard/fence_test.go b/internal/hostguard/fence_test.go| new file mode 100644 |
| index 000000000..dc429ac64 |
| --- /dev/null |
| +++ b/internal/hostguard/fence_test.go |
| @@ -0,0 +1,229 @@ |
| +//go:build unix |
| + |
| +package hostguard |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +func fenceFixture(t *testing.T) (*guardFixture, FenceTarget, Captured) { |
| + t.Helper() |
| + f := newGuardFixture(t) |
| + f.obs.Claim.Holder.Session = 7 |
| + f.obs.Claim.Op.ID = "operation-1" |
| + f.obs.Claim.Op.Phase = "serving" |
| + if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + captured, err := Capture(f.g.DataDir, f.obs.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + target := FenceTarget{FenceID: "fence-1", Host: f.obs.Host, Incarnation: "inc", Stamp: 1, Session: 7, |
| + Op: "operation-1", Phase: "serving", Box: "uk", Enrollment: "enrollment-1", |
| + BootID: "boot-1", ControllerID: "controller-1", ClaimETag: "one", DirectoryRevision: "directory-9"} |
| + return f, target, captured |
| +} |
| + |
| +func TestBeginFenceDeniesOnlyExactCurrentAuthority(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + bad := target |
| + bad.ClaimETag = "stale" |
| + if _, err := f.g.BeginFence(t.Context(), bad); !errors.Is(err, ErrFenceTarget) { |
| + t.Fatalf("stale begin = %v", err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { // exact replay |
| + t.Fatal(err) |
| + } |
| + retry := target |
| + retry.FenceID = "new-id-must-not-replace-durable-id" |
| + got, err := f.g.BeginFence(t.Context(), retry) |
| + if err != nil || got != target { |
| + t.Fatalf("begin retry = %+v, %v; want original %+v", got, err, target) |
| + } |
| + called := false |
| + err = f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }) |
| + if !errors.Is(err, ErrFenced) || called { |
| + t.Fatalf("guard under fence = %v, called=%v", err, called) |
| + } |
| + f.obs.Claim.Op.Phase = "next-phase" |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) { |
| + t.Fatalf("phase change bypassed fence: %v", err) |
| + } |
| + |
| + // A later writer session is a different authority tuple. The old deny |
| + // remains durable for cleanup but cannot deny the successor session. |
| + f.obs.Claim.Holder.Session = 8 |
| + if err := f.g.With(t.Context(), target.Host, captured, allow, func() error { called = true; return nil }); err != nil || !called { |
| + t.Fatalf("new session guard = %v, called=%v", err, called) |
| + } |
| +} |
| + |
| +func TestBeginFenceCannotOverwriteUnsettledTarget(t *testing.T) { |
| + f, first, _ := fenceFixture(t) |
| + if _, err := f.g.BeginFence(t.Context(), first); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := first |
| + second.FenceID = "fence-2" |
| + second.Phase = "next-phase" |
| + second.ClaimETag = "two" |
| + second.DirectoryRevision = "directory-10" |
| + f.obs.ETag = "two" |
| + f.obs.Claim.Op.Phase = "next-phase" |
| + if _, err := f.g.BeginFence(t.Context(), second); !errors.Is(err, ErrFenceActive) { |
| + t.Fatalf("unsettled overwrite = %v", err) |
| + } |
| + at := time.Date(2026, 10, 6, 14, 30, 0, 0, time.UTC) |
| + if err := f.g.CompleteFence(t.Context(), first, []byte("first receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.g.BeginFence(t.Context(), second); err != nil { |
| + t.Fatalf("completed predecessor blocked successor fence: %v", err) |
| + } |
| + if err := f.g.CleanupWith(t.Context(), first, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := readRecord(f.g.DataDir, first.Host) |
| + if err != nil || r.ActiveFence == nil || *r.ActiveFence != second || len(r.FenceJournal) != 1 { |
| + t.Fatalf("predecessor cleanup damaged successor fence: %+v, %v", r, err) |
| + } |
| +} |
| + |
| +func TestCompleteFenceVerificationFailureAndImmutableReplay(t *testing.T) { |
| + f, target, captured := fenceFixture(t) |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + failure := errors.New("root rejected evidence") |
| + at := time.Date(2026, 10, 6, 14, 0, 0, 0, time.UTC) |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return failure }); !errors.Is(err, failure) { |
| + t.Fatalf("failed verification = %v", err) |
| + } |
| + r, err := readRecord(f.g.DataDir, target.Host) |
| + if err != nil || len(r.FenceJournal) != 0 || r.ActiveFence == nil { |
| + t.Fatalf("failed verification persisted evidence: %+v, %v", r, err) |
| + } |
| + verify := func(ctx context.Context, got FenceTarget, _ []byte, _ time.Time) error { |
| + // Verification is outside the flock: reacquiring it completes rather |
| + // than deadlocking. The installed deny is visible to the nested guard. |
| + return f.g.With(ctx, got.Host, captured, allow, func() error { return nil }) |
| + } |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); !errors.Is(err, ErrFenced) { |
| + t.Fatalf("nested verifier = %v", err) |
| + } |
| + verify = func(context.Context, FenceTarget, []byte, time.Time) error { return nil } |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("receipt"), at, verify); err != nil { |
| + t.Fatalf("exact replay = %v", err) |
| + } |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("changed"), at, verify); !errors.Is(err, ErrReceiptChanged) { |
| + t.Fatalf("changed replay = %v", err) |
| + } |
| +} |
| + |
| +func TestCleanupUsesJournalDuringControlOutage(t *testing.T) { |
| + f, target, _ := fenceFixture(t) |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Date(2026, 10, 6, 15, 0, 0, 0, time.UTC) |
| + if err := f.g.CompleteFence(t.Context(), target, []byte{0, 1, 2, 255}, at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.err = errors.New("control store unavailable") |
| + verified, cleaned := false, false |
| + err := f.g.CleanupWith(t.Context(), target, func(_ context.Context, receipt FenceReceipt) error { |
| + verified = string(receipt.Bytes) == string([]byte{0, 1, 2, 255}) && receipt.EvidenceAt.Equal(at) |
| + return nil |
| + }, func() error { cleaned = true; return nil }) |
| + if err != nil || !verified || !cleaned { |
| + t.Fatalf("cleanup = %v verified=%v cleaned=%v", err, verified, cleaned) |
| + } |
| + r, err := readRecord(f.g.DataDir, target.Host) |
| + if err != nil || r.ActiveFence == nil || len(r.FenceJournal) != 1 { |
| + t.Fatalf("cleaned record = %+v, %v", r, err) |
| + } |
| + f.err = nil |
| + called := false |
| + if err := f.g.With(t.Context(), target.Host, hostguardCaptured(target), allow, func() error { called = true; return nil }); !errors.Is(err, ErrFenced) || called { |
| + t.Fatalf("cleanup reopened fenced session: %v called=%v", err, called) |
| + } |
| +} |
| + |
| +func TestCleanupOldFencePreservesSuccessor(t *testing.T) { |
| + f, target, _ := fenceFixture(t) |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Date(2026, 10, 6, 16, 0, 0, 0, time.UTC) |
| + if err := f.g.CompleteFence(t.Context(), target, []byte("root receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.ETag = "two" |
| + f.obs.Claim.Holder.Incarnation = "successor" |
| + f.obs.Claim.Holder.Stamp = 2 |
| + f.obs.Claim.Holder.Session = 8 |
| + f.obs.Claim.Op = hostclaim.Op{Kind: hostclaim.KindServing, ID: "operation-2", Phase: "serving", Exec: "uk"} |
| + if err := f.g.Activate(t.Context(), target.Host, "uk", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.g.CleanupWith(t.Context(), target, func(context.Context, FenceReceipt) error { return nil }, func() error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := readRecord(f.g.DataDir, target.Host) |
| + if err != nil || r.Incarnation != "successor" || r.ActiveStamp != 2 || r.ActiveFence == nil || len(r.FenceJournal) != 1 { |
| + t.Fatalf("successor after cleanup = %+v, %v", r, err) |
| + } |
| +} |
| + |
| +func hostguardCaptured(target FenceTarget) Captured { |
| + return Captured{Incarnation: target.Incarnation, Stamp: target.Stamp} |
| +} |
| + |
| +func TestBeginFenceResumesAfterUnrelatedClaimCAS(t *testing.T) { |
| + f, target, _ := fenceFixture(t) |
| + if _, err := f.g.BeginFence(t.Context(), target); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs.ETag = "bookkeeping" |
| + if _, err := f.g.BeginFence(t.Context(), target); !errors.Is(err, ErrFenceTarget) { |
| + t.Fatalf("stale request: %v", err) |
| + } |
| + fresh := target |
| + fresh.ClaimETag = f.obs.ETag |
| + fresh.FenceID = "retry" |
| + got, err := f.g.BeginFence(t.Context(), fresh) |
| + if err != nil || got != target { |
| + t.Fatalf("resume=%+v %v", got, err) |
| + } |
| + at := time.Unix(1, 0).UTC() |
| + if err := f.g.CompleteFence(t.Context(), got, []byte("original receipt"), at, func(context.Context, FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + receipt, err := f.g.FenceReceiptFor(t.Context(), target) |
| + if err != nil || string(receipt.Bytes) != "original receipt" || receipt.Target != target { |
| + t.Fatalf("receipt=%+v %v", receipt, err) |
| + } |
| + for _, change := range []func(*FenceTarget){func(v *FenceTarget) { v.Session++ }, func(v *FenceTarget) { v.Op = "another" }, func(v *FenceTarget) { v.ControllerID = "another" }} { |
| + g, old, _ := fenceFixture(t) |
| + if _, err := g.g.BeginFence(t.Context(), old); err != nil { |
| + t.Fatal(err) |
| + } |
| + fresh := old |
| + change(&fresh) |
| + if _, err := g.g.BeginFence(t.Context(), fresh); err == nil { |
| + t.Fatal("changed authority resumed") |
| + } |
| + } |
| +} |
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go| index 09aac8f0b..04b4be015 100644 |
| --- a/internal/hostguard/guard.go |
| +++ b/internal/hostguard/guard.go |
| @@ -22,6 +22,7 @@ var ( |
| ErrNotParticipant = errors.New("hostguard: box is not authorized to activate") |
| ErrRetiredStamp = errors.New("hostguard: installation stamp retired") |
| ErrOldStamp = errors.New("hostguard: installation stamp predates local history") |
| + ErrFenced = errors.New("hostguard: execution denied by durable fence") |
| ) |
| type ClaimReader interface { |
| @@ -73,35 +74,43 @@ func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) erro |
| } |
| func (g Guard) fresh(ctx context.Context, host string) (*hostclaim.Claim, error) { |
| - if err := ctx.Err(); err != nil { |
| + obs, err := g.freshObserved(ctx, host) |
| + if err != nil { |
| return nil, err |
| } |
| + return obs.Claim, nil |
| +} |
| + |
| +func (g Guard) freshObserved(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + if err := ctx.Err(); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| if g.Claims == nil { |
| - return nil, ErrNoClaims |
| + return hostclaim.Observed{}, ErrNoClaims |
| } |
| obs, err := g.Claims.Read(ctx, host) |
| if err != nil { |
| - return nil, err |
| + return hostclaim.Observed{}, err |
| } |
| if err := ctx.Err(); err != nil { |
| - return nil, err |
| + return hostclaim.Observed{}, err |
| } |
| if obs.Host != host { |
| - return nil, ErrMalformedClaim |
| + return hostclaim.Observed{}, ErrMalformedClaim |
| } |
| if obs.Absent { |
| - return nil, ErrNoClaim |
| + return hostclaim.Observed{}, ErrNoClaim |
| } |
| if obs.Claim == nil || obs.Claim.Host != host { |
| - return nil, ErrMalformedClaim |
| + return hostclaim.Observed{}, ErrMalformedClaim |
| } |
| if obs.Claim.Holder == nil { |
| - return nil, ErrNoHolder |
| + return hostclaim.Observed{}, ErrNoHolder |
| } |
| if obs.Claim.Holder.Incarnation == "" { |
| - return nil, ErrMalformedClaim |
| + return hostclaim.Observed{}, ErrMalformedClaim |
| } |
| - return obs.Claim, nil |
| + return obs, nil |
| } |
| func (g Guard) With(ctx context.Context, host string, captured Captured, check func(*hostclaim.Claim) error, fn func() error) error { |
| @@ -132,6 +141,14 @@ func (g Guard) With(ctx context.Context, host string, captured Captured, check f |
| if captured.Stamp != r.ActiveStamp { |
| return ErrStampMismatch |
| } |
| + if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, captured) { |
| + return ErrFenced |
| + } |
| + for _, receipt := range r.FenceJournal { |
| + if receipt.Target.matchesSession(c, captured) { |
| + return ErrFenced |
| + } |
| + } |
| if fn == nil { |
| return ErrInvalidInput |
| } |
| @@ -170,8 +187,12 @@ func (g Guard) Activate(ctx context.Context, host, box string, participant func( |
| if err != nil && !errors.Is(err, ErrNoRecord) { |
| return err |
| } |
| - if errors.Is(err, ErrNoRecord) || r.Incarnation != c.Holder.Incarnation { |
| + if errors.Is(err, ErrNoRecord) { |
| r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}} |
| + } else if r.Incarnation != c.Holder.Incarnation { |
| + // Fence evidence belongs to the machine, not the successor holder. |
| + // Preserve it so delayed cleanup can prove and retire only its target. |
| + r = record{V: 1, Host: host, Incarnation: c.Holder.Incarnation, RetiredStamps: []uint64{}, ActiveFence: r.ActiveFence, FenceJournal: r.FenceJournal} |
| } else { |
| for _, s := range r.RetiredStamps { |
| if stamp == s { |
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go| index bee23a619..7de7237f8 100644 |
| --- a/internal/hostguard/record.go |
| +++ b/internal/hostguard/record.go |
| @@ -26,11 +26,13 @@ type Captured struct { |
| } |
| type record struct { |
| - V int `json:"v"` |
| - Host string `json:"host"` |
| - Incarnation string `json:"incarnation"` |
| - ActiveStamp uint64 `json:"active_stamp"` |
| - RetiredStamps []uint64 `json:"retired_stamps"` |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + ActiveStamp uint64 `json:"active_stamp"` |
| + RetiredStamps []uint64 `json:"retired_stamps"` |
| + ActiveFence *FenceTarget `json:"active_fence,omitempty"` |
| + FenceJournal []FenceReceipt `json:"fence_journal,omitempty"` |
| } |
| func (r record) validate(host string) error { |
| @@ -44,6 +46,19 @@ func (r record) validate(host string) error { |
| } |
| previous = s |
| } |
| + if r.ActiveFence != nil { |
| + if err := r.ActiveFence.validate(host); err != nil { |
| + return err |
| + } |
| + } |
| + for i := range r.FenceJournal { |
| + if err := r.FenceJournal[i].validate(host); err != nil { |
| + return err |
| + } |
| + if i > 0 && r.FenceJournal[i-1].Target == r.FenceJournal[i].Target { |
| + return ErrMalformedRecord |
| + } |
| + } |
| return nil |
| } |
diff --git a/internal/instancectl/peer_tuple_linux.go b/internal/instancectl/peer_tuple_linux.go| new file mode 100644 |
| index 000000000..7015e8e96 |
| --- /dev/null |
| +++ b/internal/instancectl/peer_tuple_linux.go |
| @@ -0,0 +1,64 @@ |
| +//go:build linux |
| + |
| +package instancectl |
| + |
| +import ( |
| + "fmt" |
| + "net" |
| + "os" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +type AuthenticatedPeer struct { |
| + UID uint32 |
| + PID int |
| + ProcessStart, BootID, ControllerID string |
| +} |
| + |
| +func procStart(pid int) (string, error) { |
| + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) |
| + if err != nil { |
| + return "", err |
| + } |
| + i := strings.LastIndexByte(string(b), ')') |
| + if i < 0 { |
| + return "", fmt.Errorf("malformed proc stat") |
| + } |
| + f := strings.Fields(string(b[i+1:])) |
| + if len(f) <= 19 { |
| + return "", fmt.Errorf("malformed proc stat") |
| + } |
| + if _, err := strconv.ParseUint(f[19], 10, 64); err != nil { |
| + return "", err |
| + } |
| + return f[19], nil |
| +} |
| + |
| +// AuthenticatePeerTuple binds the accepted Unix peer to kernel identity and |
| +// rereads starttime to reject PID reuse during collection. |
| +func AuthenticatePeerTuple(conn net.Conn, expectedUID uint32, expectedPID int, controllerID string) (AuthenticatedPeer, error) { |
| + if conn == nil || expectedPID <= 0 || controllerID == "" { |
| + return AuthenticatedPeer{}, fmt.Errorf("invalid peer expectation") |
| + } |
| + u, err := peerCred(conn) |
| + if err != nil { |
| + return AuthenticatedPeer{}, err |
| + } |
| + if u.Uid != expectedUID || int(u.Pid) != expectedPID { |
| + return AuthenticatedPeer{}, fmt.Errorf("peer identity mismatch") |
| + } |
| + start, err := procStart(expectedPID) |
| + if err != nil { |
| + return AuthenticatedPeer{}, err |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + return AuthenticatedPeer{}, err |
| + } |
| + start2, err := procStart(expectedPID) |
| + if err != nil || start2 != start { |
| + return AuthenticatedPeer{}, fmt.Errorf("peer process changed during authentication") |
| + } |
| + return AuthenticatedPeer{UID: u.Uid, PID: int(u.Pid), ProcessStart: start, BootID: strings.TrimSpace(string(boot)), ControllerID: controllerID}, nil |
| +} |
diff --git a/internal/instancectl/peer_tuple_linux_test.go b/internal/instancectl/peer_tuple_linux_test.go| new file mode 100644 |
| index 000000000..9600a6795 |
| --- /dev/null |
| +++ b/internal/instancectl/peer_tuple_linux_test.go |
| @@ -0,0 +1,30 @@ |
| +//go:build linux |
| + |
| +package instancectl |
| + |
| +import ( |
| + "os" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +func TestProcStartReadsStableIdentity(t *testing.T) { |
| + pid := os.Getpid() |
| + a, err := procStart(pid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := procStart(pid) |
| + if err != nil || a == "" || a != b { |
| + t.Fatalf("start %q %q %v", a, b, err) |
| + } |
| + if _, err := strconv.ParseUint(a, 10, 64); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestProcStartRefusesMissingProcess(t *testing.T) { |
| + if _, err := procStart(1 << 30); err == nil { |
| + t.Fatal("missing pid accepted") |
| + } |
| +} |
diff --git a/internal/instancectl/peer_tuple_other.go b/internal/instancectl/peer_tuple_other.go| new file mode 100644 |
| index 000000000..6d9a12a57 |
| --- /dev/null |
| +++ b/internal/instancectl/peer_tuple_other.go |
| @@ -0,0 +1,18 @@ |
| +//go:build !linux |
| + |
| +package instancectl |
| + |
| +import ( |
| + "errors" |
| + "net" |
| +) |
| + |
| +type AuthenticatedPeer struct { |
| + UID uint32 |
| + PID int |
| + ProcessStart, BootID, ControllerID string |
| +} |
| + |
| +func AuthenticatePeerTuple(net.Conn, uint32, int, string) (AuthenticatedPeer, error) { |
| + return AuthenticatedPeer{}, errors.ErrUnsupported |
| +} |