Sidecar isolation Task 9 (1/5): the flat-exec policy at both spawn seams (D12, D13)
FlatExecPolicy (Refuse, Allow, Record) and ErrFlatExecRefused, wrapping hostbox.ErrUnsupported as the claim refusal does. Both seams ask it immediately after the claim refusal and before Cgroups is touched; a childless spawnInCgroupAtTier delegates so a spawn is admitted and recorded once. The swap gate classifies the refusal as no ownership. An enumeration test pins every exec.Command in the package to a seam (four tenant spawns) or to a platform-own literal.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+498
−2
internal/activator/backend_exec.go+14 −0internal/activator/containment.go+7 −0internal/activator/flatexec.go+101 −0internal/activator/flatexec_copy.go+12 −0internal/activator/flatexec_test.go+208 −0internal/activator/spawn_enumeration_test.go+151 −0internal/activator/swap.go+5 −2
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index abcb6ba9b..f55dfe417 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -184,6 +184,13 @@ type ExecBackend struct { |
| // interface's Layout(rt) (Layout, string)) so a struct field and a |
| // method can coexist on the same type. |
| LayoutFacts LayoutInputs |
| + // FlatExec is the box's flat-exec policy (sidecar isolation D12, D13), |
| + // asked at both spawn seams before any cgroup is touched: under refuse |
| + // a flat or foreign row not on its allowlist is not started in the host |
| + // namespace; under report it is recorded and started. Fixed at agent |
| + // start from host.env. Nil (every unit test, and no agent ever sets |
| + // it nil in production) is today's behaviour exactly. |
| + FlatExec *FlatExecPolicy |
| // Control is the root instance-control channel a nested wake talks to |
| // (design §5.3). Nil refuses every nested wake with "instance control |
| // channel not configured" rather than dialing nothing. A flat or |
| @@ -469,6 +476,13 @@ func (b *ExecBackend) spawnInCgroupGated(rt registry.Route, tier cgroup.Limit, b |
| if b.Cfg.HostWrites.Enabled { |
| return nil, nil, ErrClaimIsolationUnavailable |
| } |
| + // The flat-exec policy (sidecar isolation D13), before Cgroups is |
| + // touched: a box with no delegated subtree falls back to a plain start |
| + // in the agent's own cgroup, and that start is a host-namespace process |
| + // like any other. A nil policy is today's behaviour exactly. |
| + if err := b.admitFlatExec(rt); err != nil { |
| + return nil, nil, err |
| + } |
| if tier == (cgroup.Limit{}) { |
| tier = b.tier(rt) |
| } |
diff --git a/internal/activator/containment.go b/internal/activator/containment.go| index fce2195c6..00cabec31 100644 |
| --- a/internal/activator/containment.go |
| +++ b/internal/activator/containment.go |
| @@ -242,8 +242,15 @@ func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTi |
| return nil, nil, ErrClaimIsolationUnavailable |
| } |
| if child == "" { |
| + // spawnInCgroupGated asks the flat-exec policy itself, so a |
| + // childless spawn is admitted (and recorded) exactly once. |
| return b.spawnInCgroupGated(rt, tier, build, before) |
| } |
| + // The flat-exec policy (sidecar isolation D13), before Cgroups is |
| + // touched, as in spawnInCgroupGated. |
| + if err := b.admitFlatExec(rt); err != nil { |
| + return nil, nil, err |
| + } |
| start := func() (*exec.Cmd, func(), error) { |
| t := tier |
| if t == (cgroup.Limit{}) { |
diff --git a/internal/activator/flatexec.go b/internal/activator/flatexec.go| new file mode 100644 |
| index 000000000..a7e51faf2 |
| --- /dev/null |
| +++ b/internal/activator/flatexec.go |
| @@ -0,0 +1,101 @@ |
| +package activator |
| + |
| +import ( |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The flat-exec policy (sidecar isolation spec rev 4, D12 to D14): whether |
| +// this box starts tenant processes in the host namespace at all. Every |
| +// tenant process the agent execs outside a unit goes through one of two |
| +// seams (spawnInCgroupGated and spawnInCgroupAtTier, §1.4), so one check |
| +// at each covers the flat instance, the flat seed, the legacy co-spawn |
| +// sidecar and the native worker. The policy is fixed when the agent |
| +// starts, from root-owned host.env; a nil policy is today's behaviour |
| +// exactly. |
| + |
| +// ErrFlatExecRefused is the refusal of a spawn in the host namespace under |
| +// CARLOS_FLAT_EXEC=refuse. It wraps hostbox.ErrUnsupported, as the claim |
| +// refusal does (ErrClaimIsolationUnavailable), and is classified beside it: |
| +// the swap gate reads it as no ownership (verdictOf), so a refused |
| +// successor keeps the old generation serving rather than half-switching, |
| +// and a worker respawn it refuses is an attempt's recorded veto, not a |
| +// failure of the build. |
| +var ErrFlatExecRefused = fmt.Errorf("%w: this box does not start tenant processes in the host namespace (CARLOS_FLAT_EXEC=refuse)", hostbox.ErrUnsupported) |
| + |
| +// The closed reason words of a flat-exec record (D14). Status carries them |
| +// per host; error text never goes beside them. |
| +const ( |
| + FlatExecFlatRefused = "flat_refused" |
| + FlatExecForeignRefused = "foreign_refused" |
| + FlatExecFlatReported = "flat_reported" |
| + FlatExecForeignReported = "foreign_reported" |
| +) |
| + |
| +// FlatExecPolicy is the box's setting as the agent read it at start. |
| +// Refuse is CARLOS_FLAT_EXEC=refuse (false is report: every spawn |
| +// proceeds and each would-be refusal is recorded). Allow is the exact |
| +// hosts of CARLOS_FLAT_EXEC_ALLOW, each trusted as the agent itself. |
| +// Record, when set, is told of every refusal and would-be refusal with |
| +// the host and its closed reason word; the host agent keeps the spawn |
| +// record for status and nudges the layout pass (D16) from it. |
| +type FlatExecPolicy struct { |
| + Refuse bool |
| + Allow map[string]bool |
| + Record func(host, reason string) |
| +} |
| + |
| +// FlatExecReason is the closed word for a spawn of a row in layout under |
| +// refuse or report. The seams start host-namespace processes only, so a |
| +// row that is not the flat shape is foreign to them whatever else Classify |
| +// called it: a nested, fenced or conflict row never reaches a seam by |
| +// design, and one that did would still be a flat process. |
| +func FlatExecReason(layout Layout, refuse bool) string { |
| + switch { |
| + case layout == LayoutFlat && refuse: |
| + return FlatExecFlatRefused |
| + case layout == LayoutFlat: |
| + return FlatExecFlatReported |
| + case refuse: |
| + return FlatExecForeignRefused |
| + } |
| + return FlatExecForeignReported |
| +} |
| + |
| +// Admit decides one host-namespace spawn of rt, whose paths Classify |
| +// called layout. An allowlisted host is admitted and not recorded. Any |
| +// other host is recorded (Record) with its reason word; under refuse it is |
| +// refused with ErrFlatExecRefused, and under report it proceeds. A nil |
| +// policy admits everything and records nothing: today's behaviour. |
| +// |
| +// The refusal is logged once per host per hour (logPerFile, as the |
| +// replica key gate logs), naming the host and the layout; the operator |
| +// needs it once, and a refused worker's crash restart would otherwise say |
| +// it every backoff. |
| +func (p *FlatExecPolicy) Admit(rt registry.Route, layout Layout) error { |
| + if p == nil || p.Allow[rt.Host] { |
| + return nil |
| + } |
| + reason := FlatExecReason(layout, p.Refuse) |
| + if p.Record != nil { |
| + p.Record(rt.Host, reason) |
| + } |
| + if !p.Refuse { |
| + return nil |
| + } |
| + logPerFile("flat-exec", rt.Host, fmt.Sprintf(flatExecRefusedLine, rt.Host, layout)) |
| + return fmt.Errorf("%s: %w: layout %s", rt.Host, ErrFlatExecRefused, layout) |
| +} |
| + |
| +// admitFlatExec is D13's check at a seam: the policy's Admit for rt's own |
| +// classification. With no policy the classification is not computed and |
| +// the seam is exactly as it was. |
| +func (b *ExecBackend) admitFlatExec(rt registry.Route) error { |
| + if b.FlatExec == nil { |
| + return nil |
| + } |
| + layout, _ := b.Layout(rt) |
| + return b.FlatExec.Admit(rt, layout) |
| +} |
diff --git a/internal/activator/flatexec_copy.go b/internal/activator/flatexec_copy.go| new file mode 100644 |
| index 000000000..68daff0d1 |
| --- /dev/null |
| +++ b/internal/activator/flatexec_copy.go |
| @@ -0,0 +1,12 @@ |
| +package activator |
| + |
| +// Copy for the flat-exec policy (sidecar isolation spec D14), collected and |
| +// reviewed in Task 12. Each is a placeholder until then. |
| + |
| +// flatExecRefusedLine is the operator's event line for a refused spawn, |
| +// said once per host per hour: the host, the layout word, and what to do. |
| +const flatExecRefusedLine = "activator: %s not started: this box does not run tenant processes in the host namespace (CARLOS_FLAT_EXEC=refuse) and the row is %s; move it nested with the layout pass, repair it with carlos add, or allowlist it in CARLOS_FLAT_EXEC_ALLOW" // copy: placeholder, Task 12 |
| + |
| +// FlatExecHeldWord is the state word `carlos sidecars ls` gives a worker |
| +// whose start this box refused under the policy (D14, "Wake failure"). |
| +const FlatExecHeldWord = "Held: this box will not run it unisolated" // copy: placeholder, Task 12 |
diff --git a/internal/activator/flatexec_test.go b/internal/activator/flatexec_test.go| new file mode 100644 |
| index 000000000..c88225ab5 |
| --- /dev/null |
| +++ b/internal/activator/flatexec_test.go |
| @@ -0,0 +1,208 @@ |
| +package activator |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// flatExecRoutes is one row of each layout a seam can meet, built against |
| +// the backend's own data and run directories: a flat instance, a foreign |
| +// instance (an operator's carlos add --db), a flat worker, a foreign worker |
| +// (a sidecar row whose DBPath was edited elsewhere, R3-2), and a nested |
| +// instance, which never reaches a seam but is classified here so the |
| +// refusal's reason word is pinned for every layout. |
| +func flatExecRoutes(b *ExecBackend) map[Layout]registry.Route { |
| + dd, sd := b.DataDir, b.RunDirOverride |
| + inst, foreign, worker := "shop.example.com", "odd.example.com", "job.shop.abc123"+provision.SidecarSuffix |
| + fw := "edited.shop.abc123" + provision.SidecarSuffix |
| + return map[Layout]registry.Route{ |
| + LayoutFlat: {Host: inst, Kind: "instance", Hibernate: true, Socket: filepath.Join(sd, inst+".sock"), DBPath: provision.DefaultDBPath(dd, inst)}, |
| + LayoutForeign: {Host: foreign, Kind: "instance", Hibernate: true, Socket: filepath.Join(sd, foreign+".sock"), DBPath: filepath.Join(dd, "elsewhere.db")}, |
| + "worker_flat": {Host: worker, Kind: "sidecar", DBPath: provision.DefaultDBPath(dd, worker)}, |
| + "worker_foreign": {Host: fw, Kind: "sidecar", DBPath: filepath.Join(dd, "tenant-chose-this.db")}, |
| + } |
| +} |
| + |
| +func flatExecBackend(t *testing.T) *ExecBackend { |
| + t.Helper() |
| + b := testExecBackend(t) |
| + b.RunDirOverride = t.TempDir() |
| + return b |
| +} |
| + |
| +// Admit refuses a flat or foreign row not on the allowlist under refuse, |
| +// with the closed reason word for its layout, and admits an allowlisted |
| +// host without recording it. Under report every row proceeds and is |
| +// recorded with the reported word. A nil policy admits and records nothing. |
| +func TestAdmitRefusesFlatAndForeignNotAllowlisted(t *testing.T) { |
| + b := flatExecBackend(t) |
| + rows := flatExecRoutes(b) |
| + for _, refuse := range []bool{true, false} { |
| + for name, rt := range rows { |
| + t.Run(fmt.Sprintf("refuse=%t/%s", refuse, name), func(t *testing.T) { |
| + var recorded []string |
| + p := &FlatExecPolicy{Refuse: refuse, Record: func(host, reason string) { recorded = append(recorded, host+" "+reason) }} |
| + layout, _ := b.Layout(rt) |
| + want := FlatExecReason(layout, refuse) |
| + err := p.Admit(rt, layout) |
| + if refuse { |
| + if !errors.Is(err, ErrFlatExecRefused) || !errors.Is(err, hostbox.ErrUnsupported) { |
| + t.Fatalf("Admit = %v, want ErrFlatExecRefused wrapping hostbox.ErrUnsupported", err) |
| + } |
| + } else if err != nil { |
| + t.Fatalf("Admit under report = %v", err) |
| + } |
| + if len(recorded) != 1 || recorded[0] != rt.Host+" "+want { |
| + t.Fatalf("recorded %v, want [%s %s]", recorded, rt.Host, want) |
| + } |
| + // Allowlisted: admitted, not recorded. |
| + recorded = nil |
| + p.Allow = map[string]bool{rt.Host: true} |
| + if err := p.Admit(rt, layout); err != nil || len(recorded) != 0 { |
| + t.Fatalf("allowlisted: err=%v recorded=%v", err, recorded) |
| + } |
| + }) |
| + } |
| + } |
| + var nilPolicy *FlatExecPolicy |
| + if err := nilPolicy.Admit(rows[LayoutFlat], LayoutFlat); err != nil { |
| + t.Fatalf("nil policy: %v", err) |
| + } |
| + // The reason words are the closed set, by layout: flat is flat, and |
| + // everything else a seam could meet is foreign to it. |
| + for layout, words := range map[Layout][2]string{ |
| + LayoutFlat: {FlatExecFlatRefused, FlatExecFlatReported}, |
| + LayoutForeign: {FlatExecForeignRefused, FlatExecForeignReported}, |
| + LayoutNested: {FlatExecForeignRefused, FlatExecForeignReported}, |
| + LayoutFenced: {FlatExecForeignRefused, FlatExecForeignReported}, |
| + LayoutConflict: {FlatExecForeignRefused, FlatExecForeignReported}, |
| + } { |
| + if got := FlatExecReason(layout, true); got != words[0] { |
| + t.Errorf("%s refused = %s, want %s", layout, got, words[0]) |
| + } |
| + if got := FlatExecReason(layout, false); got != words[1] { |
| + t.Errorf("%s reported = %s, want %s", layout, got, words[1]) |
| + } |
| + } |
| +} |
| + |
| +// R3-2: a sidecar row whose DBPath was edited elsewhere classifies foreign |
| +// and is refused under refuse like any other, so a tenant cannot keep its |
| +// flat process by editing its row. |
| +func TestRefuseRefusesTenantMadeForeignWorkerRow(t *testing.T) { |
| + b := flatExecBackend(t) |
| + rt := flatExecRoutes(b)["worker_foreign"] |
| + if layout, _ := b.Layout(rt); layout != LayoutForeign { |
| + t.Fatalf("edited worker row classifies %s, want foreign", layout) |
| + } |
| + var reasons []string |
| + b.FlatExec = &FlatExecPolicy{Refuse: true, Record: func(_, reason string) { reasons = append(reasons, reason) }} |
| + built := 0 |
| + _, _, err := b.spawnInCgroupGated(rt, cgroup.Limit{}, func() *exec.Cmd { built++; return exec.Command("true") }, nil) |
| + if !errors.Is(err, ErrFlatExecRefused) || built != 0 { |
| + t.Fatalf("err=%v built=%d", err, built) |
| + } |
| + if len(reasons) != 1 || reasons[0] != FlatExecForeignRefused { |
| + t.Fatalf("recorded %v, want [%s]", reasons, FlatExecForeignRefused) |
| + } |
| +} |
| + |
| +// Both seams refuse before Cgroups is touched, a nil manager included (a |
| +// box with no delegated subtree, whose StartGated would fall back to a |
| +// plain start): nothing is built, no gate is asked, no strict group is |
| +// prepared. The allowlist opens exactly the named host. Under report the |
| +// spawn proceeds and the would-be refusal is recorded once per spawn, |
| +// including a childless spawnInCgroupAtTier, which delegates. |
| +func TestFlatExecSeamsRefuseBeforeCgroups(t *testing.T) { |
| + for _, seam := range []string{"gated", "at-tier-childless", "at-tier-child"} { |
| + for _, mode := range []string{"refuse", "report", "allow"} { |
| + t.Run(seam+"/"+mode, func(t *testing.T) { |
| + b := flatExecBackend(t) |
| + rt := flatExecRoutes(b)[LayoutFlat] |
| + b.Cgroups = nil |
| + var recorded []string |
| + b.FlatExec = &FlatExecPolicy{Refuse: mode == "refuse", Record: func(host, reason string) { recorded = append(recorded, host+" "+reason) }} |
| + if mode == "allow" { |
| + b.FlatExec.Refuse = true |
| + b.FlatExec.Allow = map[string]bool{rt.Host: true} |
| + } |
| + prepared, built, gates := 0, 0, 0 |
| + b.Strict = func(string) bool { return true } |
| + b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| + prepared++ |
| + return nil, errors.New("strict preparation reached") |
| + } |
| + build := func() *exec.Cmd { built++; return exec.Command("true") } |
| + before := func() error { gates++; return nil } |
| + var cmd *exec.Cmd |
| + var settle func() |
| + var err error |
| + switch seam { |
| + case "gated": |
| + cmd, settle, err = b.spawnInCgroupGated(rt, cgroup.Limit{}, build, before) |
| + case "at-tier-childless": |
| + cmd, settle, err = b.spawnInCgroupAtTier(rt, "", true, cgroup.Limit{}, build, before) |
| + default: |
| + // A placed child under a strict host is refused by the |
| + // host gate after the policy; the policy must have |
| + // answered first. |
| + b.Strict = func(string) bool { return false } |
| + b.Cgroups = delegatedManager(t) |
| + cmd, settle, err = b.spawnInCgroupAtTier(rt, "g7", true, cgroup.Limit{}, build, before) |
| + } |
| + if cmd != nil { |
| + _ = cmd.Wait() |
| + } |
| + if settle != nil { |
| + settle() |
| + } |
| + switch mode { |
| + case "refuse": |
| + if !errors.Is(err, ErrFlatExecRefused) || cmd != nil || prepared != 0 || built != 0 || gates != 0 { |
| + t.Fatalf("refuse: cmd=%v prepared=%d built=%d gates=%d err=%v", cmd, prepared, built, gates, err) |
| + } |
| + if len(recorded) != 1 || recorded[0] != rt.Host+" "+FlatExecFlatRefused { |
| + t.Fatalf("recorded %v", recorded) |
| + } |
| + case "report": |
| + if errors.Is(err, ErrFlatExecRefused) { |
| + t.Fatalf("report refused: %v", err) |
| + } |
| + if len(recorded) != 1 || recorded[0] != rt.Host+" "+FlatExecFlatReported { |
| + t.Fatalf("recorded %v, want one reported record", recorded) |
| + } |
| + if seam != "at-tier-child" && (err == nil || !errors.Is(err, ErrStrictRequired)) { |
| + // Report proceeds past the policy into the strict |
| + // path, which is this test's proof it was not |
| + // stopped by the policy. |
| + t.Fatalf("report did not reach the strict path: %v", err) |
| + } |
| + case "allow": |
| + if errors.Is(err, ErrFlatExecRefused) || len(recorded) != 0 { |
| + t.Fatalf("allowlisted host: err=%v recorded=%v", err, recorded) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// The swap gate's classifier reads the refusal as no ownership, so a refused |
| +// successor is abandonment, never a failure of the build. |
| +func TestSwapGateClassifiesFlatExecRefused(t *testing.T) { |
| + err := fmt.Errorf("shop.example.com: %w: layout flat", ErrFlatExecRefused) |
| + if v := verdictOf(err); v != gateNoOwnership { |
| + t.Fatalf("verdictOf(ErrFlatExecRefused) = %v, want gateNoOwnership", v) |
| + } |
| + if v := verdictOf(claimAdmission(err, false)); v != gateNoOwnership { |
| + t.Fatalf("verdictOf(claimAdmission(ErrFlatExecRefused)) = %v, want gateNoOwnership", v) |
| + } |
| +} |
diff --git a/internal/activator/spawn_enumeration_test.go b/internal/activator/spawn_enumeration_test.go| new file mode 100644 |
| index 000000000..cc3b4bbe8 |
| --- /dev/null |
| +++ b/internal/activator/spawn_enumeration_test.go |
| @@ -0,0 +1,151 @@ |
| +package activator |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// spawnSeams are the functions whose build argument is the one place an |
| +// exec.Cmd for tenant code may be constructed (sidecar isolation D13, |
| +// §1.4): every tenant process started outside a unit goes through |
| +// spawnInCgroupGated or spawnInCgroupAtTier, directly or through the two |
| +// thin wrappers, and the flat-exec policy is asked there. |
| +var spawnSeams = map[string]bool{ |
| + "spawnInCgroup": true, |
| + "spawnInCgroupGated": true, |
| + "spawnInCgroupAt": true, |
| + "spawnInCgroupAtTier": true, |
| +} |
| + |
| +// platformOwnCommands are the executables the agent runs for itself, not |
| +// tenant code: nothing here changes them (D13, "The platform's own |
| +// children"). A new one is added here, deliberately, or the test fails. |
| +var platformOwnCommands = map[string]bool{ |
| + "systemctl": true, |
| +} |
| + |
| +// TestEveryExecCommandReachesASeam enumerates every exec.Command and |
| +// exec.CommandContext in this package's non-test files and fails on one |
| +// that is neither inside a build closure handed to a seam nor a platform's |
| +// own command named by literal. Four tenant spawns today: the flat seed, |
| +// the flat instance, the legacy co-spawn sidecar and the native worker. |
| +func TestEveryExecCommandReachesASeam(t *testing.T) { |
| + files, err := filepath.Glob("*.go") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fset := token.NewFileSet() |
| + var tenant, stray []string |
| + for _, name := range files { |
| + if strings.HasSuffix(name, "_test.go") { |
| + continue |
| + } |
| + src, err := os.ReadFile(name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := parser.ParseFile(fset, name, src, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // A stack of the enclosing call expressions, so an exec.Command |
| + // inside a function literal can ask what that literal was passed to. |
| + var stack []ast.Node |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + if n == nil { |
| + stack = stack[:len(stack)-1] |
| + return false |
| + } |
| + stack = append(stack, n) |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + sel, ok := call.Fun.(*ast.SelectorExpr) |
| + if !ok { |
| + return true |
| + } |
| + pkg, ok := sel.X.(*ast.Ident) |
| + if !ok || pkg.Name != "exec" || (sel.Sel.Name != "Command" && sel.Sel.Name != "CommandContext") { |
| + return true |
| + } |
| + where := fset.Position(call.Pos()).String() |
| + if lit, ok := firstArgLiteral(call, sel.Sel.Name); ok && platformOwnCommands[lit] { |
| + return true |
| + } |
| + if seamOf(stack[:len(stack)-1]) != "" { |
| + tenant = append(tenant, where) |
| + return true |
| + } |
| + stray = append(stray, where) |
| + return true |
| + }) |
| + } |
| + sort.Strings(tenant) |
| + if len(stray) != 0 { |
| + t.Fatalf("exec.Command outside every spawn seam (a tenant process the flat-exec policy would never see):\n %s", strings.Join(stray, "\n ")) |
| + } |
| + if len(tenant) != 4 { |
| + t.Fatalf("tenant spawns through a seam = %d, want 4 (the flat seed, the flat instance, the legacy co-spawn sidecar, the native worker):\n %s", len(tenant), strings.Join(tenant, "\n ")) |
| + } |
| +} |
| + |
| +// firstArgLiteral is the command's executable when it is a string literal: |
| +// the first argument of exec.Command, the second of exec.CommandContext. |
| +func firstArgLiteral(call *ast.CallExpr, fn string) (string, bool) { |
| + i := 0 |
| + if fn == "CommandContext" { |
| + i = 1 |
| + } |
| + if len(call.Args) <= i { |
| + return "", false |
| + } |
| + lit, ok := call.Args[i].(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + return "", false |
| + } |
| + s, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + return "", false |
| + } |
| + return s, true |
| +} |
| + |
| +// seamOf walks outward from an exec.Command's enclosing nodes to the |
| +// nearest function literal and reports the seam that literal is an |
| +// argument of, or "" when it is none. |
| +func seamOf(enclosing []ast.Node) string { |
| + for i := len(enclosing) - 1; i >= 0; i-- { |
| + lit, ok := enclosing[i].(*ast.FuncLit) |
| + if !ok { |
| + continue |
| + } |
| + for j := i - 1; j >= 0; j-- { |
| + call, ok := enclosing[j].(*ast.CallExpr) |
| + if !ok { |
| + continue |
| + } |
| + for _, arg := range call.Args { |
| + if arg != ast.Expr(lit) { |
| + continue |
| + } |
| + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && spawnSeams[sel.Sel.Name] { |
| + return sel.Sel.Name |
| + } |
| + if id, ok := call.Fun.(*ast.Ident); ok && spawnSeams[id.Name] { |
| + return id.Name |
| + } |
| + } |
| + return "" |
| + } |
| + return "" |
| + } |
| + return "" |
| +} |
diff --git a/internal/activator/swap.go b/internal/activator/swap.go| index 629c3145c..b8fa3d8ed 100644 |
| --- a/internal/activator/swap.go |
| +++ b/internal/activator/swap.go |
| @@ -189,7 +189,10 @@ const ( |
| // ErrStrictRequired, ErrStrictGroupInUse, ErrStrictCleanupPending; |
| // - a closed claims-on launch (index P2-9): ErrClaimIsolationUnavailable. |
| // It wraps hostbox.ErrUnsupported, which is not otherwise classified, |
| -// so it is named here; |
| +// so it is named here; and the flat-exec policy's refusal of a |
| +// host-namespace spawn (sidecar isolation D13, ErrFlatExecRefused), |
| +// which wraps the same and is classified the same way, so a refused |
| +// successor keeps the old generation serving; |
| // - a row that cannot run a staged generation at all: errUnitNoGens, |
| // errSwapNoGens; |
| // - the cgroup manager refusing a generation start because the host's |
| @@ -236,7 +239,7 @@ func verdictOf(err error) gateVerdict { |
| errors.Is(err, hostguard.ErrNoHolder), errors.Is(err, hostguard.ErrMalformedClaim), |
| errors.Is(err, hostbox.ErrChanged), errors.Is(err, errClaimsAdmission), |
| errors.Is(err, ErrStrictRequired), errors.Is(err, ErrStrictGroupInUse), errors.Is(err, ErrStrictCleanupPending), |
| - errors.Is(err, ErrClaimIsolationUnavailable), errors.Is(err, cgroup.ErrLegacyParentPopulated), |
| + errors.Is(err, ErrClaimIsolationUnavailable), errors.Is(err, ErrFlatExecRefused), errors.Is(err, cgroup.ErrLegacyParentPopulated), |
| errors.Is(err, cgroup.ErrKilledCgroupKept), errors.Is(err, cgroup.ErrLimitNotApplied), |
| errors.Is(err, errUnitNoGens), errors.Is(err, errSwapNoGens), errors.Is(err, instancectl.ErrStagedExe), |
| errors.Is(err, errBuildStateUnwritten): |