diff --git a/.agents/skills/plugin-nerdctl/SKILL.md b/.agents/skills/plugin-nerdctl/SKILL.md index 5637a6a..69d7654 100644 --- a/.agents/skills/plugin-nerdctl/SKILL.md +++ b/.agents/skills/plugin-nerdctl/SKILL.md @@ -1,15 +1,20 @@ --- name: plugin-nerdctl -description: The out-of-tree charly ENGINE plugin serving engine:nerdctl (verb:nerdctl - / command:nerdctl) — every engine op delegates to container.InvokeEngineOp. Use - when working with the engine:nerdctl provider, the plugin-nerdctl candy, or the - engine provider-class placement model. +description: The out-of-tree charly ENGINE plugin serving engine:containerd (verb:containerd + / command:containerd) — every engine op delegates to container.InvokeEngineOp. `containerd` + is the engine word; `nerdctl` is only the CLI binary it is driven through. Use when working + with the engine:containerd provider, the plugin-nerdctl candy, or the engine provider-class + placement model. --- # plugin-nerdctl -The out-of-tree charly ENGINE plugin serving `engine:nerdctl` (plus the -`verb:nerdctl` / `command:nerdctl` convenience words). +The out-of-tree charly ENGINE plugin serving `engine:containerd` (plus the +`verb:containerd` / `command:containerd` convenience words). + +`containerd` is the ENGINE WORD. `nerdctl` is only the CLI BINARY that drives it — +the CUE-owned `#EngineBinaries` map (`containerd → nerdctl`) is the one home for +that mapping, and the retired engine spelling is gone (R5, no alias). ## What it is @@ -23,11 +28,34 @@ registry. Every engine op (`describe` / `binary` / `gpu_args` / `start_plan` / `unit_emit` / `network_ensure`) is answered by delegating to the pure body -`container.InvokeEngineOp("nerdctl", …)` in the spec module — the SAME body the -compiled-in `engine:podman` / `engine:docker` providers serve. So the engine +`container.InvokeEngineOp("containerd", …)` in the spec module — the SAME body +the compiled-in `engine:podman` / `engine:docker` providers serve. So the engine class has one implementation and two placements. +## The containerd start-plan policy + +The one provider-specific policy is applied to `start_plan`: + +- **A runtime handler is ALWAYS named.** nerdctl's unqualified default handler is + `runc` while the CRI default is `crun`, so an unqualified plan would silently + select a different handler; the plugin fills an empty `runtime` with `crun` + (validated against the capability's `runtime_handlers`) so `--runtime` is + always emitted. +- **Rootful is not silently rootless.** A rootful plan gets the capability's + `privilege_prefix` (e.g. `sudo -n`) as an argv prefix, taken from data. + `unit_emit` is a pure renderer and its envelope carries no scope field: it + renders a user unit from the `start_argv` it is given. A rootful unit carries + the prefix and `--runtime` only when the caller passes the rootful start-plan + argv; the provider does not re-apply the policy at the unit boundary. +- **Defaults are not re-spelled.** No `--address` / `--namespace` is emitted for + the capability defaults; a caller's override rides `extra_args`. + +The capability row itself (`supports_rootless` / `supports_rootful`, the +mode-split workload users, `address`, `namespace`, `privilege_prefix`, +`substrates`, `runtime_handlers`) is served by the spec module's engine body — +one table, no copy here. + ## Use when -Working with the `engine:nerdctl` provider, the plugin-nerdctl candy, or the +Working with the `engine:containerd` provider, the plugin-nerdctl candy, or the engine provider-class placement model. diff --git a/AGENTS.md b/AGENTS.md index 956a1a6..371e367 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,9 @@ # AGENTS.md — plugin-nerdctl -Standalone out-of-tree plugin repo serving the `engine:nerdctl` engine backend -(`engine:nerdctl` + `verb:nerdctl` + `command:nerdctl`). The plugin is a Go +Standalone out-of-tree plugin repo serving the `engine:containerd` engine backend +(`engine:containerd` + `verb:containerd` + `command:containerd`). `containerd` is +the ENGINE WORD; `nerdctl` is only the CLI binary it is driven through (the +CUE-owned `#EngineBinaries` map), never an engine word. The plugin is a Go module at `candy/plugin-nerdctl/` (module path `github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl`); the root `charly.yml` declares `discover: box` + `discover: candy` so the repo is a @@ -12,18 +14,21 @@ Canonical files: - `candy/plugin-nerdctl/charly.yml` — the `plugin-nerdctl:` candy entity (`plugin:` block, `plan:` checks) and the embedded `plugin-nerdctl-skill:` skill entity. -- `candy/plugin-nerdctl/` — the Go source: `plugin.go` (provider + meta), - `schema/plugin.cue`, `cmd/serve/main.go`. +- `candy/plugin-nerdctl/` — the Go source: `plugin.go` (provider + meta + the + containerd start-plan policy), `schema/plugin.cue`, `cmd/serve/main.go`. +- `.agents/skills/plugin-nerdctl/SKILL.md` — the skill body, the one canonical + base the corpus projects `/charly-internals:plugin-nerdctl` from. The candy's + `plugin-nerdctl-skill:` entity is the owner registration paired with that body. - `README.md` — user overview only; never agent guidance. -The repo carries **no** per-repo workflow file; the org-wide required workflow is -what gates the merge. +The repo carries only the `tag-on-merge` caller (`.github/workflows/`); the +org-wide required workflow is what gates the merge. ## Load these skills first (R0) -- `/charly-internals:plugin-nerdctl` — the `engine:nerdctl` provider reference - (projected from this candy's own `plugin-nerdctl-skill:` entity). Load before - changing an engine op. +- `/charly-internals:plugin-nerdctl` — the `engine:containerd` provider reference + (projected from this repo's `.agents/skills/plugin-nerdctl/SKILL.md`). Load + before changing an engine op. - `/charly-internals:plugin` — the plugin authoring reference: the `plugin:` block, the unified Provider model, the per-plugin CUE-schema contract, placement. @@ -34,7 +39,8 @@ what gates the merge. ## Build / validate / test - `go build ./...` in `candy/plugin-nerdctl/` — compile the plugin module. -- `go test ./...` in `candy/plugin-nerdctl/` — the plugin's Go tests. +- `go test ./...` in `candy/plugin-nerdctl/` — the plugin's Go tests, including + the out-of-process go-plugin round trip (skipped with `-short`). - `charly box validate` at the repo root — the structural check (the candy + `plugin:` block, CUE schema). - The merge gate is the **org-wide** `charly/pr-validator` (required check @@ -45,11 +51,21 @@ what gates the merge. - Edit the `plugin-nerdctl:` candy entity, the Go source, and `schema/plugin.cue` **together** — the schema is the served declaration surface. -- Keep the delegation to `container.InvokeEngineOp("nerdctl", …)` in the spec +- Keep the delegation to `container.InvokeEngineOp("containerd", …)` in the spec module: the engine class has ONE implementation served from either placement. Do not fork the engine body here. -- Keep the `plugin-nerdctl-skill:` entity in step with any provider-surface - change — it is the projected source for `/charly-internals:plugin-nerdctl`. +- The PROVIDER-SPECIFIC policy belongs in the provider: `start_plan` always names + a runtime handler (nerdctl's unqualified default is runc, the CRI default + crun), and a rootful plan is prefixed with the capability's + `privilege_prefix` — never a hard-coded escalation, never silently rootless. +- Keep `nerdctl` out of the engine vocabulary. It is the CLI binary named by + `spec.EngineBinaries["containerd"]`; a second engine spelling is a cutover + regression. +- Keep `.agents/skills/plugin-nerdctl/SKILL.md` in step with any provider-surface + change: it is the skill BODY, the one canonical base the corpus reader resolves. + The candy's `plugin-nerdctl-skill:` entity is the owner registration the reader + pairs with that body (name/family/owner only — it carries no body); update its + metadata only when the name/family/owner changes. ## Landing diff --git a/README.md b/README.md index 9fa1ebf..79a5039 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # plugin-nerdctl -The out-of-tree charly ENGINE plugin serving `engine:nerdctl` — the containerd -engine backend for OpenCharly pods, plus the `verb:nerdctl` / `command:nerdctl` -convenience words. +The out-of-tree charly ENGINE plugin serving `engine:containerd` — the containerd +engine backend for OpenCharly pods, plus the `verb:containerd` / +`command:containerd` convenience words. + +`containerd` is the ENGINE WORD. `nerdctl` is only the CLI BINARY that drives it: +the CUE-owned `#EngineBinaries` map (`containerd → nerdctl`) is the one home for +that mapping, and the retired engine spelling is gone (R5, no alias). The plugin is a standalone Go module: charly's loader host-builds `./cmd/serve` and connects it **out-of-process** over go-plugin gRPC via the plugin SDK. It answers every engine op by delegating to the pure body -`container.InvokeEngineOp("nerdctl", …)` in the spec module — the SAME body the +`container.InvokeEngineOp("containerd", …)` in the spec module — the SAME body the compiled-in `engine:podman` / `engine:docker` providers serve — so the engine class has one implementation and two placements. @@ -15,12 +19,23 @@ class has one implementation and two placements. | Capability | Surface | |---|---| -| `engine:nerdctl` | the engine provider-class op envelope: `describe` / `binary` / `gpu_args` / `start_plan` / `unit_emit` / `network_ensure` | -| `verb:nerdctl` | the `nerdctl:` convenience verb | -| `command:nerdctl` | the `charly nerdctl` convenience command | +| `engine:containerd` | the engine provider-class op envelope: `describe` / `binary` / `gpu_args` / `start_plan` / `unit_emit` / `network_ensure` | +| `verb:containerd` | the `containerd:` convenience verb | +| `command:containerd` | the `charly containerd` convenience command | + +The start plan always names an OCI runtime handler (nerdctl's unqualified default +is `runc`, the CRI default `crun`), and a rootful plan is prefixed with the +capability's `privilege_prefix` (e.g. `sudo -n`) instead of being treated as +rootless. The capability defaults for `address` / `namespace` are never +re-spelled; an override rides `extra_args`. + +`unit_emit` is a pure renderer and its envelope carries no scope field: a +rootful unit is a user unit whose `ExecStart` is the (prefixed, +`--runtime`-bearing) start-plan argv the caller supplies. This provider does not +re-apply the policy at the unit boundary. Composes with the `layer-nerdctl` candy (the packages + rootless -containerd/buildkit/CNI stack) to deploy a pod with `engine: nerdctl`. +containerd/buildkit/CNI stack) to deploy a pod with `engine: containerd`. ## How to use it @@ -34,20 +49,23 @@ Compose the plugin candy alongside the layer candy in a box's `candy:` list: Then select the engine on the deploy: ```yaml -engine: nerdctl +engine: containerd ``` ## Layout -- `candy/plugin-nerdctl/` — the plugin module: `plugin.go` (provider + meta), - `schema/plugin.cue`, and `cmd/serve/main.go`. +- `candy/plugin-nerdctl/` — the plugin module: `plugin.go` (provider + meta + + the containerd start-plan policy), `schema/plugin.cue`, and + `cmd/serve/main.go`. +- `.agents/skills/plugin-nerdctl/SKILL.md` — the skill body, the one canonical + base the corpus projects `/charly-internals:plugin-nerdctl` from. The candy's + `plugin-nerdctl-skill:` entity is the owner registration paired with that body + (name/family/owner only); it carries no body. - `charly.yml` — the root project manifest (`discover: box` + `discover: candy`). -- The candy's `plugin-nerdctl-skill:` entity is the projected source for the - `/charly-internals:plugin-nerdctl` skill. ## Related -- Owning skill: `/charly-internals:plugin-nerdctl` — the engine:nerdctl provider - reference (projected from this candy's own `skill:` entity). +- Owning skill: `/charly-internals:plugin-nerdctl` — the engine:containerd + provider reference (projected from this repo's skill body). - `/charly-internals:plugin` — the plugin/provider model and placement. - [`opencharly/charly`](https://github.com/opencharly/charly) — the charly CLI. diff --git a/candy/plugin-nerdctl/charly.yml b/candy/plugin-nerdctl/charly.yml index 7ea6266..722b6fd 100644 --- a/candy/plugin-nerdctl/charly.yml +++ b/candy/plugin-nerdctl/charly.yml @@ -1,41 +1,58 @@ -# plugin-nerdctl — the out-of-tree charly ENGINE plugin serving `engine:nerdctl`. +# plugin-nerdctl — the out-of-tree charly ENGINE plugin serving `engine:containerd`. # # A standalone Go module served over go-plugin gRPC via the charly plugin SDK; the # charly loader host-builds ./cmd/serve and connects it OUT-OF-PROCESS. It answers # every engine op by delegating to the pure body container.InvokeEngineOp in the # spec module — the SAME body the compiled-in engine:podman/engine:docker providers # serve — so the engine class has one implementation and two placements. +# +# `containerd` is the ENGINE WORD. `nerdctl` is only the CLI binary that drives it +# (#EngineBinaries maps containerd -> nerdctl); the retired engine spelling is gone +# (R5, no alias). plugin-nerdctl: candy: description: | - The out-of-tree charly ENGINE plugin serving engine:nerdctl (plus the - verb:nerdctl / command:nerdctl convenience words). It answers the engine - provider-class op envelope (describe / binary / gpu_args / start_plan / - unit_emit / network_ensure) by delegating to the pure body - container.InvokeEngineOp("nerdctl", …) in the spec module, so the engine - class has one implementation served from either placement. A standalone Go - module (go.mod + cmd/serve) host-built and connected over go-plugin gRPC. - Composes with the layer-nerdctl candy (the packages + rootless - containerd/buildkit/CNI stack) to deploy a pod with engine: nerdctl. + The out-of-tree charly ENGINE plugin serving engine:containerd (plus the + verb:containerd / command:containerd convenience words). containerd is the + engine word; nerdctl is the CLI binary named by the CUE-owned #EngineBinaries + map, never a literal here. It answers the engine provider-class op envelope + (describe / binary / gpu_args / start_plan / unit_emit / network_ensure) by + delegating to the pure body container.InvokeEngineOp("containerd", …) in the + spec module, so the engine class has one implementation served from either + placement. Its start plan always names an OCI runtime handler (nerdctl's + unqualified default is runc, the CRI default crun) and prefixes a rootful + plan with the capability's privilege_prefix (e.g. sudo -n), so a rootful + request is never silently run as rootless; the capability defaults for + address and namespace are never re-spelled. The unit_emit op is a pure + renderer and its envelope carries no scope field: a rootful unit is a user + unit whose ExecStart is the (prefixed, --runtime-bearing) start-plan argv + the caller supplies, and this provider does not re-apply the policy at the + unit boundary. A standalone Go module + (go.mod + cmd/serve) host-built and connected over go-plugin gRPC. Composes + with the layer-nerdctl candy (the packages + rootless + containerd/buildkit/CNI stack) to deploy a pod with engine: containerd. plugin: providers: - - engine:nerdctl - - verb:nerdctl - - command:nerdctl + - engine:containerd + - verb:containerd + - command:containerd source: github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl plan: - - check: the out-of-tree engine:nerdctl plugin ships a buildable Go module (go.mod + cmd/serve) the host can build + serve out-of-process - id: nerdctl-module-present + - check: the out-of-tree engine:containerd plugin ships a buildable Go module (go.mod + cmd/serve) the host can build + serve out-of-process + id: containerd-module-present context: - build command: "true" - - check: the plugin declares the engine:nerdctl provider word - id: nerdctl-provider-declared + - check: the plugin declares the engine:containerd provider word + id: containerd-provider-declared context: - build - command: grep -q 'engine:nerdctl' charly.yml + command: grep -q 'engine:containerd' charly.yml # ---- HARNESS SKILL ---- +# Owner registration for the skill body at .agents/skills/plugin-nerdctl/SKILL.md, +# the one canonical base the corpus reader resolves. This entity names the owner +# (name/family/owner) and carries no body; the body is the file at the repo root. plugin-nerdctl-skill: skill: name: plugin-nerdctl diff --git a/candy/plugin-nerdctl/cmd/serve/main.go b/candy/plugin-nerdctl/cmd/serve/main.go index 96af43c..38171f8 100644 --- a/candy/plugin-nerdctl/cmd/serve/main.go +++ b/candy/plugin-nerdctl/cmd/serve/main.go @@ -1,4 +1,4 @@ -// Command serve is the OUT-OF-PROCESS entrypoint for the nerdctl engine plugin: a +// Command serve is the OUT-OF-PROCESS entrypoint for the containerd engine plugin: a // thin shim serving the importable provider over go-plugin gRPC via sdk.Serve. The // SAME NewProvider()/NewMeta() compile INTO charly in-process when listed in // compiled_plugins; this binary is host-built + connected only when they are NOT — diff --git a/candy/plugin-nerdctl/containerd_engine_test.go b/candy/plugin-nerdctl/containerd_engine_test.go new file mode 100644 index 0000000..891822f --- /dev/null +++ b/candy/plugin-nerdctl/containerd_engine_test.go @@ -0,0 +1,470 @@ +package nerdctl + +// containerd_engine_test.go — the engine-word cutover contract. +// +// The spec module's engine vocabulary (schema/engine.cue #EngineName at +// v0.2026295.1243) is podman|docker|containerd, and `nerdctl` is DELETED as an +// engine word (it survives only as the CLI BINARY the containerd word is driven +// through, per #EngineBinaries). These tests FAIL while this plugin still serves +// the retired word and pass once it serves engine:containerd: +// +// - TestProviderWordsAreContainerd — the served capability words +// - TestEngineWordIsContainerdBinaryIsNerdctl — the word/binary split, from data +// - TestStartPlanAlwaysCarriesRuntime — never rely on nerdctl's default handler +// - TestStartPlanOmitsDefaultAddressNamespace — capability defaults are not re-spelled +// - TestRootfulPlanUsesPrivilegePrefix — rootful is NOT silently treated as rootless +// - TestDescribeServesContainerdCapabilityRow — the new capability fields over the wire +// - TestOutOfProcessServeRoundTrip — the real go-plugin boundary (slow) + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/hashicorp/go-plugin" + "github.com/opencharly/spec/container" + "github.com/opencharly/spec/ops" + pb "github.com/opencharly/spec/proto" + "github.com/opencharly/spec/spec" + "github.com/opencharly/spec/transport" +) + +// providedWords returns class:word for every capability the plugin advertises. +func providedWords(t *testing.T) map[string]bool { + t.Helper() + caps, err := NewMeta().Describe(context.Background(), &pb.Empty{}) + if err != nil { + t.Fatalf("Describe: %v", err) + } + got := make(map[string]bool, len(caps.GetProvided())) + for _, c := range caps.GetProvided() { + got[c.GetClass()+":"+c.GetWord()] = true + } + return got +} + +// TestProviderWordsAreContainerd pins the hard cutover: the plugin serves the +// engine:containerd word (plus its verb/command convenience words) and NOT the +// retired engine:nerdctl spelling. It is the negative arm: with the pre-cutover +// plugin it reports engine:nerdctl/verb:nerdctl/command:nerdctl. +func TestProviderWordsAreContainerd(t *testing.T) { + got := providedWords(t) + for _, want := range []string{"engine:containerd", "verb:containerd", "command:containerd"} { + if !got[want] { + t.Errorf("plugin does not advertise %s; got %v", want, got) + } + } + for _, retired := range []string{"engine:nerdctl", "verb:nerdctl", "command:nerdctl"} { + if got[retired] { + t.Errorf("plugin still advertises the retired engine word %s", retired) + } + } + if len(got) != 3 { + t.Errorf("plugin advertises %d capabilities, want exactly 3: %v", len(got), got) + } +} + +// TestEngineWordIsContainerdBinaryIsNerdctl pins the word/binary split from the +// CUE-owned data: `containerd` is the engine WORD, `nerdctl` is only the binary, +// and the word is the one the closed spec vocabulary accepts. +func TestEngineWordIsContainerdBinaryIsNerdctl(t *testing.T) { + if engineWord != "containerd" { + t.Fatalf("engineWord = %q, want containerd (the closed engine vocabulary)", engineWord) + } + if !container.IsEngineName(engineWord) { + t.Fatalf("%q is not a member of spec.EngineNames = %v", engineWord, spec.EngineNames) + } + if container.IsEngineName("nerdctl") { + t.Fatalf("nerdctl is still a member of spec.EngineNames = %v", spec.EngineNames) + } + if got, want := container.EngineBinary(engineWord), spec.EngineBinaries["containerd"]; got != want { + t.Fatalf("EngineBinary(%q) = %q, want the #EngineBinaries value %q", engineWord, got, want) + } + if spec.EngineBinaries["containerd"] != "nerdctl" { + t.Fatalf("#EngineBinaries[containerd] = %q, want nerdctl", spec.EngineBinaries["containerd"]) + } +} + +// invokeStartPlan calls the provider's engine start_plan op and decodes the reply. +func invokeStartPlan(t *testing.T, req spec.EngineStartPlanRequest) spec.EngineStartPlanReply { + t.Helper() + params, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal start_plan request: %v", err) + } + reply, err := NewProvider().Invoke(context.Background(), &pb.InvokeRequest{ + Reserved: engineWord, + Op: ops.OpEngineStartPlan, + ParamsJson: params, + }) + if err != nil { + t.Fatalf("start_plan invoke: %v", err) + } + var out spec.EngineStartPlanReply + if err := json.Unmarshal(reply.GetResultJson(), &out); err != nil { + t.Fatalf("decode start_plan reply: %v", err) + } + if out.Error != "" { + t.Fatalf("start_plan reply error: %s", out.Error) + } + return out +} + +// TestStartPlanAlwaysCarriesRuntime pins the policy that the containerd plan +// NEVER relies on the CLI's unqualified default handler: nerdctl's default is +// runc while the CRI default is crun, so an unqualified plan would silently run +// a different handler than the one the engine is configured for. +func TestStartPlanAlwaysCarriesRuntime(t *testing.T) { + out := invokeStartPlan(t, spec.EngineStartPlanRequest{ + Name: "charly-test", + Image: "docker.io/library/alpine:3.21", + Detach: true, + }) + if !hasFlagValue(out.Argv, "--runtime") { + t.Fatalf("start_plan argv has no --runtime: %v", out.Argv) + } + if got := flagValue(out.Argv, "--runtime"); got != wantDefaultRuntime { + t.Fatalf("--runtime = %q, want the named default %q", got, wantDefaultRuntime) + } + // The binary is the #EngineBinaries value, never a literal in the plugin. + if got, want := out.Argv[0], spec.EngineBinaries["containerd"]; got != want { + t.Fatalf("argv[0] = %q, want the #EngineBinaries binary %q", got, want) + } + // An explicitly requested handler is honoured unchanged. + explicit := invokeStartPlan(t, spec.EngineStartPlanRequest{Name: "charly-test", Image: "img", Runtime: "runc"}) + if got := flagValue(explicit.Argv, "--runtime"); got != "runc" { + t.Fatalf("explicit --runtime = %q, want runc", got) + } +} + +// TestStartPlanOmitsDefaultAddressNamespace pins that the plan does not re-spell +// the capability's DEFAULTS: no --address / --namespace is emitted unless the +// caller supplies an override (the start-plan envelope carries no such fields; +// an override rides extra_args and passes through unchanged). +func TestStartPlanOmitsDefaultAddressNamespace(t *testing.T) { + out := invokeStartPlan(t, spec.EngineStartPlanRequest{Name: "charly-test", Image: "img"}) + for _, flag := range []string{"--address", "--namespace"} { + if hasFlag(out.Argv, flag) { + t.Errorf("default plan emitted %s: %v", flag, out.Argv) + } + } + + override := invokeStartPlan(t, spec.EngineStartPlanRequest{ + Name: "charly-test", + Image: "img", + ExtraArgs: []string{"--address", "/run/user/1000/containerd/containerd.sock", "--namespace", "k8s.io"}, + }) + for _, want := range []string{"/run/user/1000/containerd/containerd.sock", "k8s.io"} { + if !containsArg(override.Argv, want) { + t.Errorf("caller override %q did not pass through: %v", want, override.Argv) + } + } +} + +// TestRootfulPlanUsesPrivilegePrefix pins that rootful is NOT silently treated +// as rootless: the rootful plan is prefixed with the capability's +// privilege_prefix (data), and the mode-specific workload-user fact applies. +func TestRootfulPlanUsesPrivilegePrefix(t *testing.T) { + capability, ok := container.EngineCapabilityFor(engineWord) + if !ok { + t.Fatalf("no capability row for %q", engineWord) + } + if !capability.SupportsRootful { + t.Fatalf("%q capability does not declare supports_rootful", engineWord) + } + + rootful := invokeStartPlan(t, spec.EngineStartPlanRequest{Name: "charly-test", Image: "img", Rootful: true}) + prefix := strings.Fields(capability.PrivilegePrefix) + if len(prefix) == 0 { + t.Fatalf("%q declares supports_rootful with an empty privilege_prefix", engineWord) + } + if len(rootful.Argv) < len(prefix)+1 { + t.Fatalf("rootful argv %v is shorter than the privilege prefix %v plus the binary", rootful.Argv, prefix) + } + if !reflect.DeepEqual(rootful.Argv[:len(prefix)], prefix) { + t.Fatalf("rootful argv prefix = %v, want the capability privilege_prefix %v", rootful.Argv[:len(prefix)], prefix) + } + if got, want := rootful.Argv[len(prefix)], spec.EngineBinaries["containerd"]; got != want { + t.Fatalf("rootful argv binary = %q, want %q", got, want) + } + if hasFlag(rootful.Argv, "--user") { + t.Errorf("rootful plan forced a workload user (workload_user_rootful is empty): %v", rootful.Argv) + } + + rootless := invokeStartPlan(t, spec.EngineStartPlanRequest{Name: "charly-test", Image: "img"}) + if !reflect.DeepEqual(rootless.Argv[:1], []string{spec.EngineBinaries["containerd"]}) { + t.Fatalf("rootless argv does not start with the binary: %v", rootless.Argv) + } + if got, want := flagValue(rootless.Argv, "--user"), capability.WorkloadUserRootless; got != want { + t.Errorf("rootless --user = %q, want workload_user_rootless %q", got, want) + } +} + +// TestDescribeServesContainerdCapabilityRow pins every field the spec cutover +// added: the plugin serves the capability row over the engine describe op, from +// the ONE spec table, never a second copy. +func TestDescribeServesContainerdCapabilityRow(t *testing.T) { + reply, err := NewProvider().Invoke(context.Background(), &pb.InvokeRequest{ + Reserved: engineWord, + Op: ops.OpEngineDescribe, + }) + if err != nil { + t.Fatalf("describe invoke: %v", err) + } + var out spec.EngineDescribeReply + if err := json.Unmarshal(reply.GetResultJson(), &out); err != nil { + t.Fatalf("decode describe reply: %v", err) + } + if out.Error != "" || out.Capability == nil { + t.Fatalf("describe reply = %+v", out) + } + c := *out.Capability + checks := []struct { + name string + got any + want any + }{ + {"name", c.Name, spec.EngineName("containerd")}, + {"binary", c.Binary, spec.EngineBinaries["containerd"]}, + {"supports_rootless", c.SupportsRootless, true}, + {"supports_rootful", c.SupportsRootful, true}, + {"workload_user_rootless", c.WorkloadUserRootless, "0"}, + {"workload_user_rootful", c.WorkloadUserRootful, ""}, + {"namespace", c.Namespace, "default"}, + {"substrates", c.Substrates, []string{"pod"}}, + } + for _, chk := range checks { + if !reflect.DeepEqual(chk.got, chk.want) { + t.Errorf("capability %s = %#v, want %#v", chk.name, chk.got, chk.want) + } + } + if c.Address == "" { + t.Error("capability address is empty") + } + if c.PrivilegePrefix == "" { + t.Error("capability privilege_prefix is empty") + } + for _, handler := range []string{"crun", "krun", "runc", "nvidia"} { + if !containsArg(c.RuntimeHandlers, handler) { + t.Errorf("capability runtime_handlers %v missing %q", c.RuntimeHandlers, handler) + } + } +} + +// TestOutOfProcessServeRoundTrip exercises the REAL placement: it host-builds +// cmd/serve and connects to it over go-plugin gRPC (the same handshake + +// dispense key charly uses), then reads Describe and invokes start_plan across +// the process boundary. Skipped with -short (it builds + execs a binary). +func TestOutOfProcessServeRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("builds + execs the plugin binary (slow)") + } + dir := t.TempDir() + bin := filepath.Join(dir, "serve") + build := exec.Command("go", "build", "-o", bin, "./cmd/serve") + build.Env = os.Environ() + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build ./cmd/serve: %v\n%s", err, out) + } + + cmd := exec.Command(bin) + cmd.Env = append(os.Environ(), "CHARLY_PLUGIN=1") + client := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: transport.Handshake, + Plugins: transport.PluginMap(nil, nil), + Cmd: cmd, + AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, + AutoMTLS: true, + }) + defer client.Kill() + + rpc, err := client.Client() + if err != nil { + t.Fatalf("plugin client: %v", err) + } + raw, err := rpc.Dispense(transport.DispenseKey) + if err != nil { + t.Fatalf("dispense: %v", err) + } + conn, ok := raw.(*transport.Conn) + if !ok { + t.Fatalf("dispensed %T, want *transport.Conn", raw) + } + ctx := context.Background() + caps, err := conn.Meta.Describe(ctx, &pb.Empty{}) + if err != nil { + t.Fatalf("out-of-process Describe: %v", err) + } + var words []string + for _, c := range caps.GetProvided() { + words = append(words, c.GetClass()+":"+c.GetWord()) + } + if !containsArg(words, "engine:containerd") || containsArg(words, "engine:nerdctl") { + t.Fatalf("out-of-process capabilities = %v, want engine:containerd and no engine:nerdctl", words) + } + + params, _ := json.Marshal(spec.EngineStartPlanRequest{Name: "charly-test", Image: "img"}) + rep, err := conn.Provider.Invoke(ctx, &pb.InvokeRequest{ + Reserved: engineWord, + Op: ops.OpEngineStartPlan, + ParamsJson: params, + }) + if err != nil { + t.Fatalf("out-of-process start_plan: %v", err) + } + var plan spec.EngineStartPlanReply + if err := json.Unmarshal(rep.GetResultJson(), &plan); err != nil { + t.Fatalf("decode out-of-process start_plan: %v", err) + } + if got := flagValue(plan.Argv, "--runtime"); got != wantDefaultRuntime { + t.Fatalf("out-of-process --runtime = %q, want %q (argv %v)", got, wantDefaultRuntime, plan.Argv) + } + + // unit_emit across the same process boundary: the renderer carries the argv + // it is given, including --runtime, into ExecStart. + unitParams, _ := json.Marshal(spec.EngineUnitRequest{Name: "charly-e2e", RunMode: "systemd-unit", StartArgv: plan.Argv}) + unitRep, err := conn.Provider.Invoke(ctx, &pb.InvokeRequest{ + Reserved: engineWord, + Op: ops.OpEngineUnitEmit, + ParamsJson: unitParams, + }) + if err != nil { + t.Fatalf("out-of-process unit_emit: %v", err) + } + var unitReply spec.EngineUnitReply + if err := json.Unmarshal(unitRep.GetResultJson(), &unitReply); err != nil { + t.Fatalf("decode out-of-process unit_emit: %v", err) + } + if !strings.Contains(unitReply.Files["charly-charly-e2e.service"], "--runtime "+wantDefaultRuntime) { + t.Fatalf("out-of-process unit_emit did not carry --runtime %s: %+v", wantDefaultRuntime, unitReply) + } +} + +// wantDefaultRuntime is the handler the plan MUST name when the caller names +// none: nerdctl's unqualified default is runc, while the CRI default is crun. +const wantDefaultRuntime = "crun" + +// invokeUnitEmit calls the provider's engine unit_emit op and decodes the reply. +func invokeUnitEmit(t *testing.T, req spec.EngineUnitRequest) spec.EngineUnitReply { + t.Helper() + params, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal unit_emit request: %v", err) + } + reply, err := NewProvider().Invoke(context.Background(), &pb.InvokeRequest{ + Reserved: engineWord, + Op: ops.OpEngineUnitEmit, + ParamsJson: params, + }) + if err != nil { + t.Fatalf("unit_emit invoke: %v", err) + } + var out spec.EngineUnitReply + if err := json.Unmarshal(reply.GetResultJson(), &out); err != nil { + t.Fatalf("decode unit_emit reply: %v", err) + } + if out.Error != "" { + t.Fatalf("unit_emit reply error: %s", out.Error) + } + return out +} + +// TestUnitEmitRendersTheSuppliedArgv pins the unit_emit boundary truthfully. +// The op is a PURE RENDERER: the spec body renders whatever start_argv it is +// given, and the envelope carries no scope/rootful field. So a rootful unit +// carries the privilege prefix and --runtime only because the caller passes the +// rootful start-plan argv; this provider does not re-apply the policy at the +// unit boundary (and the test proves it does not, with an unprefixed control). +func TestUnitEmitRendersTheSuppliedArgv(t *testing.T) { + capability, ok := container.EngineCapabilityFor(engineWord) + if !ok { + t.Fatalf("no capability row for %q", engineWord) + } + prefix := strings.Fields(capability.PrivilegePrefix) + plan := invokeStartPlan(t, spec.EngineStartPlanRequest{Name: "charly-test", Image: "img", Rootful: true}) + if len(plan.Argv) < len(prefix)+1 { + t.Fatalf("rootful argv %v is shorter than the privilege prefix %v plus the binary", plan.Argv, prefix) + } + + reply := invokeUnitEmit(t, spec.EngineUnitRequest{ + Name: "charly-test", + RunMode: string(capability.RunMode), + StartArgv: plan.Argv, + StopArgv: append(append([]string{}, prefix...), capability.Binary, "rm", "-f", "charly-test"), + }) + unit := reply.Files["charly-charly-test.service"] + if unit == "" { + t.Fatalf("unit_emit returned no unit file: %+v", reply) + } + if want := "ExecStart=" + strings.Join(plan.Argv, " "); !strings.Contains(unit, want) { + t.Errorf("rendered unit does not carry the start-plan argv (%q):\n%s", want, unit) + } + if want := "ExecStart=" + strings.Join(prefix, " ") + " " + capability.Binary + " run"; !strings.Contains(unit, want) { + t.Errorf("rendered rootful unit does not start with the privilege prefix (%q):\n%s", want, unit) + } + if !strings.Contains(unit, "--runtime "+wantDefaultRuntime) { + t.Errorf("rendered rootful unit does not carry --runtime %s:\n%s", wantDefaultRuntime, unit) + } + if !strings.Contains(unit, "WantedBy=default.target") { + t.Errorf("rendered unit is not the user unit the envelope's renderer produces:\n%s", unit) + } + + // Control: the provider does NOT re-apply the policy at the unit boundary. + // An unprefixed argv is rendered exactly as supplied, with no --runtime added. + bare := invokeUnitEmit(t, spec.EngineUnitRequest{ + Name: "charly-bare", + RunMode: string(capability.RunMode), + StartArgv: []string{capability.Binary, "run", "img"}, + }) + bareUnit := bare.Files["charly-charly-bare.service"] + if want := "ExecStart=" + capability.Binary + " run img"; !strings.Contains(bareUnit, want) { + t.Errorf("unit_emit did not render the supplied argv (%q):\n%s", want, bareUnit) + } + if strings.Contains(bareUnit, "--runtime") { + t.Errorf("unit_emit re-applied the runtime policy at the unit boundary:\n%s", bareUnit) + } +} + +// --- small argv helpers (test-local) --- + +func containsArg(args []string, want string) bool { + for _, a := range args { + if a == want { + return true + } + } + return false +} + +func hasFlag(args []string, flag string) bool { + return flagValue(args, flag) != "" || containsArg(args, flag) +} + +func flagValue(args []string, flag string) string { + for i, a := range args { + if a == flag { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if strings.HasPrefix(a, flag+"=") { + return strings.TrimPrefix(a, flag+"=") + } + } + return "" +} + +func hasFlagValue(args []string, flag string) bool { + for _, a := range args { + if a == flag || strings.HasPrefix(a, flag+"=") { + return true + } + } + return false +} diff --git a/candy/plugin-nerdctl/go.mod b/candy/plugin-nerdctl/go.mod index 85bdc1c..f3f9c9d 100644 --- a/candy/plugin-nerdctl/go.mod +++ b/candy/plugin-nerdctl/go.mod @@ -1,11 +1,12 @@ module github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl -go 1.26.4 +go 1.26.8 require ( cuelang.org/go v0.16.1 - github.com/opencharly/sdk v0.2026266.623 - github.com/opencharly/spec v0.2026266.545 + github.com/hashicorp/go-plugin v1.8.0 + github.com/opencharly/sdk v0.2026294.1520 + github.com/opencharly/spec v0.2026295.1243 ) require ( @@ -16,20 +17,21 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.8.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect - google.golang.org/grpc v1.61.0 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/candy/plugin-nerdctl/go.sum b/candy/plugin-nerdctl/go.sum index 422b217..9695beb 100644 --- a/candy/plugin-nerdctl/go.sum +++ b/candy/plugin-nerdctl/go.sum @@ -10,17 +10,22 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -60,19 +65,18 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/opencharly/sdk v0.2026266.623 h1:wXyRpPwuo/vOZPMh/OuGK5VO2ZCyG0orxDLiVMwBdcY= -github.com/opencharly/sdk v0.2026266.623/go.mod h1:bai2KyfdFKj/IyJhGgt6Av6hdDttwguB744Ch9JKFh8= -github.com/opencharly/spec v0.2026266.545 h1:RcAMcaLCdzwXv7Y6YX6vwQ/xYhGxFwMucXJUJ8nON6w= -github.com/opencharly/spec v0.2026266.545/go.mod h1:yaB8ABSTiJClK1xq/zCtWizcqfKKOhnuKcgGrHwxmW8= +github.com/opencharly/sdk v0.2026294.1520 h1:43EGZK2h1a5udbvgvToZ7EDZZubADN6IFZk1mf1L6Fo= +github.com/opencharly/sdk v0.2026294.1520/go.mod h1:VhRRULJmaVXr6hpSKcFdDGr4NKx5txRZdUDp6EHDeDs= +github.com/opencharly/spec v0.2026295.1243 h1:70q66n3NLbwWgHImNKBGeAViJKKo/yQGjfPWHlHVMN8= +github.com/opencharly/spec v0.2026295.1243/go.mod h1:VrjzjP2glMbDwP4JNm50OV0MraHFZnFKTl+bVlouoso= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -80,36 +84,50 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= -google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= -google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/candy/plugin-nerdctl/plugin.go b/candy/plugin-nerdctl/plugin.go index 201b1c7..791c0f3 100644 --- a/candy/plugin-nerdctl/plugin.go +++ b/candy/plugin-nerdctl/plugin.go @@ -1,13 +1,25 @@ // Package nerdctl is the out-of-tree charly ENGINE plugin serving the `engine` -// provider class for the nerdctl word — `engine:nerdctl` — plus the `verb:nerdctl` -// / `command:nerdctl` convenience words. +// provider class for the containerd word — `engine:containerd` — plus the +// `verb:containerd` / `command:containerd` convenience words. +// +// The ENGINE WORD is `containerd`; `nerdctl` is only the CLI BINARY that drives +// it (the CUE-owned #EngineBinaries map, spec.EngineBinaries["containerd"]), and +// never an engine word. The spec module's closed vocabulary is podman | docker | +// containerd; the retired engine spelling is deleted (R5, no alias). // // The engine provider is engine-word-keyed DATA + ONE dispatch: this plugin // answers every engine op (describe / binary / gpu_args / start_plan / unit_emit / -// network_ensure) by delegating to the pure body `container.InvokeEngineOp("nerdctl", …)` +// network_ensure) by delegating to the pure body `container.InvokeEngineOp(engineWord, …)` // in the spec module, so the op behavior is written ONCE and served identically by // the compiled-in `engine:podman` / `engine:docker` providers and this -// out-of-process `engine:nerdctl` plugin. +// out-of-process `engine:containerd` plugin. +// +// The one containerd-SPECIFIC policy lives here, on the provider that serves the +// containerd row, and is applied to the start plan BEFORE/AFTER the pure body: +// - a runtime handler is ALWAYS named (nerdctl's unqualified default handler is +// runc while the CRI default is crun — a plan must never rely on a default); +// - a rootful plan is prefixed with the capability's `privilege_prefix` +// (e.g. `sudo -n`), so rootful is never silently treated as rootless. // // Dual-placement by construction: the SAME NewProvider()/NewMeta() compile INTO // charly in-process when listed in compiled_plugins, or cmd/serve serves them @@ -20,25 +32,37 @@ import ( "embed" "encoding/json" "fmt" + "slices" + "strings" "github.com/opencharly/sdk" "github.com/opencharly/spec/container" + "github.com/opencharly/spec/ops" pb "github.com/opencharly/spec/proto" + "github.com/opencharly/spec/spec" ) //go:embed schema/*.cue var schemaFS embed.FS -const calver = "2026.266.1500" +const calver = "2026.295.1306" + +// engineWord is both the registry key ("engine:containerd") and the engine WORD +// the pure body dispatches on. `nerdctl` is NOT an engine word: it is the CLI +// binary #EngineBinaries maps the containerd word to. +const engineWord = "containerd" -// engineWord is both the registry key ("engine:nerdctl") and the engine the pure -// body dispatches on. -const engineWord = "nerdctl" +// defaultRuntimeHandler is the OCI runtime handler a containerd start plan names +// when the caller names none. nerdctl's unqualified default handler is runc, +// while the CRI default is crun, so an unqualified plan would silently select a +// different handler than the engine is configured for. It is validated against +// the capability's CUE-owned runtime_handlers before use — never assumed. +const defaultRuntimeHandler = "crun" -// NewProvider returns the nerdctl provider. +// NewProvider returns the containerd provider. func NewProvider() pb.ProviderServer { return &provider{} } -// NewMeta advertises the engine:nerdctl capability (empty InputDef — the engine +// NewMeta advertises the engine:containerd capability (empty InputDef — the engine // class carries no authored plugin_input; its envelopes live in the base // schema/spec) plus the verb/command convenience words, together with the // plugin's OWN self-contained, package-less CUE schema (schema/plugin.cue) served @@ -56,12 +80,91 @@ func NewMeta() pb.PluginMetaServer { type provider struct{ pb.UnimplementedProviderServer } // Invoke answers every engine op by delegating to the pure fabric servant -// container.InvokeEngineOp, which validates the engine word and the op and errors -// on an unknown one. The verb/command words serve the same envelope. +// container.InvokeEngineOp. The start_plan op is the one op carrying the +// containerd row's own policy (an always-named runtime handler and the rootful +// privilege prefix); every other op is a direct delegation. The verb/command +// words serve the same envelopes. func (provider) Invoke(_ context.Context, req *pb.InvokeRequest) (*pb.InvokeReply, error) { - out, err := container.InvokeEngineOp(engineWord, req.GetOp(), json.RawMessage(req.GetParamsJson())) + op := req.GetOp() + params := json.RawMessage(req.GetParamsJson()) + var ( + out json.RawMessage + err error + ) + if op == ops.OpEngineStartPlan { + out, err = containerdStartPlan(params) + } else { + out, err = container.InvokeEngineOp(engineWord, op, params) + } if err != nil { - return nil, fmt.Errorf("plugin-nerdctl: engine:%s op %q: %w", engineWord, req.GetOp(), err) + return nil, fmt.Errorf("plugin-nerdctl: engine:%s op %q: %w", engineWord, op, err) } return &pb.InvokeReply{ResultJson: out}, nil } + +// containerdStartPlan applies the containerd row's start-plan policy and then +// delegates the argv assembly to the ONE engine body: +// +// 1. a rootful request is checked against the capability (a rootful target must +// be declared) and the workload-user fact the pure body uses is the rootful +// one; +// 2. an empty runtime is filled with defaultRuntimeHandler, validated against +// the capability's runtime_handlers, so `--runtime` is ALWAYS emitted; +// 3. the pure body builds the argv; +// 4. a rootful plan is prefixed with the capability's privilege_prefix as argv +// words (`sudo -n`), taken from data — never a hard-coded escalation. An +// empty prefix means the invoking user already reaches the rootful daemon. +// +// The capability DEFAULTS (address, namespace) are never re-spelled here: a +// default flag is not emitted, and a caller's override rides `extra_args` +// through the pure body unchanged. +func containerdStartPlan(params json.RawMessage) (json.RawMessage, error) { + var req spec.EngineStartPlanRequest + if len(params) > 0 { + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("start_plan: %w", err) + } + } + capability, ok := container.EngineCapabilityFor(engineWord) + if !ok { + return nil, fmt.Errorf("engine %q has no capability row", engineWord) + } + if req.Rootful && !capability.SupportsRootful { + return nil, fmt.Errorf("engine %q does not support rootful targets", engineWord) + } + if req.Runtime == "" { + if !slices.Contains(capability.RuntimeHandlers, defaultRuntimeHandler) { + return nil, fmt.Errorf("engine %q does not declare the default runtime handler %q (handlers: %v)", + engineWord, defaultRuntimeHandler, capability.RuntimeHandlers) + } + req.Runtime = defaultRuntimeHandler + } + adjusted, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("start_plan: %w", err) + } + out, err := container.InvokeEngineOp(engineWord, ops.OpEngineStartPlan, adjusted) + if err != nil { + return nil, err + } + if !req.Rootful { + return out, nil + } + var reply spec.EngineStartPlanReply + if err := json.Unmarshal(out, &reply); err != nil { + return nil, fmt.Errorf("start_plan reply: %w", err) + } + if reply.Error != "" { + return out, nil + } + prefix := strings.Fields(capability.PrivilegePrefix) + if len(prefix) == 0 { + return out, nil + } + reply.Argv = append(prefix, reply.Argv...) + prefixed, err := json.Marshal(reply) + if err != nil { + return nil, fmt.Errorf("start_plan reply: %w", err) + } + return prefixed, nil +} diff --git a/candy/plugin-nerdctl/schema/plugin.cue b/candy/plugin-nerdctl/schema/plugin.cue index 185813e..1ecb4d4 100644 --- a/candy/plugin-nerdctl/schema/plugin.cue +++ b/candy/plugin-nerdctl/schema/plugin.cue @@ -13,11 +13,24 @@ // NO `cue exp gengotypes` artifact — it is the SERVED documentation/config surface, // not a code-generation source. // -// It DOCUMENTS the `engine: nerdctl` word (plus the `verb:` / `command:` convenience words). The engine request/reply envelopes live in the base spec schema (`spec/schema/engine.cue`), so no authored input is declared here. -#NerdctlPlugin: { - // The engine word the plugin serves (the registry key `engine:nerdctl`). - engine: "nerdctl" +// It DOCUMENTS the `engine: containerd` word (plus the `verb:` / `command:` +// convenience words). The engine request/reply envelopes live in the base spec schema +// (`spec/schema/engine.cue`), so no authored input is declared here. `nerdctl` is the +// CLI BINARY the containerd word is driven through (#EngineBinaries), not an engine +// word; the retired engine spelling is deleted (R5, no alias). +#ContainerdPlugin: { + // The engine WORD the plugin serves (the registry key `engine:containerd`). + engine: "containerd" // What the plugin does, in one line (the public-docs surface). contract: string & !="" + + // rootful names how this provider implements a rootful start plan: it prefixes + // the argv with the capability's `privilege_prefix` (e.g. `sudo -n`), taken from + // the spec table, so a rootful request is never silently run as rootless. + // `unit_emit` is a pure renderer and its envelope carries no scope field: it + // renders a user unit from the `start_argv` it is given. A rootful unit carries + // the prefix and `--runtime` only when the caller passes the rootful start-plan + // argv; this provider does not re-apply the policy at the unit boundary. + rootful: "privilege_prefix" } diff --git a/candy/plugin-nerdctl/schema_serve_test.go b/candy/plugin-nerdctl/schema_serve_test.go index 161a4f1..b7d84c6 100644 --- a/candy/plugin-nerdctl/schema_serve_test.go +++ b/candy/plugin-nerdctl/schema_serve_test.go @@ -24,8 +24,8 @@ func TestNewMetaServesNonEmptySchema(t *testing.T) { if strings.TrimSpace(caps.GetSchemaCue()) == "" { t.Fatal("Describe served an EMPTY schema_cue; every plugin MUST ship a non-empty CUE schema") } - if !strings.Contains(caps.GetSchemaCue(), "#NerdctlPlugin") { - t.Fatalf("served schema does not carry #NerdctlPlugin:\n%s", caps.GetSchemaCue()) + if !strings.Contains(caps.GetSchemaCue(), "#ContainerdPlugin") { + t.Fatalf("served schema does not carry #ContainerdPlugin:\n%s", caps.GetSchemaCue()) } }