Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions candy/plugin-deploy-pod/config_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ func runPodConfigSetup(ctx context.Context, ex *sdk.Executor, c *spec.PodConfigS
if rootfulUnsupported(sel.Target, rt.RunMode) {
return rootfulRefusal(sel.Engine, rt.RunMode)
}
// The launch argv this config step is about to emit names a runtime handler. Refuse HERE,
// before any file is written or any image work runs, when the venue cannot launch it: the
// emitted unit would restart-loop and hold the container name (plugin-containerd#16).
if err := ensureVenueRuntimeHandler(sel.Engine); err != nil {
return err
}
return runConfig(ctx, ex, rt, sel, c)
}

Expand Down
9 changes: 9 additions & 0 deletions candy/plugin-deploy-pod/config_setup_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,11 @@ func runConfigDirect(rt *kit.ResolvedRuntime, sel podSelection, qcfg deploykit.Q
if rootfulUnsupported(sel.Target, rt.RunMode) {
return rootfulRefusal(sel.Engine, rt.RunMode)
}
// The direct launch argv names a runtime handler; refuse one this venue cannot launch
// before anything is stopped or removed (plugin-containerd#16).
if err := ensureVenueRuntimeHandler(sel.Engine); err != nil {
return err
}
engine := string(sel.Engine)
name := kit.ContainerNameInstance(qcfg.BoxName, qcfg.Instance)
stop := engineStopArgv(sel.Target, name)
Expand All @@ -536,6 +541,10 @@ func runConfigDirect(rt *kit.ResolvedRuntime, sel podSelection, qcfg deploykit.Q
cmd := exec.Command(args[0], args[1:]...)
out, err := cmd.CombinedOutput()
if err != nil {
// The engine creates the container before it starts the task, so a failed launch may
// leave it CREATED with its name held. Remove it with the SAME argv used above, so a
// retry is not blocked by `name ... already used` (plugin-containerd#16).
_ = exec.Command(rm[0], rm[1:]...).Run()
return fmt.Errorf("%s run %s failed: %w\n%s", engine, name, err, strings.TrimSpace(string(out)))
}
cid := strings.TrimSpace(string(out))
Expand Down
80 changes: 80 additions & 0 deletions candy/plugin-deploy-pod/engine_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package deploypod
import (
"encoding/json"
"fmt"
"os/exec"
"sort"
"strings"

"github.com/opencharly/sdk/deploykit"
Expand Down Expand Up @@ -136,6 +138,84 @@ func engineDefaultRuntime(c spec.EngineCapability) string {
return c.RuntimeHandlers[0]
}

// runtimeHandlerBinary maps a runtime-handler word to the executable the launch resolves it
// through. It mirrors the two arms of nerdctl's `generateRuntimeCOpts` (pkg/cmd/container/
// run_runtime.go) and containerd's shim naming (pkg/shim.BinaryName), because those are the
// programs that decide whether a `--runtime <word>` can start:
//
// - an ABSOLUTE PATH is the executable itself;
// - a runtime TYPE (`io.containerd.*`) resolves to `containerd-shim-<second-last>-<last>`;
// - every other word is a BARE OCI-runtime binary name, which nerdctl resolves with
// exec.LookPath and launches through the `io.containerd.runc.v2` shim.
//
// An unrepresentable word returns "", which the probe treats as "not installed".
func runtimeHandlerBinary(handler string) string {
if strings.Contains(handler, "/") {
return handler
}
if strings.HasPrefix(handler, "io.containerd.") || handler == "wtf.sbk.runj.v1" {
parts := strings.Split(handler, ".")
if len(parts) < 2 {
return ""
}
return "containerd-shim-" + parts[len(parts)-2] + "-" + parts[len(parts)-1]
}
return handler
}

// venueRuntimeHandlers returns the SORTED subset of the engine's declared runtime handlers
// that this venue can actually launch: for each candidate it asks the SAME resolution the
// launch will (exec.LookPath over the runtime executable, see runtimeHandlerBinary).
//
// MEASURED on this host's rootless containerd 2.3.5 (nerdctl 2.4.1): `/usr/bin/crun` is
// absent, `nerdctl run --runtime crun` fails `failed to resolve runtime path: invalid
// runtime name crun`, and `--runtime runc` succeeds because `/usr/bin/runc` is installed.
// The venue, not the capability row, is what makes a handler runnable.
func venueRuntimeHandlers(c spec.EngineCapability) []string {
installed := make([]string, 0, len(c.RuntimeHandlers))
for _, handler := range c.RuntimeHandlers {
binary := runtimeHandlerBinary(handler)
if binary == "" {
continue
}
if _, err := exec.LookPath(binary); err == nil {
installed = append(installed, handler)
}
}
sort.Strings(installed)
return installed
}

// ensureVenueRuntimeHandler refuses a config/start step whose launch argv would name a
// runtime handler THIS VENUE cannot launch. Without it the capability row alone names the
// handler (`engineDefaultRuntime`), so a venue without that handler emits a unit whose
// ExecStart can never start: systemd restart-loops and the failed attempt leaves the
// container CREATED with its name held, so every retry dies `name ... already used`
// (opencharly/plugin-containerd#16).
//
// It refuses instead of falling back to another handler: a silent switch to a different
// OCI runtime is a different substrate (R3). An engine that declares NO runtime handlers
// (podman, docker) takes exactly the path it took before — no probe, no `--runtime`, no new
// failure mode.
func ensureVenueRuntimeHandler(engine spec.EngineName) error {
c, ok := container.EngineCapabilityFor(string(engine))
if !ok || len(c.RuntimeHandlers) == 0 {
return nil
}
selected := engineDefaultRuntime(c)
installed := venueRuntimeHandlers(c)
for _, handler := range installed {
if handler == selected {
return nil
}
}
reported := strings.Join(installed, ", ")
if reported == "" {
reported = "none of " + strings.Join(c.RuntimeHandlers, ", ")
}
return fmt.Errorf("engine %q: the selected runtime handler %q is not installed on this venue (the venue reports: %s); remedy: install a %q runtime handler on the venue (the layer-containerd candy installs crun and krun), or point the deploy at a venue that has it", engine, selected, reported, selected)
}

// engineGlobalArgs returns the engine CLI's connection flags from the RESOLVED target. Only
// an engine with a separate daemon endpoint declares an address (containerd), so podman and
// docker are unchanged; the namespace is the resolved one (deploy > settings > capability
Expand Down
5 changes: 4 additions & 1 deletion candy/plugin-deploy-pod/engine_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,13 @@ func TestResolvePodStartSystemdUnit_StartsViaSystemctl(t *testing.T) {
}

rt := kitResolvedRuntimeForTest("containerd", "systemd-unit", "auto")
plan, err := resolvePodStartSystemdUnit(context.Background(), nil, "demo", "", rt)
plan, sel, err := resolvePodStartSystemdUnit(context.Background(), nil, "demo", "", rt)
if err != nil {
t.Fatalf("resolvePodStartSystemdUnit: %v", err)
}
if sel.Engine != "containerd" {
t.Fatalf("resolved selection: got engine %q, want containerd", sel.Engine)
}
if plan.Mode != "systemd-unit" || plan.DirectDeploy {
t.Fatalf("plan mode: got %q directDeploy=%v, want systemd-unit/false", plan.Mode, plan.DirectDeploy)
}
Expand Down
43 changes: 36 additions & 7 deletions candy/plugin-deploy-pod/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func podStart(ctx context.Context, exec *sdk.Executor, p lifecycleParams) (*pb.I
return nil, fmt.Errorf("plugin-deploy-pod start: decode opts: %w", err)
}
box, instance := spec.ParseDeployKey(p.Name)
planPtr, err := resolvePodStartPlan(ctx, exec, box, instance, opts)
planPtr, sel, err := resolvePodStartPlan(ctx, exec, box, instance, opts)
if err != nil {
return nil, fmt.Errorf("plugin-deploy-pod start: resolve plan: %w", err)
}
Expand All @@ -137,7 +137,7 @@ func podStart(ctx context.Context, exec *sdk.Executor, p lifecycleParams) (*pb.I
return nil, fmt.Errorf("plugin-deploy-pod start: mount encrypted volumes: %w", err)
}
}
if err := podContainerStart(ctx, exec, plan); err != nil {
if err := podContainerStart(ctx, exec, plan, sel); err != nil {
return nil, err
}
if plan.Tunnel != nil {
Expand Down Expand Up @@ -221,22 +221,51 @@ func podStartCommand(plan spec.PodLifecyclePlan) (cmd, label, target string, err

// podContainerStart runs the resolved container start over the served host executor. Mirrors
// StartCmd.runQuadlet / runDirect's exec.
func podContainerStart(ctx context.Context, exec *sdk.Executor, plan spec.PodLifecyclePlan) error {
//
// A FAILED start cleans up after itself. The engine creates the container BEFORE it starts
// the task, so a start that dies (a venue whose containerd cannot resolve the runtime
// handler, a missing image at task-create, …) leaves the container CREATED with its name
// held; every retry then fails `name ... already used`, and a unit-mode failure is worse
// because systemd's restart policy keeps re-creating the container. Stop the unit (no
// restart) and remove the partial container through the SAME teardown argv `charly remove`
// uses (engineRemoveArgv), so the next attempt starts from a free name
// (opencharly/plugin-containerd#16). The START error is what the operator sees; a cleanup
// failure is reported beside it, never in place of it.
func podContainerStart(ctx context.Context, ex venueExecutor, plan spec.PodLifecyclePlan, sel podSelection) error {
cmd, label, target, err := podStartCommand(plan)
if err != nil {
return err
}
return venueRun(ctx, exec, cmd, label, target)
if err := venueRun(ctx, ex, cmd, label, target); err != nil {
cleanupFailedPodStart(ctx, ex, plan, sel)
return err
}
return nil
}

// cleanupFailedPodStart releases everything a failed start may have left behind: the unit
// (so Restart=always cannot re-create the container) and the container name itself. Both
// are best-effort and each failure is printed with what it means for the next attempt.
func cleanupFailedPodStart(ctx context.Context, ex venueExecutor, plan spec.PodLifecyclePlan, sel podSelection) {
if kit.IsUnitRunMode(plan.Mode) && !plan.DirectDeploy {
if err := podContainerStop(ctx, ex, plan); err != nil {
fmt.Fprintf(os.Stderr, "Warning: after a failed start, could not stop %s (systemd may restart it): %v\n", plan.SvcName, err)
}
}
rm := engineRemoveArgv(sel.Target, plan.ContainerName)
if err := venueRun(ctx, ex, shellJoin(rm), "remove partial container", plan.ContainerName); err != nil {
fmt.Fprintf(os.Stderr, "Warning: after a failed start, could not remove the partial container %s (a retry may fail with a name-store error): %v\n", plan.ContainerName, err)
}
}

// podContainerStop runs the resolved container stop: a unit run mode (quadlet or
// systemd-unit) → `systemctl --user stop <svc>` (always via systemctl so podman-stop +
// Restart=always cannot restart-loop); direct → `<engine> stop <ctr>`.
func podContainerStop(ctx context.Context, exec *sdk.Executor, plan spec.PodLifecyclePlan) error {
func podContainerStop(ctx context.Context, ex venueExecutor, plan spec.PodLifecyclePlan) error {
if kit.IsUnitRunMode(plan.Mode) {
return venueRun(ctx, exec, "systemctl --user stop "+shellquote.ShellQuote(plan.SvcName), "stop", plan.SvcName)
return venueRun(ctx, ex, "systemctl --user stop "+shellquote.ShellQuote(plan.SvcName), "stop", plan.SvcName)
}
return venueRun(ctx, exec, plan.EngineBin+" stop "+shellquote.ShellQuote(plan.ContainerName), "stop", plan.ContainerName)
return venueRun(ctx, ex, plan.EngineBin+" stop "+shellquote.ShellQuote(plan.ContainerName), "stop", plan.ContainerName)
}

// podTunnelOp composes verb:tunnel over InvokeProvider with the {plugin_input:{method,config}}
Expand Down
Loading
Loading