Refuse surviving maintenance descendants and terminate after lease expiry
8 files changed,
+242
−25
implementation/maintenance-worker-progress.json+12 −3internal/cgroup/strict_linux.go+13 −0internal/cgroup/strict_linux_test.go+25 −0internal/cgroup/strict_other.go+1 −0internal/maintenanceexec/errors.go+6 −0internal/maintenanceexec/run_linux.go+29 −17internal/maintenanceexec/run_linux_test.go+140 −5internal/maintenanceexec/run_other.go+16 −0
CI passed — run details
diff --git a/implementation/maintenance-worker-progress.json b/implementation/maintenance-worker-progress.json| index d4dc9767c..23c22af22 100644 |
| --- a/implementation/maintenance-worker-progress.json |
| +++ b/implementation/maintenance-worker-progress.json |
| @@ -1,10 +1,10 @@ |
| { |
| "scope": "Complete supported maintenance dispatch needed by Oficina provisioning; no live invocation until all approved launch gates are implemented and reviewed", |
| "base": "006e7825", |
| - "current_slice": "Strict typed private launch-context codec for approved mutable v1 and checkpoint-checker v2", |
| + "current_slice": "Strict private launch codec and inert Linux subprocess supervisor; no worker mounting", |
| "authority": "Decoded context is metadata only. It cannot construct a retained lease, executor, lifecycle claim, approval or trusted invocation.", |
| "remaining": [ |
| - "Verified pinned-artifact launch and cancellable bounded pipe supervisor using strict cgroup handles", |
| + "Native cgroup/lease integration evidence for reviewed subprocess supervisor", |
| "Retained worker lease/executor and closed-checkpoint preparation proof", |
| "Home startup adapter binding signed input, image, descriptors and opened database", |
| "Owner-reviewed prepare/approval/status/result/retry/resume CLI and console workflow", |
| @@ -12,5 +12,14 @@ |
| "Integrated native tests and Fable review before worker mounting", |
| "Preview owner-approved provisioning, restore drill, and public gateway cutover" |
| ], |
| - "verification": ["Required closed fields and exact numeric ranges", "Canonical timestamps, identifiers and binding digests", "UTF-8 and surrogate validity", "8KiB hard limit and overflow detection", "Nested v2 checkpoint evidence bindings", "No command mounting or execution authority from decoded bytes"] |
| + "verification": [ |
| + "Required closed fields and exact numeric ranges", |
| + "Canonical timestamps, identifiers and binding digests", |
| + "UTF-8 and surrogate validity", |
| + "8KiB hard limit and overflow detection", |
| + "Nested v2 checkpoint evidence bindings", |
| + "No command mounting or execution authority from decoded bytes", |
| + "Focused race tests for descendants, expired-lease termination, exact private input and descriptor/environment isolation", |
| + "Fable approved codec at 01b82bde; supervisor refusal preserved and fixes awaiting re-review" |
| + ] |
| } |
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go| index 7d365038e..4b2557507 100644 |
| --- a/internal/cgroup/strict_linux.go |
| +++ b/internal/cgroup/strict_linux.go |
| @@ -266,6 +266,19 @@ func (p *Prepared) Start(cmd *exec.Cmd) error { |
| return nil |
| } |
| +// Populated checks the retained group after the direct child has been reaped. |
| +func (p *Prepared) Populated() (bool, error) { |
| + if p == nil || p.strictPreparedState == nil { |
| + return false, ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if e := p.identityOK(); e != nil { |
| + return false, e |
| + } |
| + return strictPopulation(p.events) |
| +} |
| + |
| // KillAndEmpty proves kernel population is zero before the caller's deadline. |
| // Failure retains the pinned cleanup handle and permits another bounded attempt. |
| func (p *Prepared) KillAndEmpty(ctx context.Context) error { |
diff --git a/internal/cgroup/strict_linux_test.go b/internal/cgroup/strict_linux_test.go| index 0d6124a94..03f687a55 100644 |
| --- a/internal/cgroup/strict_linux_test.go |
| +++ b/internal/cgroup/strict_linux_test.go |
| @@ -65,6 +65,31 @@ func strictContext(t *testing.T, d time.Duration) context.Context { |
| t.Cleanup(cancel) |
| return ctx |
| } |
| + |
| +func TestStrictPopulationUsesRetainedIdentity(t *testing.T) { |
| + p := strictFixture(t) |
| + for _, populated := range []bool{false, true} { |
| + value := "populated 0\nfrozen 0\n" |
| + if populated { |
| + value = "populated 1\nfrozen 0\n" |
| + } |
| + if e := strictWrite(p.events, value); e != nil { |
| + t.Fatal(e) |
| + } |
| + if got, e := p.Populated(); e != nil || got != populated { |
| + t.Fatal("incorrect population", got, e) |
| + } |
| + } |
| + if e := os.Rename(p.dir.Name(), p.dir.Name()+".old"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := p.Populated(); e == nil { |
| + t.Fatal("replaced cgroup accepted") |
| + } |
| + if _, e := (&Prepared{}).Populated(); e == nil { |
| + t.Fatal("empty handle accepted") |
| + } |
| +} |
| func TestStrictRejectsUnavailableManagerHostAndTier(t *testing.T) { |
| for _, m := range []*Manager{nil, {root: t.TempDir()}} { |
| for _, host := range []string{"ok.example", "../outside", "Upper.example", "", "a/b"} { |
diff --git a/internal/cgroup/strict_other.go b/internal/cgroup/strict_other.go| index 0a1b0ed76..b5c10bd57 100644 |
| --- a/internal/cgroup/strict_other.go |
| +++ b/internal/cgroup/strict_other.go |
| @@ -13,5 +13,6 @@ func (m *Manager) PrepareStrict(host string, limit Limit) (*Prepared, error) { |
| return nil, ErrStrictUnsupported |
| } |
| func (p *Prepared) Start(cmd *exec.Cmd) error { return ErrStrictUnsupported } |
| +func (p *Prepared) Populated() (bool, error) { return false, ErrStrictUnsupported } |
| func (p *Prepared) KillAndEmpty(ctx context.Context) error { return ErrStrictUnsupported } |
| func (p *Prepared) Close() error { return ErrStrictUnsupported } |
diff --git a/internal/maintenanceexec/errors.go b/internal/maintenanceexec/errors.go| new file mode 100644 |
| index 000000000..818cdcf84 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/errors.go |
| @@ -0,0 +1,6 @@ |
| +package maintenanceexec |
| + |
| +import "errors" |
| + |
| +var ErrRefused = errors.New("maintenance execution: refused") |
| +var ErrUncertain = errors.New("maintenance execution: process cleanup uncertain") |
diff --git a/internal/maintenanceexec/run_linux.go b/internal/maintenanceexec/run_linux.go| index b37aa4e82..252662fe6 100644 |
| --- a/internal/maintenanceexec/run_linux.go |
| +++ b/internal/maintenanceexec/run_linux.go |
| @@ -8,7 +8,6 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| - "errors" |
| "io" |
| "os" |
| "os/exec" |
| @@ -20,13 +19,11 @@ import ( |
| "golang.org/x/sys/unix" |
| ) |
| -var ErrRefused = errors.New("maintenance execution: refused") |
| -var ErrUncertain = errors.New("maintenance execution: process cleanup uncertain") |
| - |
| const stopMargin = 2 * time.Second |
| type containment interface { |
| Start(*exec.Cmd) error |
| + Populated() (bool, error) |
| KillAndEmpty(context.Context) error |
| } |
| type ownership interface { |
| @@ -46,9 +43,12 @@ func Run(ctx context.Context, group *cgroup.Prepared, held *lease.Held, artifact |
| } |
| func run(ctx context.Context, group containment, held ownership, artifact *os.File, launch maintenance.LaunchContext, input maintenance.Input, environment []string) (result []byte, err error) { |
| - if ctx == nil || ctx.Err() != nil || artifact == nil || launch.Version != 1 { |
| + if ctx == nil || ctx.Err() != nil || artifact == nil || launch.Version != 1 || len(input.Arguments) > maintenance.MaxArguments || len(input.Stdin) > maintenance.MaxInputBytes { |
| return nil, ErrRefused |
| } |
| + input.Arguments = append([]string(nil), input.Arguments...) |
| + input.Stdin = append([]byte(nil), input.Stdin...) |
| + defer clear(input.Stdin) |
| raw, e := maintenance.EncodeLaunch(launch) |
| if e != nil { |
| return nil, ErrRefused |
| @@ -102,9 +102,7 @@ func run(ctx context.Context, group containment, held ownership, artifact *os.Fi |
| return nil, ErrRefused |
| } |
| if e = group.Start(cmd); e != nil { |
| - stopCtx, cancel := context.WithDeadline(context.Background(), until) |
| - stopErr := group.KillAndEmpty(stopCtx) |
| - cancel() |
| + stopErr := stop(group, until) |
| if stopErr != nil { |
| return nil, ErrUncertain |
| } |
| @@ -119,9 +117,7 @@ func run(ctx context.Context, group containment, held ownership, artifact *os.Fi |
| return |
| } |
| pipes.close() |
| - stopCtx, cancel := context.WithDeadline(context.Background(), until) |
| - defer cancel() |
| - if group.KillAndEmpty(stopCtx) != nil { |
| + if stop(group, until) != nil { |
| err = ErrUncertain |
| result = nil |
| } |
| @@ -174,9 +170,13 @@ func run(ctx context.Context, group containment, held ownership, artifact *os.Fi |
| } |
| } |
| if failed || waited { |
| - stopCtx, cancel := context.WithDeadline(context.Background(), until) |
| - e := group.KillAndEmpty(stopCtx) |
| - cancel() |
| + if waited { |
| + populated, e := group.Populated() |
| + if e != nil || populated { |
| + failed = true |
| + } |
| + } |
| + e := stop(group, until) |
| if e != nil { |
| clear(result) |
| return nil, ErrUncertain |
| @@ -206,9 +206,7 @@ func run(ctx context.Context, group containment, held ownership, artifact *os.Fi |
| } |
| } |
| if !cleanupDone { |
| - stopCtx, cancel := context.WithDeadline(context.Background(), until) |
| - e := group.KillAndEmpty(stopCtx) |
| - cancel() |
| + e := stop(group, until) |
| if e != nil { |
| clear(result) |
| return nil, ErrUncertain |
| @@ -225,6 +223,20 @@ func run(ctx context.Context, group containment, held ownership, artifact *os.Fi |
| return result, nil |
| } |
| +func stop(group containment, until time.Time) error { |
| + deadline := time.Now().Add(stopMargin) |
| + if until.After(deadline) { |
| + deadline = until |
| + } |
| + ctx, cancel := context.WithDeadline(context.Background(), deadline) |
| + defer cancel() |
| + err := group.KillAndEmpty(ctx) |
| + if err != nil || !time.Now().Before(until) { |
| + return ErrUncertain |
| + } |
| + return nil |
| +} |
| + |
| type pipeEvent struct { |
| kind string |
| data []byte |
diff --git a/internal/maintenanceexec/run_linux_test.go b/internal/maintenanceexec/run_linux_test.go| index 67ef4e60c..9b65cf118 100644 |
| --- a/internal/maintenanceexec/run_linux_test.go |
| +++ b/internal/maintenanceexec/run_linux_test.go |
| @@ -13,6 +13,8 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "reflect" |
| + "strconv" |
| "strings" |
| "sync" |
| "syscall" |
| @@ -44,7 +46,33 @@ func (g *fixtureGroup) Start(c *exec.Cmd) error { |
| c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} |
| return c.Start() |
| } |
| -func (g *fixtureGroup) KillAndEmpty(context.Context) error { |
| +func (g *fixtureGroup) Populated() (bool, error) { |
| + if g.cmd == nil || g.cmd.Process == nil { |
| + return false, nil |
| + } |
| + entries, err := os.ReadDir("/proc") |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, entry := range entries { |
| + if _, err := strconv.Atoi(entry.Name()); err != nil { |
| + continue |
| + } |
| + raw, err := os.ReadFile("/proc/" + entry.Name() + "/stat") |
| + if err != nil { |
| + continue |
| + } |
| + fields := strings.Fields(string(raw)[strings.LastIndexByte(string(raw), ')')+1:]) |
| + if len(fields) >= 3 && fields[2] == strconv.Itoa(g.cmd.Process.Pid) && fields[0] != "Z" { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| +func (g *fixtureGroup) KillAndEmpty(ctx context.Context) error { |
| + if ctx.Err() != nil { |
| + return ctx.Err() |
| + } |
| g.kills++ |
| if g.cmd != nil && g.cmd.Process != nil { |
| unix.Kill(-g.cmd.Process.Pid, unix.SIGKILL) |
| @@ -72,6 +100,32 @@ func TestChildFixture(t *testing.T) { |
| if mode == "" { |
| return |
| } |
| + if len(os.Environ()) != 2 || os.Getenv("GODEBUG") != "containermaxprocs=0,updatemaxprocs=0" { |
| + os.Exit(15) |
| + } |
| + if cwd, err := os.Getwd(); err != nil || cwd != "/" { |
| + os.Exit(16) |
| + } |
| + stderr, err := os.Stderr.Stat() |
| + null, nullErr := os.Stat(os.DevNull) |
| + if err != nil || nullErr != nil || !os.SameFile(stderr, null) { |
| + os.Exit(17) |
| + } |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + os.Exit(18) |
| + } |
| + for _, entry := range entries { |
| + fd, err := strconv.Atoi(entry.Name()) |
| + if err != nil || fd <= 4 { |
| + continue |
| + } |
| + target, err := os.Readlink("/proc/self/fd/" + entry.Name()) |
| + if errors.Is(err, os.ErrNotExist) || strings.HasPrefix(target, "anon_inode:") { |
| + continue |
| + } |
| + os.Exit(19) |
| + } |
| if mode == "blocked" { |
| time.Sleep(time.Minute) |
| return |
| @@ -84,6 +138,15 @@ func TestChildFixture(t *testing.T) { |
| if mode == "nonzero" { |
| os.Exit(7) |
| } |
| + if mode == "descendant" { |
| + child := exec.Command("/proc/self/exe", "-test.run=^TestChildFixture$") |
| + child.Env = []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=blocked"} |
| + child.Dir = "/" |
| + child.Stdout = os.Stdout |
| + if child.Start() != nil { |
| + os.Exit(14) |
| + } |
| + } |
| c, err := maintenance.ReadLaunch(os.NewFile(3, "context")) |
| if err != nil { |
| os.Exit(8) |
| @@ -114,6 +177,7 @@ func TestChildFixture(t *testing.T) { |
| } |
| func executionFixture(t *testing.T) (*os.File, maintenance.LaunchContext, maintenance.Input, *fixtureGroup, *fixtureLease) { |
| t.Helper() |
| + t.Setenv("MAINTENANCE_PARENT_ONLY", "must-not-reach-child") |
| src, err := os.Open("/proc/self/exe") |
| if err != nil { |
| t.Fatal(err) |
| @@ -153,9 +217,9 @@ func TestPinnedImageAndExactPrivateInput(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - out, err := run(t.Context(), g, l, pin, c, input, []string{"MAINTENANCE_EXEC_FIXTURE=success"}) |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| if err != nil { |
| - t.Fatal(err) |
| + t.Fatal(err, g.cmd.ProcessState) |
| } |
| var result struct { |
| InputBytes int `json:"input_bytes"` |
| @@ -180,7 +244,7 @@ func TestBlockedIOOverflowFailureAndLeaseLoss(t *testing.T) { |
| g.before = func() { go func() { time.Sleep(50 * time.Millisecond); l.lose() }() } |
| } |
| started := time.Now() |
| - out, err := run(ctx, g, l, pin, c, input, []string{"MAINTENANCE_EXEC_FIXTURE=" + fixtureMode}) |
| + out, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=" + fixtureMode}) |
| if err == nil || len(out) != 0 || g.starts != 1 || g.kills == 0 { |
| t.Fatal("failure became success", err, g.starts, g.kills) |
| } |
| @@ -190,6 +254,77 @@ func TestBlockedIOOverflowFailureAndLeaseLoss(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +func TestDescendantHoldingOutputPipe(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Second) |
| + defer cancel() |
| + started := time.Now() |
| + out, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=descendant"}) |
| + if err != ErrRefused || len(out) != 0 || g.kills != 1 { |
| + t.Fatal("surviving descendant was accepted", err, g.kills) |
| + } |
| + if time.Since(started) > 3*time.Second { |
| + t.Fatal("waited for descendant instead of terminating it") |
| + } |
| +} |
| + |
| +func TestExpiredLeaseStillTerminatesProcess(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + l.until = time.Now().Add(2200 * time.Millisecond) |
| + g.before = func() { time.Sleep(time.Until(l.until) + 10*time.Millisecond) } |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=blocked"}) |
| + if err != ErrUncertain || len(out) != 0 || g.kills == 0 { |
| + t.Fatal("expired lease skipped termination", err, g.kills) |
| + } |
| +} |
| + |
| +func descriptorSnapshot(t *testing.T) map[string]string { |
| + t.Helper() |
| + entries, err := os.ReadDir("/proc/self/fd") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for _, entry := range entries { |
| + target, err := os.Readlink("/proc/self/fd/" + entry.Name()) |
| + if errors.Is(err, os.ErrNotExist) || strings.HasPrefix(target, "anon_inode:") { |
| + continue |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out[entry.Name()] = target |
| + } |
| + return out |
| +} |
| + |
| +func TestParentDescriptorsClose(t *testing.T) { |
| + for _, mode := range []string{"success", "blocked", "overflow", "nonzero", "descendant"} { |
| + t.Run(mode, func(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + before := descriptorSnapshot(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + defer cancel() |
| + _, err := run(ctx, g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=" + mode}) |
| + if (err == nil) != (mode == "success") { |
| + t.Fatal("wrong result", err) |
| + } |
| + if !reflect.DeepEqual(before, descriptorSnapshot(t)) { |
| + t.Fatal("parent descriptor set changed after execution") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestInputRemainsBoundAfterPreflight(t *testing.T) { |
| + pin, c, input, g, l := executionFixture(t) |
| + g.before = func() { input.Stdin[10] = 'b' } |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| + if err != nil || len(out) == 0 { |
| + t.Fatal("child received input changed after validation", err) |
| + } |
| +} |
| func TestPreflightRefusesWithoutSpawn(t *testing.T) { |
| for _, mode := range []string{"input", "artifact", "deadline", "checker", "lease"} { |
| t.Run(mode, func(t *testing.T) { |
| @@ -220,7 +355,7 @@ func TestFailedStartAndUncertainCleanup(t *testing.T) { |
| } |
| pin, c, input, g, l = executionFixture(t) |
| g.killErr = errors.New("synthetic private cleanup detail") |
| - out, err := run(t.Context(), g, l, pin, c, input, []string{"MAINTENANCE_EXEC_FIXTURE=success"}) |
| + out, err := run(t.Context(), g, l, pin, c, input, []string{"GODEBUG=containermaxprocs=0,updatemaxprocs=0", "MAINTENANCE_EXEC_FIXTURE=success"}) |
| if err != ErrUncertain || len(out) != 0 { |
| t.Fatal("uncertain cleanup accepted", err) |
| } |
diff --git a/internal/maintenanceexec/run_other.go b/internal/maintenanceexec/run_other.go| new file mode 100644 |
| index 000000000..0aa531438 |
| --- /dev/null |
| +++ b/internal/maintenanceexec/run_other.go |
| @@ -0,0 +1,16 @@ |
| +//go:build !linux |
| + |
| +package maintenanceexec |
| + |
| +import ( |
| + "context" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/maintenance" |
| +) |
| + |
| +func Run(context.Context, *cgroup.Prepared, *lease.Held, *os.File, maintenance.LaunchContext, maintenance.Input, []string) ([]byte, error) { |
| + return nil, ErrRefused |
| +} |