diff --git a/.agents/skills/plugin-nerdctl/SKILL.md b/.agents/skills/plugin-containerd/SKILL.md similarity index 64% rename from .agents/skills/plugin-nerdctl/SKILL.md rename to .agents/skills/plugin-containerd/SKILL.md index 69d7654..e0c4e1f 100644 --- a/.agents/skills/plugin-nerdctl/SKILL.md +++ b/.agents/skills/plugin-containerd/SKILL.md @@ -1,13 +1,13 @@ --- -name: plugin-nerdctl +name: plugin-containerd 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 + with the engine:containerd provider, the plugin-containerd candy, or the engine provider-class placement model. --- -# plugin-nerdctl +# plugin-containerd The out-of-tree charly ENGINE plugin serving `engine:containerd` (plus the `verb:containerd` / `command:containerd` convenience words). @@ -55,7 +55,25 @@ 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. +## The read-only check verb (`verb:containerd`) + +`containerd: ` is a READ-ONLY check verb (W3.9). Its typed input is +`#ContainerdInput` with `method` as the PRIMARY field, so `containerd: info` +desugars to `{method: "info"}`. It drives the venue's own tooling over the host +executor reverse channel; each method catches a defect nothing else does: + +| method | measures | visible skip (names the mechanism) | +|---|---|---| +| `info` | daemon id + mode from `nerdctl info` **SecurityOptions** (never `Plugins`) | — | +| `runtimes` | the declared CRI handlers (`crictl info`) | `crictl` not installed | +| `cdi_devices` | devices in the **client's** CDI spec dirs (`/etc/cdi`, `/var/run/cdi`) | — (an absent dir is an empty, passing list; a malformed spec FAILS) | +| `criu_preflight` | `criu check --unprivileged --feature seccomp_suspend`: rc **and** the supported line; `--extra` is never used | `criu` not installed | +| `krun_guest` | one throwaway probe guest (`--rm`): kernel, arch, CPU features | no probe `image:`, no `/dev/kvm`, handler not declared | +| `image` | an image present in a **named** namespace | — | + +A skip is never a silent pass: an assertion that cannot run is not green. + ## Use when -Working with the `engine:containerd` provider, the plugin-nerdctl candy, or the -engine provider-class placement model. +Working with the `engine:containerd` provider, the `containerd:` check verb, the +plugin-containerd candy, or the engine provider-class placement model. diff --git a/AGENTS.md b/AGENTS.md index 371e367..c48fa6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,26 @@ -# AGENTS.md — plugin-nerdctl +# AGENTS.md — plugin-containerd 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 +module at `candy/plugin-containerd/` (module path +`github.com/opencharly/plugin-containerd/candy/plugin-containerd`); the root `charly.yml` declares `discover: box` + `discover: candy` so the repo is a project and its candy is scanned. Canonical files: -- `candy/plugin-nerdctl/charly.yml` — the `plugin-nerdctl:` candy entity - (`plugin:` block, `plan:` checks) and the embedded `plugin-nerdctl-skill:` +- `candy/plugin-containerd/charly.yml` — the `plugin-containerd:` candy entity + (`plugin:` block, `plan:` checks) and the embedded `plugin-containerd-skill:` skill entity. -- `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. +- `candy/plugin-containerd/` — the Go source: `plugin.go` (provider + meta + the + containerd start-plan policy), `verb.go` (the READ-ONLY `containerd:` check + verbs), `schema/plugin.cue`, `params/` (generated from the schema by + `params/gen.sh`; never hand-edit `cue_types_gen.go`), `cmd/serve/main.go`. +- `.agents/skills/plugin-containerd/SKILL.md` — the skill body, the one canonical + base the corpus projects `/charly-internals:plugin-containerd` from. The candy's + `plugin-containerd-skill:` entity is the owner registration paired with that body. - `README.md` — user overview only; never agent guidance. The repo carries only the `tag-on-merge` caller (`.github/workflows/`); the @@ -26,8 +28,8 @@ org-wide required workflow is what gates the merge. ## Load these skills first (R0) -- `/charly-internals:plugin-nerdctl` — the `engine:containerd` provider reference - (projected from this repo's `.agents/skills/plugin-nerdctl/SKILL.md`). Load +- `/charly-internals:plugin-containerd` — the `engine:containerd` provider reference + (projected from this repo's `.agents/skills/plugin-containerd/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, @@ -38,9 +40,14 @@ org-wide required workflow is 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, including - the out-of-process go-plugin round trip (skipped with `-short`). +- `go build ./...` in `candy/plugin-containerd/` — compile the plugin module. +- `go test ./...` in `candy/plugin-containerd/` — the plugin's Go tests, including + the out-of-process go-plugin round trip (skipped with `-short`). The live + containerd check-verb arm is opt-in: `CHARLY_CONTAINERD_LIVE=1 go test ./... + -run TestVerbLiveLocal` (and `CHARLY_CONTAINERD_LIVE_IMAGE=` for the + `image`/`krun_guest` methods); without it the arm skips visibly. +- `go generate ./params` in `candy/plugin-containerd/` — regenerate + `params/cue_types_gen.go` from `schema/plugin.cue`; a second run is a no-op. - `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 @@ -49,7 +56,7 @@ org-wide required workflow is what gates the merge. ## Modify this repo -- Edit the `plugin-nerdctl:` candy entity, the Go source, and `schema/plugin.cue` +- Edit the `plugin-containerd:` candy entity, the Go source, and `schema/plugin.cue` **together** — the schema is the served declaration surface. - Keep the delegation to `container.InvokeEngineOp("containerd", …)` in the spec module: the engine class has ONE implementation served from either placement. @@ -61,9 +68,9 @@ org-wide required workflow is what gates the merge. - 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 +- Keep `.agents/skills/plugin-containerd/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 + The candy's `plugin-containerd-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. diff --git a/README.md b/README.md index 79a5039..9e09f32 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# plugin-nerdctl +# plugin-containerd The out-of-tree charly ENGINE plugin serving `engine:containerd` — the containerd engine backend for OpenCharly pods, plus the `verb:containerd` / @@ -20,7 +20,7 @@ class has one implementation and two placements. | Capability | Surface | |---|---| | `engine:containerd` | the engine provider-class op envelope: `describe` / `binary` / `gpu_args` / `start_plan` / `unit_emit` / `network_ensure` | -| `verb:containerd` | the `containerd:` convenience verb | +| `verb:containerd` | the read-only `containerd:` check verb: `info` / `runtimes` / `cdi_devices` / `criu_preflight` / `krun_guest` / `image` | | `command:containerd` | the `charly containerd` convenience command | The start plan always names an OCI runtime handler (nerdctl's unqualified default @@ -42,7 +42,7 @@ containerd/buildkit/CNI stack) to deploy a pod with `engine: containerd`. Compose the plugin candy alongside the layer candy in a box's `candy:` list: ```yaml -- '@github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl:' +- '@github.com/opencharly/plugin-containerd/candy/plugin-containerd:' - '@github.com/opencharly/layer-nerdctl/candy/layer-nerdctl:' ``` @@ -54,18 +54,18 @@ engine: containerd ## Layout -- `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 +- `candy/plugin-containerd/` — the plugin module: `plugin.go` (provider + meta + + the containerd start-plan policy), `verb.go` (the read-only `containerd:` check + verbs), `schema/plugin.cue`, `params/` (generated), and `cmd/serve/main.go`. +- `.agents/skills/plugin-containerd/SKILL.md` — the skill body, the one canonical + base the corpus projects `/charly-internals:plugin-containerd` from. The candy's + `plugin-containerd-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`). ## Related -- Owning skill: `/charly-internals:plugin-nerdctl` — the engine:containerd +- Owning skill: `/charly-internals:plugin-containerd` — 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-containerd/charly.yml similarity index 68% rename from candy/plugin-nerdctl/charly.yml rename to candy/plugin-containerd/charly.yml index 722b6fd..335435f 100644 --- a/candy/plugin-nerdctl/charly.yml +++ b/candy/plugin-containerd/charly.yml @@ -1,4 +1,4 @@ -# plugin-nerdctl — the out-of-tree charly ENGINE plugin serving `engine:containerd`. +# plugin-containerd — 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 @@ -9,7 +9,7 @@ # `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: +plugin-containerd: candy: description: | The out-of-tree charly ENGINE plugin serving engine:containerd (plus the @@ -27,7 +27,15 @@ plugin-nerdctl: 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 + unit boundary. The verb:containerd word additionally serves the READ-ONLY + check verbs (W3.9), each able to FAIL on a defect nothing else catches: + info (daemon id + mode from nerdctl info SecurityOptions — Plugins is never + a mode source), runtimes (the declared CRI handlers via crictl info), + cdi_devices (the CLIENT's CDI spec dirs), criu_preflight (rc AND the + seccomp_suspend line; --extra is never used), krun_guest (one throwaway + probe guest: kernel + CPU features) and image (present in a named + namespace). A capability the venue lacks is a visible skip naming the + mechanism, never a silent pass. 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. @@ -36,7 +44,9 @@ plugin-nerdctl: - engine:containerd - verb:containerd - command:containerd - source: github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl + source: github.com/opencharly/plugin-containerd/candy/plugin-containerd + primary: + containerd: method plan: - 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 @@ -48,13 +58,18 @@ plugin-nerdctl: context: - build command: grep -q 'engine:containerd' charly.yml + - check: the served schema declares the typed verb:containerd input and its full method set + id: containerd-check-verbs-declared + context: + - build + command: grep -q '#ContainerdInput' schema/plugin.cue && grep -q 'criu_preflight' schema/plugin.cue && grep -q 'krun_guest' schema/plugin.cue # ---- HARNESS SKILL ---- -# Owner registration for the skill body at .agents/skills/plugin-nerdctl/SKILL.md, +# Owner registration for the skill body at .agents/skills/plugin-containerd/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: +plugin-containerd-skill: skill: - name: plugin-nerdctl + name: plugin-containerd family: internals - owner: plugin-nerdctl + owner: plugin-containerd diff --git a/candy/plugin-nerdctl/cmd/serve/main.go b/candy/plugin-containerd/cmd/serve/main.go similarity index 74% rename from candy/plugin-nerdctl/cmd/serve/main.go rename to candy/plugin-containerd/cmd/serve/main.go index 38171f8..544efba 100644 --- a/candy/plugin-nerdctl/cmd/serve/main.go +++ b/candy/plugin-containerd/cmd/serve/main.go @@ -6,8 +6,8 @@ package main import ( - nerdctl "github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl" + containerd "github.com/opencharly/plugin-containerd/candy/plugin-containerd" "github.com/opencharly/sdk" ) -func main() { sdk.Serve(nerdctl.NewProvider(), nerdctl.NewMeta()) } +func main() { sdk.Serve(containerd.NewProvider(), containerd.NewMeta()) } diff --git a/candy/plugin-nerdctl/containerd_engine_test.go b/candy/plugin-containerd/containerd_engine_test.go similarity index 99% rename from candy/plugin-nerdctl/containerd_engine_test.go rename to candy/plugin-containerd/containerd_engine_test.go index 891822f..ceb785e 100644 --- a/candy/plugin-nerdctl/containerd_engine_test.go +++ b/candy/plugin-containerd/containerd_engine_test.go @@ -1,4 +1,4 @@ -package nerdctl +package containerd // containerd_engine_test.go — the engine-word cutover contract. // diff --git a/candy/plugin-nerdctl/go.mod b/candy/plugin-containerd/go.mod similarity index 92% rename from candy/plugin-nerdctl/go.mod rename to candy/plugin-containerd/go.mod index f3f9c9d..b66a446 100644 --- a/candy/plugin-nerdctl/go.mod +++ b/candy/plugin-containerd/go.mod @@ -1,4 +1,4 @@ -module github.com/opencharly/plugin-nerdctl/candy/plugin-nerdctl +module github.com/opencharly/plugin-containerd/candy/plugin-containerd go 1.26.8 @@ -7,6 +7,7 @@ require ( github.com/hashicorp/go-plugin v1.8.0 github.com/opencharly/sdk v0.2026294.1520 github.com/opencharly/spec v0.2026295.1243 + go.yaml.in/yaml/v3 v3.0.5 ) require ( @@ -26,7 +27,6 @@ require ( 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.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 diff --git a/candy/plugin-nerdctl/go.sum b/candy/plugin-containerd/go.sum similarity index 100% rename from candy/plugin-nerdctl/go.sum rename to candy/plugin-containerd/go.sum diff --git a/candy/plugin-containerd/params/cue_types_gen.go b/candy/plugin-containerd/params/cue_types_gen.go new file mode 100644 index 0000000..19ff380 --- /dev/null +++ b/candy/plugin-containerd/params/cue_types_gen.go @@ -0,0 +1,73 @@ +// Code generated by "cue exp gengotypes"; DO NOT EDIT. + +package params + +// plugin-containerd's OWN self-contained CUE schema — the SINGLE SOURCE for this plugin's +// served declaration surface (there is no schema-less plugin: every plugin ships a +// non-empty schema over Describe). +// +// SELF-CONTAINED and PACKAGE-LESS: it references no base def and carries no package +// clause, so it compiles STANDALONE — the property the SDK's serve-side compile needs +// and the property that lets the host splice `base ++ plugin` at the load gate +// (registerPluginUnitSchema); a self-contained schema that will not splice is a LOUD +// load failure. +// +// GO CONSUMER: #ContainerdInput is the typed plugin_input of the `containerd:` +// check verb and generates ../params/cue_types_gen.go (params/gen.sh). #ContainerdPlugin +// is the served documentation surface only. +// +// It DOCUMENTS the `engine: containerd` word (plus the `verb:` / `command:` +// convenience words) and the check verb's input. The engine request/reply envelopes +// live in the base spec schema (`spec/schema/engine.cue`). `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). +type ContainerdPlugin struct { + // The engine WORD the plugin serves (the registry key `engine:containerd`). + Engine string `json:"engine"` + + // What the plugin does, in one line (the public-docs surface). + Contract string `json:"contract"` + + // 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 string `json:"rootful"` +} + +// #ContainerdInput is the `verb:containerd` check verb's plugin_input: the method +// name plus its method-exclusive modifiers. The verb's PRIMARY input field is +// `method`, so `containerd: info` desugars to {method: "info"}. +// +// Every method is READ-ONLY over the venue except krun_guest, which boots ONE +// throwaway probe guest (`--rm`) to read the guest kernel and CPU features. A +// method reports what it measured; a capability the venue does not have is a +// VISIBLE skip naming the mechanism (crictl/criu absent, /dev/kvm absent, a +// runtime handler not declared), never a silent pass. +type ContainerdInput struct { + // method — the containerd assertion to run. + Method string `json:"method"` + + // address — the containerd gRPC address (nerdctl --address). Empty uses the + // venue's default. + Address string `json:"address,omitempty"` + + // namespace — the containerd namespace (nerdctl --namespace). Empty uses the + // venue's default ("default" for nerdctl). + Namespace string `json:"namespace,omitempty"` + + // image — method:image / method:krun_guest — the image reference to assert + // present in the namespace, or the probe image to boot. + Image string `json:"image,omitempty"` + + // runtime — method:krun_guest — the OCI runtime handler the probe guest boots + // with (default "krun"). + Runtime string `json:"runtime,omitempty"` + + // spec_dirs — method:cdi_devices — the CLIENT's CDI spec directories. Defaults + // to nerdctl's own defaults: /etc/cdi and /var/run/cdi. + Spec_dirs []string `json:"spec_dirs,omitempty"` +} diff --git a/candy/plugin-containerd/params/gen.go b/candy/plugin-containerd/params/gen.go new file mode 100644 index 0000000..ff8fb8c --- /dev/null +++ b/candy/plugin-containerd/params/gen.go @@ -0,0 +1,3 @@ +package params + +//go:generate sh ./gen.sh diff --git a/candy/plugin-containerd/params/gen.sh b/candy/plugin-containerd/params/gen.sh new file mode 100755 index 0000000..7f5c1f5 --- /dev/null +++ b/candy/plugin-containerd/params/gen.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# gen.sh — regenerate params/cue_types_gen.go from the ONE source, +# ../schema/plugin.cue. +# +# The served schema MUST stay package-less (the host splices it onto its base as +# `base ++ plugin`; a package clause in the appended body would be a syntax +# error). `cue exp gengotypes` can only generate from a NAMED package, so this +# script stages a copy with the params package clause, generates from it, and the +# generator writes cue_types_gen.go into this directory. A second run is a +# byte-identical no-op. +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +{ + echo "package params" + echo + cat "$here/../schema/plugin.cue" +} >"$tmp/plugin.cue" + +cd "$here" +cue exp gengotypes "$tmp/plugin.cue" + +[ -f cue_types_gen.go ] || { + echo "gen.sh: cue emitted no cue_types_gen.go" >&2 + exit 1 +} +gofmt -w cue_types_gen.go diff --git a/candy/plugin-nerdctl/plugin.go b/candy/plugin-containerd/plugin.go similarity index 85% rename from candy/plugin-nerdctl/plugin.go rename to candy/plugin-containerd/plugin.go index 791c0f3..2a22170 100644 --- a/candy/plugin-nerdctl/plugin.go +++ b/candy/plugin-containerd/plugin.go @@ -25,7 +25,7 @@ // charly in-process when listed in compiled_plugins, or cmd/serve serves them // OUT-OF-PROCESS over go-plugin gRPC when they are not — placement is invisible // above the registry. -package nerdctl +package containerd import ( "context" @@ -64,14 +64,16 @@ func NewProvider() pb.ProviderServer { return &provider{} } // 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 -// over Describe: there is NO schema-less plugin. +// schema/spec), the verb:containerd READ-ONLY check verb (its typed input is +// #ContainerdInput, with `method` as the PRIMARY field so `containerd: info` +// desugars to {method: "info"}), and the command convenience word — together with +// the plugin's OWN self-contained, package-less CUE schema (schema/plugin.cue) +// served over Describe: there is NO schema-less plugin. func NewMeta() pb.PluginMetaServer { return sdk.NewMeta(calver, []sdk.ProvidedCapability{ {Class: "engine", Word: engineWord, InputDef: ""}, - {Class: "verb", Word: engineWord, InputDef: ""}, + {Class: "verb", Word: engineWord, InputDef: "#ContainerdInput", Primary: "method"}, {Class: "command", Word: engineWord, InputDef: ""}, }, schemaFS) @@ -84,7 +86,14 @@ type provider struct{ pb.UnimplementedProviderServer } // 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) { +func (provider) Invoke(ctx context.Context, req *pb.InvokeRequest) (*pb.InvokeReply, error) { + // The verb class is the `containerd:` READ-ONLY check verb (W3.9) — a + // different contract from the engine op envelope, so it branches BEFORE the + // engine dispatch. The host dispatches a check step as class=verb, op=run, + // with the full #Op as params and the CheckEnv snapshot as env. + if req.GetClass() == "verb" && req.GetOp() == ops.OpRun { + return invokeContainerdVerb(ctx, req) + } op := req.GetOp() params := json.RawMessage(req.GetParamsJson()) var ( @@ -97,7 +106,7 @@ func (provider) Invoke(_ context.Context, req *pb.InvokeRequest) (*pb.InvokeRepl out, err = container.InvokeEngineOp(engineWord, op, params) } if err != nil { - return nil, fmt.Errorf("plugin-nerdctl: engine:%s op %q: %w", engineWord, op, err) + return nil, fmt.Errorf("plugin-containerd: engine:%s op %q: %w", engineWord, op, err) } return &pb.InvokeReply{ResultJson: out}, nil } diff --git a/candy/plugin-containerd/schema/plugin.cue b/candy/plugin-containerd/schema/plugin.cue new file mode 100644 index 0000000..12aab4b --- /dev/null +++ b/candy/plugin-containerd/schema/plugin.cue @@ -0,0 +1,69 @@ +// plugin-containerd's OWN self-contained CUE schema — the SINGLE SOURCE for this plugin's +// served declaration surface (there is no schema-less plugin: every plugin ships a +// non-empty schema over Describe). +// +// SELF-CONTAINED and PACKAGE-LESS: it references no base def and carries no package +// clause, so it compiles STANDALONE — the property the SDK's serve-side compile needs +// and the property that lets the host splice `base ++ plugin` at the load gate +// (registerPluginUnitSchema); a self-contained schema that will not splice is a LOUD +// load failure. +// +// GO CONSUMER: #ContainerdInput is the typed plugin_input of the `containerd:` +// check verb and generates ../params/cue_types_gen.go (params/gen.sh). #ContainerdPlugin +// is the served documentation surface only. +// +// It DOCUMENTS the `engine: containerd` word (plus the `verb:` / `command:` +// convenience words) and the check verb's input. The engine request/reply envelopes +// live in the base spec schema (`spec/schema/engine.cue`). `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" +} + +// #ContainerdInput is the `verb:containerd` check verb's plugin_input: the method +// name plus its method-exclusive modifiers. The verb's PRIMARY input field is +// `method`, so `containerd: info` desugars to {method: "info"}. +// +// Every method is READ-ONLY over the venue except krun_guest, which boots ONE +// throwaway probe guest (`--rm`) to read the guest kernel and CPU features. A +// method reports what it measured; a capability the venue does not have is a +// VISIBLE skip naming the mechanism (crictl/criu absent, /dev/kvm absent, a +// runtime handler not declared), never a silent pass. +#ContainerdInput: { + // method — the containerd assertion to run. + method: "info" | "runtimes" | "cdi_devices" | "criu_preflight" | "krun_guest" | "image" + + // address — the containerd gRPC address (nerdctl --address). Empty uses the + // venue's default. + address?: string + + // namespace — the containerd namespace (nerdctl --namespace). Empty uses the + // venue's default ("default" for nerdctl). + namespace?: string + + // image — method:image / method:krun_guest — the image reference to assert + // present in the namespace, or the probe image to boot. + image?: string + + // runtime — method:krun_guest — the OCI runtime handler the probe guest boots + // with (default "krun"). + runtime?: string + + // spec_dirs — method:cdi_devices — the CLIENT's CDI spec directories. Defaults + // to nerdctl's own defaults: /etc/cdi and /var/run/cdi. + spec_dirs?: [...string] +} diff --git a/candy/plugin-nerdctl/schema_serve_test.go b/candy/plugin-containerd/schema_serve_test.go similarity index 99% rename from candy/plugin-nerdctl/schema_serve_test.go rename to candy/plugin-containerd/schema_serve_test.go index b7d84c6..5d00451 100644 --- a/candy/plugin-nerdctl/schema_serve_test.go +++ b/candy/plugin-containerd/schema_serve_test.go @@ -1,4 +1,4 @@ -package nerdctl +package containerd import ( "context" diff --git a/candy/plugin-containerd/verb.go b/candy/plugin-containerd/verb.go new file mode 100644 index 0000000..2665731 --- /dev/null +++ b/candy/plugin-containerd/verb.go @@ -0,0 +1,618 @@ +package containerd + +// verb.go — the `verb:containerd` READ-ONLY check verb (W3.9). +// +// The verb drives the venue's own tooling over the host's live executor reverse +// channel (sdk.Executor.RunCapture), so it measures the deployment rather than a +// fixture: every method answers a question only a running containerd can answer. +// The method set is deliberately small and each method can FAIL on a defect no +// other probe catches: +// +// info the daemon's identity and mode, from nerdctl info's +// SecurityOptions (a rootless daemon silently rerouting to the +// rootful socket is the failure this catches). Plugins is NEVER a +// mode source: it describes storage/log drivers, not privilege. +// runtimes the declared CRI runtime handlers (crictl info). A missing +// handler is why `--runtime krun` would silently fall back. +// cdi_devices the devices visible through the CLIENT's CDI spec dirs; a +// spec that is present but malformed must not pass as "no GPU". +// criu_preflight criu's seccomp_suspend feature check: rc 0 AND the supported +// line. The rc alone is not proof (criu check returns 0 for a +// feature list it did not fully verify), and --extra is never +// used — it changes the checks being run. +// krun_guest boots ONE throwaway probe guest and reads its kernel + CPU +// features, proving the krun handler actually boots a VM rather +// than a container. +// image an image is present in a named namespace (a rootless and a +// rootful daemon have disjoint stores). +// +// A capability the venue does not have is a VISIBLE skip naming the mechanism +// (crictl/criu absent, /dev/kvm absent, a runtime handler not declared, no probe +// image supplied), never a silent pass. An assertion that cannot run is not a +// green. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/opencharly/plugin-containerd/candy/plugin-containerd/params" + "github.com/opencharly/sdk" + "github.com/opencharly/sdk/kit" + pb "github.com/opencharly/spec/proto" + "github.com/opencharly/spec/shellquote" + "github.com/opencharly/spec/spec" + "go.yaml.in/yaml/v3" +) + +// Method names — the #ContainerdInput.method closed set. +const ( + methodInfo = "info" + methodRuntimes = "runtimes" + methodCDIDevices = "cdi_devices" + methodCRIUPreflight = "criu_preflight" + methodKrunGuest = "krun_guest" + methodImage = "image" +) + +// Defaults for method-exclusive modifiers. +const ( + defaultKrunRuntime = "krun" + defaultImageNS = "default" +) + +// defaultCDISpecDirs are nerdctl's own client-side CDI spec directories. They are +// the CLIENT's dirs (what the nerdctl invocation will read), not the CRI plugin's +// server config. +var defaultCDISpecDirs = []string{"/etc/cdi", "/var/run/cdi"} + +// venueExecutor is the slice of *sdk.Executor the check verbs use. It is an +// interface so every method is unit-testable against a fake venue without a +// go-plugin broker; the production path passes the real *sdk.Executor. +type venueExecutor interface { + RunCapture(ctx context.Context, script string) (stdout, stderr string, exit int, err error) +} + +// containerdVerbEnv is the plugin-side decode of the CheckEnv the host ships as +// Operation.Env for a `containerd:` check step (provider_checkenv.go). +type containerdVerbEnv struct { + Box string `json:"box"` + Mode string `json:"mode"` // "live" | "box" +} + +// invokeContainerdVerb serves the `verb:containerd` check verb: decode the full +// #Op + the desugared #ContainerdInput, skip in box mode (a `charly check box` +// container has no containerd daemon), run the method, and self-evaluate the +// authored exit/stdout/stderr matchers through the ONE shared verdict pipeline +// (sdk.VerbVerdict) — the out-of-process verb path does not run the host-side +// matcher pipeline, so this Invoke owns the whole verdict. +func invokeContainerdVerb(ctx context.Context, req *pb.InvokeRequest) (*pb.InvokeReply, error) { + var op spec.Op + if len(req.GetParamsJson()) > 0 { + if err := json.Unmarshal(req.GetParamsJson(), &op); err != nil { + return sdk.ResultJSON("fail", "containerd: decode op: "+err.Error()) + } + } + var in params.ContainerdInput + kit.DecodeInput(op.PluginInput, &in) + method := in.Method + if method == "" { + return sdk.ResultJSON("fail", "containerd: missing method (want one of "+ + "info|runtimes|cdi_devices|criu_preflight|krun_guest|image)") + } + var env containerdVerbEnv + if len(req.GetEnvJson()) > 0 { + _ = json.Unmarshal(req.GetEnvJson(), &env) + } + if env.Mode == "box" { + return sdk.ResultJSON("skip", fmt.Sprintf( + "containerd: %s needs a running containerd daemon (skipped under charly check box)", method)) + } + ex, err := sdk.ExecutorFromInvoke(req.GetExecutorBrokerId()) + if err != nil { + return sdk.ResultJSON("fail", fmt.Sprintf( + "containerd: %s has no host executor attached — the verb needs the live venue (%v)", method, err)) + } + out, skip, runErr := dispatchVerb(ctx, ex, &in) + if skip != "" { + return sdk.ResultJSON("skip", skip) + } + return sdk.VerbVerdict("containerd", method, out, runErr, &op, false) +} + +// dispatchVerb runs one method against the venue. A non-empty skip names the +// mechanism the venue does not have and outranks runErr; otherwise runErr is the +// assertion FAILING and is mapped through the authored matchers by VerbVerdict. +func dispatchVerb(ctx context.Context, ex venueExecutor, in *params.ContainerdInput) (out, skip string, err error) { + switch in.Method { + case methodInfo: + out, err = verbInfo(ctx, ex, in) + case methodRuntimes: + out, skip, err = verbRuntimes(ctx, ex) + case methodCDIDevices: + out, err = verbCDIDevices(ctx, ex, in) + case methodCRIUPreflight: + out, skip, err = verbCRIUPreflight(ctx, ex) + case methodKrunGuest: + out, skip, err = verbKrunGuest(ctx, ex, in) + case methodImage: + out, err = verbImage(ctx, ex, in) + default: + err = fmt.Errorf("unknown containerd method %q", in.Method) + } + return out, skip, err +} + +// nerdctlBase renders the nerdctl argv prefix, emitting --address/--namespace only +// when the caller supplied them: the capability defaults are never re-spelled. +func nerdctlBase(in *params.ContainerdInput) string { + parts := []string{"nerdctl"} + if in.Address != "" { + parts = append(parts, "--address", shellquote.ShellQuote(in.Address)) + } + if in.Namespace != "" { + parts = append(parts, "--namespace", shellquote.ShellQuote(in.Namespace)) + } + return strings.Join(parts, " ") +} + +// capture runs one script on the venue and returns stdout/stderr/exit. +func capture(ctx context.Context, ex venueExecutor, script string) (string, string, int, error) { + return ex.RunCapture(ctx, script) +} + +// marshal encodes a method's report as the verdict body. +func marshal(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("encode report: %w", err) + } + return string(b), nil +} + +// --------------------------------------------------------------------------- +// info +// --------------------------------------------------------------------------- + +// nerdctlInfo is the subset of `nerdctl info --format {{json .}}` this verb reads. +// Plugins is deliberately NOT declared: the daemon mode comes from +// SecurityOptions, never from the storage/log plugin list. +type nerdctlInfo struct { + ID string `json:"ID"` + Name string `json:"Name"` + ServerVersion string `json:"ServerVersion"` + Driver string `json:"Driver"` + OperatingSystem string `json:"OperatingSystem"` + Architecture string `json:"Architecture"` + SecurityOptions []string `json:"SecurityOptions"` +} + +type infoReport struct { + DaemonID string `json:"daemon_id"` + Mode string `json:"mode"` + ServerVersion string `json:"server_version"` + Driver string `json:"driver"` + OperatingSystem string `json:"operating_system"` + Architecture string `json:"architecture"` + Host string `json:"host"` + SecurityOptions []string `json:"security_options"` +} + +// verbInfo reads the daemon's identity and mode. Mode is derived ONLY from +// SecurityOptions: nerdctl reports `name=rootless` for a rootless daemon, and a +// rootless daemon that silently reroutes to the rootful socket is the defect this +// catches. An absent SecurityOptions list is a FAIL, never a guess from Plugins. +func verbInfo(ctx context.Context, ex venueExecutor, in *params.ContainerdInput) (string, error) { + script := nerdctlBase(in) + " info --format '{{json .}}'" + stdout, stderr, exit, err := capture(ctx, ex, script) + if err != nil { + return "", err + } + if exit != 0 { + return "", fmt.Errorf("nerdctl info exited %d: %s", exit, firstLine(stderr+stdout)) + } + var raw nerdctlInfo + if err := json.Unmarshal([]byte(stdout), &raw); err != nil { + return "", fmt.Errorf("parse nerdctl info: %w", err) + } + if strings.TrimSpace(raw.ID) == "" { + return "", errors.New("nerdctl info returned no daemon ID") + } + mode, err := daemonMode(raw.SecurityOptions) + if err != nil { + return "", err + } + return marshal(infoReport{ + DaemonID: raw.ID, + Mode: mode, + ServerVersion: raw.ServerVersion, + Driver: raw.Driver, + OperatingSystem: raw.OperatingSystem, + Architecture: raw.Architecture, + Host: raw.Name, + SecurityOptions: raw.SecurityOptions, + }) +} + +// daemonMode derives rootless/rootful from SecurityOptions ALONE. There is no +// Plugins fallback: Plugins lists storage/log drivers and says nothing about +// privilege, so inferring from it would be a fabricated answer. +func daemonMode(securityOptions []string) (string, error) { + if len(securityOptions) == 0 { + return "", errors.New("nerdctl info reported no SecurityOptions: the daemon mode is not derivable (Plugins is never a mode source)") + } + for _, opt := range securityOptions { + if key, value, ok := strings.Cut(opt, "="); ok && key == "name" && value == "rootless" { + return "rootless", nil + } + } + return "rootful", nil +} + +// --------------------------------------------------------------------------- +// runtimes +// --------------------------------------------------------------------------- + +type runtimesReport struct { + Runtimes []string `json:"runtimes"` +} + +// verbRuntimes lists the CRI runtime handlers containerd declares. It uses +// `crictl info -o json`, whose config nests the handlers under a runtimes map; +// the map is located structurally so a cri-tools nesting change cannot silently +// produce an empty (passing) list. +func verbRuntimes(ctx context.Context, ex venueExecutor) (string, string, error) { + stdout, stderr, exit, err := capture(ctx, ex, "crictl info -o json") + if err != nil { + return "", "", err + } + if exit != 0 { + msg := firstLine(stderr + stdout) + if missingBinary(msg, "crictl") { + return "", "runtimes: crictl is not installed on the venue (the declared CRI runtime handlers are only reachable through crictl info)", nil + } + return "", "", fmt.Errorf("crictl info exited %d: %s", exit, msg) + } + var doc any + if err := json.Unmarshal([]byte(stdout), &doc); err != nil { + return "", "", fmt.Errorf("parse crictl info: %w", err) + } + handlers := runtimesFromCRIConfig(doc) + if len(handlers) == 0 { + return "", "", errors.New("crictl info reported no config runtimes map (no runtime handler declared)") + } + sort.Strings(handlers) + out, err := marshal(runtimesReport{Runtimes: handlers}) + return out, "", err +} + +// runtimesFromCRIConfig walks the crictl info document for the first object-valued +// "runtimes" key and returns its handler names. cri-tools has nested the map under +// different config paths across releases; walking is stable across all of them, +// and a hit is never invented from a similarly-named scalar. +func runtimesFromCRIConfig(v any) []string { + switch node := v.(type) { + case map[string]any: + if raw, ok := node["runtimes"]; ok { + if m, ok := raw.(map[string]any); ok { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + if len(names) > 0 { + return names + } + } + } + for _, child := range node { + if got := runtimesFromCRIConfig(child); len(got) > 0 { + return got + } + } + case []any: + for _, child := range node { + if got := runtimesFromCRIConfig(child); len(got) > 0 { + return got + } + } + } + return nil +} + +// --------------------------------------------------------------------------- +// cdi_devices +// --------------------------------------------------------------------------- + +type cdiSpec struct { + CdiVersion string `yaml:"cdiVersion"` + Kind string `yaml:"kind"` + Devices []struct { + Name string `yaml:"name"` + } `yaml:"devices"` +} + +type cdiReport struct { + SpecDirs []string `json:"spec_dirs"` + Devices []string `json:"devices"` + Specs []string `json:"specs"` +} + +// verbCDIDevices reads the CDI specs the nerdctl CLIENT will see: its own spec +// directories (/etc/cdi, /var/run/cdi by default, overridable per invocation). +// Reading the CRI plugin's server-side dirs instead would answer a different +// question — what the daemon sees, not what the client injects. +func verbCDIDevices(ctx context.Context, ex venueExecutor, in *params.ContainerdInput) (string, error) { + dirs := in.Spec_dirs + if len(dirs) == 0 { + dirs = defaultCDISpecDirs + } + var script strings.Builder + for _, dir := range dirs { + q := shellquote.ShellQuote(dir) + // The glob is intentionally unquoted; a missing directory leaves the + // pattern literal and the [ -f ] test drops it. A malformed spec is a + // FAIL below, never "no devices". + fmt.Fprintf(&script, "for f in %s/*.yaml %s/*.yml %s/*.json; do [ -f \"$f\" ] || continue; echo \"===CDI $f\"; cat \"$f\"; done; ", q, q, q) + } + stdout, stderr, exit, err := capture(ctx, ex, script.String()) + if err != nil { + return "", err + } + if exit != 0 { + return "", fmt.Errorf("read CDI spec dirs exited %d: %s", exit, firstLine(stderr)) + } + report := cdiReport{SpecDirs: dirs, Devices: []string{}, Specs: []string{}} + for _, block := range strings.Split(stdout, "===CDI ") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + path, body, ok := strings.Cut(block, "\n") + if !ok { + return "", fmt.Errorf("CDI spec %s is empty", strings.TrimSpace(path)) + } + var spec cdiSpec + if err := yaml.Unmarshal([]byte(body), &spec); err != nil { + return "", fmt.Errorf("parse CDI spec %s: %w", strings.TrimSpace(path), err) + } + if spec.Kind == "" { + return "", fmt.Errorf("CDI spec %s has no kind", strings.TrimSpace(path)) + } + report.Specs = append(report.Specs, strings.TrimSpace(path)) + for _, d := range spec.Devices { + report.Devices = append(report.Devices, spec.Kind+"="+d.Name) + } + } + sort.Strings(report.Devices) + sort.Strings(report.Specs) + return marshal(report) +} + +// --------------------------------------------------------------------------- +// criu_preflight +// --------------------------------------------------------------------------- + +type criuReport struct { + ExitStatus int `json:"exit_status"` + Line string `json:"seccomp_suspend_line"` + Output string `json:"output"` +} + +// criuPreflightLine is the ONLY line that proves the feature. `criu check` also +// exits 0 when a requested feature was not verified, so the rc alone is not proof. +const criuPreflightLine = "seccomp_suspend is supported" + +// verbCRIUPreflight runs criu's seccomp_suspend feature check. It requires BOTH +// rc 0 AND the supported line, and never passes --extra: --extra changes the set +// of checks (it enables optional/extended checks), so a run with it would prove a +// different claim than the one the bed makes. +func verbCRIUPreflight(ctx context.Context, ex venueExecutor) (string, string, error) { + script := "criu check --unprivileged --feature seccomp_suspend 2>&1" + stdout, _, exit, err := capture(ctx, ex, script) + if err != nil { + return "", "", err + } + if exit != 0 { + if missingBinary(stdout, "criu") { + return "", "criu_preflight: criu is not installed on the venue (the seccomp_suspend feature check cannot run)", nil + } + return "", "", fmt.Errorf("criu check exited %d: %s", exit, firstLine(stdout)) + } + if !strings.Contains(stdout, criuPreflightLine) { + return "", "", fmt.Errorf("criu check exited 0 but did not print %q: rc alone is not proof (output: %s)", + criuPreflightLine, firstLine(stdout)) + } + out, err := marshal(criuReport{ExitStatus: exit, Line: criuPreflightLine, Output: strings.TrimSpace(stdout)}) + return out, "", err +} + +// --------------------------------------------------------------------------- +// krun_guest +// --------------------------------------------------------------------------- + +type krunReport struct { + Runtime string `json:"runtime"` + Image string `json:"image"` + Kernel string `json:"kernel"` + Arch string `json:"arch"` + KVM string `json:"dev_kvm"` + Features []string `json:"cpu_features"` +} + +// verbKrunGuest boots ONE throwaway probe guest (`--rm`) with the requested runtime +// handler and reads the guest's kernel, arch and CPU features. It proves the krun +// handler boots a VM rather than silently falling back to a container. +// +// Visible skips name the mechanism: no probe image supplied, no /dev/kvm on the +// venue, or the handler not declared by this containerd. +func verbKrunGuest(ctx context.Context, ex venueExecutor, in *params.ContainerdInput) (string, string, error) { + if in.Image == "" { + return "", "krun_guest: no probe image supplied (set image: to a locally available guest image)", nil + } + runtime := in.Runtime + if runtime == "" { + runtime = defaultKrunRuntime + } + stdout, stderr, exit, err := capture(ctx, ex, "test -e /dev/kvm && echo kvm-present || echo kvm-absent") + if err != nil { + return "", "", err + } + if exit != 0 { + return "", "", fmt.Errorf("probe /dev/kvm exited %d: %s", exit, firstLine(stderr)) + } + if strings.TrimSpace(stdout) != "kvm-present" { + return "", "krun_guest: /dev/kvm is absent on the venue (no KVM, so no krun guest can boot)", nil + } + // The guest-side probe is a single sh -c: kernel, arch, CPU features, and + // whether the guest itself got /dev/kvm. + probe := "echo KERNEL=$(cat /proc/version); echo ARCH=$(uname -m); " + + "echo FLAGS=$(grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2); " + + "echo KVM=$(test -e /dev/kvm && echo present || echo absent)" + script := nerdctlBase(in) + " run --rm --runtime " + shellquote.ShellQuote(runtime) + " " + + shellquote.ShellQuote(in.Image) + " sh -c " + shellquote.ShellQuote(probe) + stdout, stderr, exit, err = capture(ctx, ex, script) + if err != nil { + return "", "", err + } + if exit != 0 { + msg := stderr + stdout + if handlerNotDeclared(msg, runtime) { + return "", fmt.Sprintf("krun_guest: the %q runtime handler is not declared by this containerd (nerdctl: %s)", runtime, firstLine(msg)), nil + } + return "", "", fmt.Errorf("probe guest exited %d: %s", exit, firstLine(msg)) + } + report := krunReport{Runtime: runtime, Image: in.Image} + for _, line := range strings.Split(stdout, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch key { + case "KERNEL": + report.Kernel = value + case "ARCH": + report.Arch = value + case "KVM": + report.KVM = value + case "FLAGS": + report.Features = strings.Fields(value) + } + } + if report.Kernel == "" { + return "", "", fmt.Errorf("probe guest printed no kernel (output: %s)", firstLine(stdout)) + } + sort.Strings(report.Features) + out, err := marshal(report) + return out, "", err +} + +// --------------------------------------------------------------------------- +// image +// --------------------------------------------------------------------------- + +type imageReport struct { + Image string `json:"image"` + Namespace string `json:"namespace"` + ID string `json:"id"` + RepoTags []string `json:"repo_tags"` + Arch string `json:"arch"` + OS string `json:"os"` +} + +// verbImage asserts an image is present in a NAMED namespace. Rootless and rootful +// daemons have disjoint stores, so "present" without the namespace is not a fact. +func verbImage(ctx context.Context, ex venueExecutor, in *params.ContainerdInput) (string, error) { + if strings.TrimSpace(in.Image) == "" { + return "", errors.New("image: the image reference is required") + } + ns := in.Namespace + if ns == "" { + ns = defaultImageNS + } + script := nerdctlBase(in) + " image inspect " + shellquote.ShellQuote(in.Image) + stdout, stderr, exit, err := capture(ctx, ex, script) + if err != nil { + return "", err + } + if exit != 0 { + return "", fmt.Errorf("image %s is not present in namespace %s: %s", in.Image, ns, firstLine(stderr+stdout)) + } + var found []struct { + ID string `json:"Id"` + RepoTags []string `json:"RepoTags"` + Arch string `json:"Architecture"` + OS string `json:"Os"` + Descriptor struct { + Digest string `json:"digest"` + } `json:"Descriptor"` + } + if err := json.Unmarshal([]byte(stdout), &found); err != nil { + return "", fmt.Errorf("parse nerdctl image inspect: %w", err) + } + if len(found) == 0 { + return "", fmt.Errorf("image %s is not present in namespace %s (empty inspect result)", in.Image, ns) + } + return marshal(imageReport{ + Image: in.Image, + Namespace: ns, + ID: found[0].ID, + RepoTags: found[0].RepoTags, + Arch: found[0].Arch, + OS: found[0].OS, + }) +} + +// --------------------------------------------------------------------------- +// shared helpers +// --------------------------------------------------------------------------- + +// firstLine returns the first non-empty line of s, bounded so a noisy command +// cannot flood a verdict message. +func firstLine(s string) string { + for _, line := range strings.Split(s, "\n") { + if t := strings.TrimSpace(line); t != "" { + if len(t) > 300 { + return t[:300] + "…" + } + return t + } + } + return "" +} + +// missingBinary reports whether a failure message says the named binary is absent. +func missingBinary(msg, binary string) bool { + lower := strings.ToLower(msg) + return strings.Contains(lower, binary+": command not found") || + strings.Contains(lower, binary+": not found") || + strings.Contains(lower, "executable file not found") && strings.Contains(lower, binary) +} + +// handlerNotDeclared reports whether a nerdctl run failed because the requested +// runtime handler is not configured. It matches ONLY handler-shaped refusals, so a +// real boot failure (kernel panic, no KVM, image error) is never downgraded to a +// skip. +func handlerNotDeclared(msg, runtime string) bool { + lower := strings.ToLower(msg) + if !strings.Contains(lower, strings.ToLower(runtime)) && !strings.Contains(lower, "runtime") { + return false + } + for _, phrase := range []string{ + "is not supported", + "unknown runtime", + "no such runtime", + "runtime handler not found", + "failed to get runtime", + "failed to resolve runtime path", + "invalid runtime name", + "no runtime for", + } { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} diff --git a/candy/plugin-containerd/verb_test.go b/candy/plugin-containerd/verb_test.go new file mode 100644 index 0000000..ff50988 --- /dev/null +++ b/candy/plugin-containerd/verb_test.go @@ -0,0 +1,426 @@ +package containerd + +// verb_test.go — the `verb:containerd` check-verb contract (W3.9). +// +// Every method has a deterministic arm against a FAKE venue (the failure is +// visible: each test names the defect it catches) plus one LIVE arm at the end +// that runs against the real daemon, or skips VISIBLY naming the mechanism it +// lacks. The fake venue is the host-executor seam (venueExecutor), so no +// go-plugin broker is needed here; the live arm's localVenue is a test-only +// stand-in for the host executor on a `host: local` venue. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "strings" + "testing" + + "github.com/opencharly/plugin-containerd/candy/plugin-containerd/params" + pb "github.com/opencharly/spec/proto" +) + +// fakeVenue answers RunCapture from a script→response table and records every +// script so a test can assert the argv the verb actually built. +type fakeVenue struct { + scripts []string + answer func(script string) (stdout, stderr string, exit int, err error) +} + +func (f *fakeVenue) RunCapture(_ context.Context, script string) (string, string, int, error) { + f.scripts = append(f.scripts, script) + if f.answer == nil { + return "", "", 0, nil + } + return f.answer(script) +} + +func (f *fakeVenue) lastScript() string { + if len(f.scripts) == 0 { + return "" + } + return f.scripts[len(f.scripts)-1] +} + +func fixed(stdout, stderr string, exit int) func(string) (string, string, int, error) { + return func(string) (string, string, int, error) { return stdout, stderr, exit, nil } +} + +// TestVerbInfoDerivesModeFromSecurityOptionsOnly pins the rule that the daemon +// mode comes from SecurityOptions, NEVER from Plugins. The third arm is the +// negative control: a Plugins-rich document with no SecurityOptions must FAIL +// instead of inferring a mode. +func TestVerbInfoDerivesModeFromSecurityOptionsOnly(t *testing.T) { + rootless := &fakeVenue{answer: fixed(`{"ID":"daemon-1","SecurityOptions":["name=seccomp,profile=builtin","name=rootless"]}`, "", 0)} + out, skip, err := dispatchVerb(context.Background(), rootless, ¶ms.ContainerdInput{Method: methodInfo}) + if err != nil || skip != "" { + t.Fatalf("rootless info: err=%v skip=%q", err, skip) + } + var rep infoReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode info report: %v", err) + } + if rep.Mode != "rootless" { + t.Fatalf("mode = %q, want rootless", rep.Mode) + } + if rep.DaemonID != "daemon-1" { + t.Fatalf("daemon id = %q, want daemon-1", rep.DaemonID) + } + if !strings.Contains(rootless.lastScript(), "info --format") { + t.Fatalf("info script does not call nerdctl info --format: %q", rootless.lastScript()) + } + + rootful := &fakeVenue{answer: fixed(`{"ID":"daemon-2","SecurityOptions":["name=seccomp"]}`, "", 0)} + out, _, err = dispatchVerb(context.Background(), rootful, ¶ms.ContainerdInput{Method: methodInfo}) + if err != nil { + t.Fatalf("rootful info: %v", err) + } + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode rootful report: %v", err) + } + if rep.Mode != "rootful" { + t.Fatalf("mode = %q, want rootful", rep.Mode) + } + + pluginsOnly := &fakeVenue{answer: fixed(`{"ID":"daemon-3","Plugins":{"Storage":["overlayfs"],"Log":["json-file"]}}`, "", 0)} + if _, _, err := dispatchVerb(context.Background(), pluginsOnly, ¶ms.ContainerdInput{Method: methodInfo}); err == nil { + t.Fatal("info passed a document with no SecurityOptions: mode must never be inferred from Plugins") + } +} + +// TestVerbRuntimesListsDeclaredHandlers pins the crictl-based handler report and +// its visible skip when crictl is absent (the mechanism, not a green). +func TestVerbRuntimesListsDeclaredHandlers(t *testing.T) { + venue := &fakeVenue{answer: fixed(`{"config":{"containerd":{"runtimes":{"krun":{},"crun":{}}}}}`, "", 0)} + out, skip, err := dispatchVerb(context.Background(), venue, ¶ms.ContainerdInput{Method: methodRuntimes}) + if err != nil || skip != "" { + t.Fatalf("runtimes: err=%v skip=%q", err, skip) + } + var rep runtimesReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode runtimes report: %v", err) + } + if got, want := strings.Join(rep.Runtimes, ","), "crun,krun"; got != want { + t.Fatalf("handlers = %q, want %q (sorted)", got, want) + } + + absent := &fakeVenue{answer: fixed("", "crictl: command not found", 127)} + _, skip, err = dispatchVerb(context.Background(), absent, ¶ms.ContainerdInput{Method: methodRuntimes}) + if err != nil { + t.Fatalf("absent crictl must be a skip, got err=%v", err) + } + if !strings.Contains(skip, "crictl") { + t.Fatalf("skip does not name crictl: %q", skip) + } + + empty := &fakeVenue{answer: fixed(`{"config":{}}`, "", 0)} + if _, _, err := dispatchVerb(context.Background(), empty, ¶ms.ContainerdInput{Method: methodRuntimes}); err == nil { + t.Fatal("runtimes passed a config with no handlers map") + } +} + +// TestVerbCDIDevicesReadsClientSpecDirs pins that the verb reads the CLIENT's +// spec directories (default /etc/cdi + /var/run/cdi, overridable), not a +// server-side config, and that a malformed spec FAILS instead of reading as "no +// devices". +func TestVerbCDIDevicesReadsClientSpecDirs(t *testing.T) { + specs := "" + + "===CDI /etc/cdi/vendor.yaml\n" + + "cdiVersion: 0.5.0\nkind: example.com/gpu\ndevices:\n - name: \"1\"\n" + + "===CDI /var/run/cdi/gpu.json\n" + + "{\"cdiVersion\":\"0.5.0\",\"kind\":\"example.com/gpu\",\"devices\":[{\"name\":\"0\"}]}\n" + venue := &fakeVenue{answer: fixed(specs, "", 0)} + out, skip, err := dispatchVerb(context.Background(), venue, ¶ms.ContainerdInput{Method: methodCDIDevices}) + if err != nil || skip != "" { + t.Fatalf("cdi_devices: err=%v skip=%q", err, skip) + } + var rep cdiReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode cdi report: %v", err) + } + if got, want := strings.Join(rep.Devices, ","), "example.com/gpu=0,example.com/gpu=1"; got != want { + t.Fatalf("devices = %q, want %q", got, want) + } + script := venue.lastScript() + for _, dir := range []string{"/etc/cdi", "/var/run/cdi"} { + if !strings.Contains(script, dir) { + t.Fatalf("script does not read the client spec dir %s: %q", dir, script) + } + } + + custom := &fakeVenue{answer: fixed("", "", 0)} + if _, _, err := dispatchVerb(context.Background(), custom, ¶ms.ContainerdInput{ + Method: methodCDIDevices, Spec_dirs: []string{"/opt/cdi"}, + }); err != nil { + t.Fatalf("custom spec dirs: %v", err) + } + if !strings.Contains(custom.lastScript(), "/opt/cdi") || strings.Contains(custom.lastScript(), "/etc/cdi") { + t.Fatalf("custom spec_dirs not honoured: %q", custom.lastScript()) + } + + malformed := &fakeVenue{answer: fixed("===CDI /etc/cdi/broken.yaml\ncdiVersion: [not-a-scalar\n", "", 0)} + if _, _, err := dispatchVerb(context.Background(), malformed, ¶ms.ContainerdInput{Method: methodCDIDevices}); err == nil { + t.Fatal("cdi_devices passed a malformed spec (a broken spec must not read as no devices)") + } +} + +// TestVerbCRIURequiresRCAndLine pins the preflight contract: rc 0 alone is not +// proof; the supported line is required too; and --extra is NEVER used (it +// changes the checks being run, so the claim would be about a different run). +func TestVerbCRIURequiresRCAndLine(t *testing.T) { + good := &fakeVenue{answer: fixed("seccomp_suspend is supported\n", "", 0)} + out, skip, err := dispatchVerb(context.Background(), good, ¶ms.ContainerdInput{Method: methodCRIUPreflight}) + if err != nil || skip != "" { + t.Fatalf("criu_preflight: err=%v skip=%q", err, skip) + } + var rep criuReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode criu report: %v", err) + } + if rep.ExitStatus != 0 || rep.Line != criuPreflightLine { + t.Fatalf("report = %+v, want rc 0 + the supported line", rep) + } + if strings.Contains(good.lastScript(), "--extra") { + t.Fatalf("criu_preflight used --extra: %q", good.lastScript()) + } + + rcOnly := &fakeVenue{answer: fixed("seccomp_suspend: failed\n", "", 0)} + if _, _, err := dispatchVerb(context.Background(), rcOnly, ¶ms.ContainerdInput{Method: methodCRIUPreflight}); err == nil { + t.Fatal("criu_preflight passed on rc 0 without the supported line") + } + + lineOnly := &fakeVenue{answer: fixed("seccomp_suspend is supported\n", "", 1)} + if _, _, err := dispatchVerb(context.Background(), lineOnly, ¶ms.ContainerdInput{Method: methodCRIUPreflight}); err == nil { + t.Fatal("criu_preflight passed with a non-zero rc") + } + + absent := &fakeVenue{answer: fixed("criu: command not found\n", "", 127)} + _, skip, err = dispatchVerb(context.Background(), absent, ¶ms.ContainerdInput{Method: methodCRIUPreflight}) + if err != nil { + t.Fatalf("absent criu must be a skip, got err=%v", err) + } + if !strings.Contains(skip, "criu") { + t.Fatalf("skip does not name criu: %q", skip) + } +} + +// TestVerbKrunGuestBootsOrSkipsVisibly pins the probe-guest arm: it needs an +// image, /dev/kvm, and a declared handler; each absence is a visible skip naming +// the mechanism, while a real boot failure stays a FAIL. +func TestVerbKrunGuestBootsOrSkipsVisibly(t *testing.T) { + noImage := &fakeVenue{} + _, skip, err := dispatchVerb(context.Background(), noImage, ¶ms.ContainerdInput{Method: methodKrunGuest}) + if err != nil || !strings.Contains(skip, "image") { + t.Fatalf("no image: skip=%q err=%v, want a skip naming the missing probe image", skip, err) + } + + noKVM := &fakeVenue{answer: func(script string) (string, string, int, error) { + if strings.Contains(script, "/dev/kvm") && strings.HasPrefix(script, "test -e") { + return "kvm-absent\n", "", 0, nil + } + return "", "", 0, nil + }} + _, skip, err = dispatchVerb(context.Background(), noKVM, ¶ms.ContainerdInput{Method: methodKrunGuest, Image: "probe:latest"}) + if err != nil || !strings.Contains(skip, "/dev/kvm") { + t.Fatalf("no kvm: skip=%q err=%v, want a skip naming /dev/kvm", skip, err) + } + + // The exact refusal measured live on nerdctl 2.3.5 (2026-10-24): a short + // handler alias that containerd never declared. It is a configuration + // capability the venue lacks, so it must be a skip, not a fail. + undeclared := &fakeVenue{answer: func(script string) (string, string, int, error) { + if strings.HasPrefix(script, "test -e /dev/kvm") { + return "kvm-present\n", "", 0, nil + } + return "", "time=\"…\" level=fatal msg=\"failed to start shim: failed to resolve runtime path: invalid runtime name krun, correct runtime name should be either format like `io.containerd.runc.v2` or a full path to the binary\"", 1, nil + }} + _, skip, err = dispatchVerb(context.Background(), undeclared, ¶ms.ContainerdInput{Method: methodKrunGuest, Image: "probe:latest"}) + if err != nil || !strings.Contains(skip, "krun") { + t.Fatalf("undeclared handler: skip=%q err=%v, want a skip naming the handler", skip, err) + } + + // A REAL boot failure must stay a FAIL: it is not downgraded to a skip. + realFailure := &fakeVenue{answer: func(script string) (string, string, int, error) { + if strings.HasPrefix(script, "test -e /dev/kvm") { + return "kvm-present\n", "", 0, nil + } + return "", "kernel panic: no working init found", 1, nil + }} + if _, _, err := dispatchVerb(context.Background(), realFailure, ¶ms.ContainerdInput{Method: methodKrunGuest, Image: "probe:latest"}); err == nil { + t.Fatal("a genuine guest boot failure must FAIL, not skip") + } + + booted := &fakeVenue{answer: func(script string) (string, string, int, error) { + if strings.HasPrefix(script, "test -e /dev/kvm") { + return "kvm-present\n", "", 0, nil + } + return "KERNEL=Linux version 6.12.0-krun\nARCH=x86_64\nFLAGS=fpu sse\nKVM=present\n", "", 0, nil + }} + out, skip, err := dispatchVerb(context.Background(), booted, ¶ms.ContainerdInput{Method: methodKrunGuest, Image: "probe:latest"}) + if err != nil || skip != "" { + t.Fatalf("booted guest: err=%v skip=%q", err, skip) + } + var rep krunReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode krun report: %v", err) + } + if rep.Kernel != "Linux version 6.12.0-krun" || rep.Arch != "x86_64" || rep.KVM != "present" { + t.Fatalf("krun report = %+v", rep) + } + if got := strings.Join(rep.Features, ","); got != "fpu,sse" { + t.Fatalf("features = %q, want sorted fpu,sse", got) + } + runScript := booted.lastScript() + for _, want := range []string{"run --rm", "--runtime 'krun'", "probe:latest"} { + if !strings.Contains(runScript, want) { + t.Fatalf("probe script missing %q: %q", want, runScript) + } + } +} + +// TestVerbImageIsNamespaced pins that "present" is answered in a NAMED namespace +// (rootless and rootful stores are disjoint) and that an absent image FAILS. +func TestVerbImageIsNamespaced(t *testing.T) { + present := &fakeVenue{answer: fixed(`[{"Id":"sha256:abc","RepoTags":["probe:latest"],"Architecture":"amd64","Os":"linux"}]`, "", 0)} + out, skip, err := dispatchVerb(context.Background(), present, ¶ms.ContainerdInput{Method: methodImage, Image: "probe:latest"}) + if err != nil || skip != "" { + t.Fatalf("image: err=%v skip=%q", err, skip) + } + var rep imageReport + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("decode image report: %v", err) + } + if rep.Namespace != "default" || rep.ID != "sha256:abc" { + t.Fatalf("image report = %+v, want namespace default + the image id", rep) + } + + k8s := &fakeVenue{answer: fixed(`[{"Id":"sha256:abc"}]`, "", 0)} + if _, _, err := dispatchVerb(context.Background(), k8s, ¶ms.ContainerdInput{ + Method: methodImage, Image: "probe:latest", Namespace: "k8s.io", + }); err != nil { + t.Fatalf("namespaced image: %v", err) + } + if !strings.Contains(k8s.lastScript(), "--namespace 'k8s.io'") { + t.Fatalf("image script did not pass the namespace: %q", k8s.lastScript()) + } + + absent := &fakeVenue{answer: fixed("", "no such image", 1)} + if _, _, err := dispatchVerb(context.Background(), absent, ¶ms.ContainerdInput{ + Method: methodImage, Image: "absent:latest", Namespace: "k8s.io", + }); err == nil || !strings.Contains(err.Error(), "k8s.io") { + t.Fatalf("absent image must fail naming the namespace; err=%v", err) + } + + noRef := &fakeVenue{} + if _, _, err := dispatchVerb(context.Background(), noRef, ¶ms.ContainerdInput{Method: methodImage}); err == nil { + t.Fatal("image passed without an image reference") + } +} + +// TestVerbCapabilityDeclaresTypedInput pins the served capability: the host can +// only desugar `containerd: info` when the verb advertises #ContainerdInput with +// `method` as PRIMARY. +func TestVerbCapabilityDeclaresTypedInput(t *testing.T) { + caps := NewMeta() + desc, err := caps.Describe(context.Background(), &pb.Empty{}) + if err != nil { + t.Fatalf("Describe: %v", err) + } + var found bool + for _, c := range desc.GetProvided() { + if c.GetClass() == "verb" && c.GetWord() == engineWord { + found = true + if c.GetInputDef() != "#ContainerdInput" { + t.Fatalf("verb input def = %q, want #ContainerdInput", c.GetInputDef()) + } + if c.GetPrimary() != "method" { + t.Fatalf("verb primary = %q, want method", c.GetPrimary()) + } + } + } + if !found { + t.Fatal("plugin does not advertise verb:containerd") + } +} + +// --------------------------------------------------------------------------- +// LIVE arm +// --------------------------------------------------------------------------- + +// localVenue is a TEST-ONLY stand-in for the host executor on a `host: local` +// venue: the host side of the reverse channel runs the same script through a +// shell. It is not product code and is never used outside this test. +type localVenue struct{} + +func (localVenue) RunCapture(ctx context.Context, script string) (string, string, int, error) { + cmd := exec.CommandContext(ctx, "sh", "-c", script) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return stdout.String(), stderr.String(), 0, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return stdout.String(), stderr.String(), exitErr.ExitCode(), nil + } + return stdout.String(), stderr.String(), 127, nil +} + +// TestVerbLiveLocal runs every method against THIS host's daemon when +// CHARLY_CONTAINERD_LIVE=1, and skips VISIBLY otherwise (the mechanism: the live +// arm is opt-in). Each method logs whether it passed or skipped and why, so the +// run is the receipt. +func TestVerbLiveLocal(t *testing.T) { + if os.Getenv("CHARLY_CONTAINERD_LIVE") != "1" { + t.Skip("live containerd probes not requested: set CHARLY_CONTAINERD_LIVE=1 to run against this host's daemon") + } + image := os.Getenv("CHARLY_CONTAINERD_LIVE_IMAGE") + ex := localVenue{} + ctx := context.Background() + + info, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodInfo}) + if err != nil || skip != "" { + t.Fatalf("info live: err=%v skip=%q", err, skip) + } + t.Logf("info: %s", info) + + if _, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodRuntimes}); err != nil { + t.Fatalf("runtimes live: %v", err) + } else if skip != "" { + t.Logf("runtimes SKIP: %s", skip) + } + + cdi, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodCDIDevices}) + if err != nil || skip != "" { + t.Fatalf("cdi_devices live: err=%v skip=%q", err, skip) + } + t.Logf("cdi_devices: %s", cdi) + + if _, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodCRIUPreflight}); err != nil { + t.Fatalf("criu_preflight live: %v", err) + } else if skip != "" { + t.Logf("criu_preflight SKIP: %s", skip) + } + + if image == "" { + t.Logf("image SKIP: set CHARLY_CONTAINERD_LIVE_IMAGE to assert an image is present") + t.Logf("krun_guest SKIP: set CHARLY_CONTAINERD_LIVE_IMAGE to boot a probe guest") + } else { + out, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodImage, Image: image}) + if err != nil || skip != "" { + t.Fatalf("image live: err=%v skip=%q", err, skip) + } + t.Logf("image: %s", out) + + if _, skip, err := dispatchVerb(ctx, ex, ¶ms.ContainerdInput{Method: methodKrunGuest, Image: image}); err != nil { + t.Fatalf("krun_guest live: %v", err) + } else if skip != "" { + t.Logf("krun_guest SKIP: %s", skip) + } + } +} diff --git a/candy/plugin-nerdctl/schema/plugin.cue b/candy/plugin-nerdctl/schema/plugin.cue deleted file mode 100644 index 1ecb4d4..0000000 --- a/candy/plugin-nerdctl/schema/plugin.cue +++ /dev/null @@ -1,36 +0,0 @@ -// plugin-nerdctl's OWN self-contained CUE schema — the SINGLE SOURCE for this plugin's -// served declaration surface (there is no schema-less plugin: every plugin ships a -// non-empty schema over Describe). -// -// SELF-CONTAINED and PACKAGE-LESS: it references no base def and carries no package -// clause, so it compiles STANDALONE — the property the SDK's serve-side compile needs -// and the property that lets the host splice `base ++ plugin` at the load gate -// (registerPluginUnitSchema); a self-contained schema that will not splice is a LOUD -// load failure. -// -// NO GO CONSUMER: the plugin declares no typed `plugin_input` (its authored input is -// its pass-through CLI grammar), so this schema generates NO `params` package and has -// NO `cue exp gengotypes` artifact — it is the SERVED documentation/config surface, -// not a code-generation source. -// -// 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" -}