feat: delegate storage:exec to scheduler plugins, harden the flow

Moves the actual exec out of the storage plugin and into a new scheduler-storage-exec plugn trigger. scheduler-docker-local does docker run with TTY-aware -it/-i selection and --user derived from entry.Chown; scheduler-k3s creates a throwaway Pod via the kubernetes API, waits for it to reach Running with structured error reporting (ImagePullBackOff and friends are surfaced from the container status verbatim, no kubectl involvement), execs the user command via the existing SPDY plumbing in k8s.go, and deletes the Pod on the way out. (Entry).Validate now accepts either an absolute path or a docker named-volume token for docker-local entries so the migration synthesizer's named-volume legacy entries work cleanly. storage:exec gains --as-user for one-off uid overrides, propagates the underlying tool's exit code via os.Exit, and detects TTY/interactive mode from os.Stdin so non-interactive scripted use no longer trips over docker's input-device-is-not-a-tty error.
This commit is contained in:
Jose Diaz-Gonzalez
2026-04-29 23:29:30 -04:00
parent d75228f165
commit 58042b9330
17 changed files with 790 additions and 72 deletions

View File

@@ -2944,6 +2944,13 @@ DOKKU_SCHEDULER="$1"; APP="$2"; REMOVE_CONTAINERS="$3";
- Arguments: `$ENTRY_NAME`
- Stdin: JSON-encoded `Entry` payload
### `scheduler-storage-exec`
- Description: Runs an interactive or non-interactive command against a storage entry. The storage plugin fires this with `<scheduler>` as the first arg; each scheduler plugin's handler matches against its own scheduler name and either handles or no-ops, mirroring `scheduler-deploy` / `scheduler-app-status`. Plugn forwards stdin/stdout/stderr to the handler subprocess so an interactive shell streams cleanly. The handler exits with the underlying tool's status code so `dokku storage:exec` propagates exit codes verbatim.
- Invoked by: `dokku storage:exec`
- Arguments: `$SCHEDULER $ENTRY_NAME $IMAGE [-- $cmd...]`
- Flags: `--interactive` (stdin is open), `--tty` (stdin is a terminal), `--as-user <uid>` (override `entry.Chown`).
### `traefik-template-source`
- Description: Retrieves an alternative template for the traefik compose config

View File

@@ -1,4 +1,4 @@
TRIGGERS = triggers/scheduler-cron-write
TRIGGERS = triggers/scheduler-cron-write triggers/scheduler-storage-exec
BUILD = triggers
PLUGIN_NAME = scheduler-docker-local

View File

@@ -5,13 +5,16 @@ go 1.26.2
require (
github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000
github.com/dokku/dokku/plugins/cron v0.0.0-00010101000000-000000000000
github.com/dokku/dokku/plugins/storage v0.0.0-00010101000000-000000000000
github.com/multiformats/go-base36 v0.2.0
github.com/spf13/pflag v1.0.10
golang.org/x/sync v0.20.0
)
require (
github.com/alexellis/go-execute/v2 v2.2.1 // indirect
github.com/dokku/dokku/plugins/app-json v0.0.0-00010101000000-000000000000 // indirect
github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -38,3 +41,7 @@ replace github.com/dokku/dokku/plugins/app-json => ../app-json
replace github.com/dokku/dokku/plugins/common => ../common
replace github.com/dokku/dokku/plugins/cron => ../cron
replace github.com/dokku/dokku/plugins/storage => ../storage
replace github.com/dokku/dokku/plugins/docker-options => ../docker-options

View File

@@ -46,6 +46,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF25UfsK2X7Ph/zCAk=
github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I=

View File

@@ -1,11 +1,12 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
flag "github.com/spf13/pflag"
"github.com/dokku/dokku/plugins/common"
schedulerdockerlocal "github.com/dokku/dokku/plugins/scheduler-docker-local"
)
@@ -14,6 +15,10 @@ import (
func main() {
parts := strings.Split(os.Args[0], "/")
trigger := parts[len(parts)-1]
interactive := flag.Bool("interactive", false, "--interactive: stdin is open")
tty := flag.Bool("tty", false, "--tty: stdin is a terminal")
asUser := flag.String("as-user", "", "--as-user: numeric uid override")
flag.Parse()
var err error
@@ -21,6 +26,27 @@ func main() {
case "scheduler-cron-write":
scheduler := flag.Arg(0)
err = schedulerdockerlocal.TriggerSchedulerCronWrite(scheduler)
case "scheduler-storage-exec":
args := flag.Args()
if len(args) < 3 {
err = fmt.Errorf("scheduler-storage-exec requires <scheduler> <entry-name> <image>")
break
}
scheduler := args[0]
entryName := args[1]
image := args[2]
var cmd []string
if len(args) > 3 {
cmd = args[3:]
}
err = schedulerdockerlocal.TriggerSchedulerStorageExec(scheduler, schedulerdockerlocal.StorageExecInput{
EntryName: entryName,
Image: image,
Interactive: *interactive,
Tty: *tty,
AsUser: *asUser,
Command: cmd,
})
default:
err = fmt.Errorf("Invalid plugin trigger call: %s", trigger)
}

View File

@@ -0,0 +1,160 @@
package schedulerdockerlocal
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/storage"
)
// dockerNamedVolumeRegexp mirrors storage.dockerNamedVolumeRegexp and is
// used to decide whether a HostPath should be statted (filesystem) or
// looked up via `docker volume inspect`.
var dockerNamedVolumeRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
// StorageExecInput captures the inputs forwarded by the storage plugin
// over the scheduler-storage-exec trigger.
type StorageExecInput struct {
EntryName string
Image string
Interactive bool
Tty bool
AsUser string
Command []string
}
// TriggerSchedulerStorageExec runs an interactive or non-interactive
// command in a throwaway docker container that mounts the storage
// entry's host path or named volume at /data. Returns the exit code of
// docker run via os.Exit so the caller's status mirrors the underlying
// tool.
func TriggerSchedulerStorageExec(scheduler string, input StorageExecInput) error {
if scheduler != "docker-local" {
// Not for us; let other handlers respond.
return nil
}
if !storage.EntryExists(input.EntryName) {
return fmt.Errorf("storage entry %q does not exist", input.EntryName)
}
entry, err := storage.LoadEntry(input.EntryName)
if err != nil {
return err
}
if entry.Scheduler != storage.SchedulerDockerLocal {
return fmt.Errorf("storage entry %q has scheduler %q, not docker-local", entry.Name, entry.Scheduler)
}
if err := preflightDockerLocalSource(entry.HostPath); err != nil {
return err
}
args, err := buildDockerExecArgs(entry, input)
if err != nil {
return err
}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: args,
StreamStdio: true,
})
if err != nil {
return err
}
if result.ExitCode != 0 {
os.Exit(result.ExitCode)
}
return nil
}
// preflightDockerLocalSource fails fast when the host path or named
// volume backing the entry doesn't exist, so the user gets a real error
// instead of a generic "docker: ..." line.
func preflightDockerLocalSource(hostPath string) error {
if hostPath == "" {
return errors.New("storage entry is missing a host path")
}
if filepath.IsAbs(hostPath) {
info, err := os.Stat(hostPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("host path %s does not exist", hostPath)
}
return fmt.Errorf("unable to stat host path %s: %w", hostPath, err)
}
if !info.IsDir() {
return fmt.Errorf("host path %s exists but is not a directory", hostPath)
}
return nil
}
if !dockerNamedVolumeRegexp.MatchString(hostPath) {
return fmt.Errorf("host path %q is neither absolute nor a valid docker volume token", hostPath)
}
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: common.DockerBin(),
Args: []string{"volume", "inspect", hostPath},
})
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("docker named volume %q is not present: %s", hostPath, strings.TrimSpace(result.StderrContents()))
}
return nil
}
// buildDockerExecArgs assembles the `docker run` argv for a storage:exec
// invocation. Split out so the unit test can exercise it without
// actually invoking docker.
func buildDockerExecArgs(entry *storage.Entry, input StorageExecInput) ([]string, error) {
cmd := input.Command
if len(cmd) == 0 {
cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
}
args := []string{"run", "--rm"}
if input.Tty {
args = append(args, "-it")
} else if input.Interactive {
args = append(args, "-i")
}
user, err := resolveUser(entry.Chown, input.AsUser)
if err != nil {
return nil, err
}
if user != "" {
args = append(args, "--user", user)
}
args = append(args, "-v", fmt.Sprintf("%s:/data", entry.HostPath))
args = append(args, input.Image)
args = append(args, cmd...)
return args, nil
}
// resolveUser turns the entry's Chown setting plus an optional --as-user
// override into a docker --user value. Returns an empty string when the
// container should run as the image's default user.
func resolveUser(chown string, asUser string) (string, error) {
if asUser != "" {
return fmt.Sprintf("%s:%s", asUser, asUser), nil
}
if chown == "" || chown == "false" {
return "", nil
}
uid, err := storage.ResolveChownID(chown)
if err != nil {
return "", err
}
if uid == "" || uid == "false" {
return "", nil
}
return fmt.Sprintf("%s:%s", uid, uid), nil
}

View File

@@ -0,0 +1,118 @@
package schedulerdockerlocal
import (
"strings"
"testing"
"github.com/dokku/dokku/plugins/storage"
)
func TestBuildDockerExecArgsTTY(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerDockerLocal,
HostPath: "/var/lib/dokku/data/storage/demo-data",
}
cases := []struct {
name string
interactive bool
tty bool
want string
}{
{"non-interactive", false, false, ""},
{"piped stdin", true, false, "-i"},
{"interactive tty", true, true, "-it"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args, err := buildDockerExecArgs(entry, StorageExecInput{
Image: "alpine:3",
Interactive: tc.interactive,
Tty: tc.tty,
Command: []string{"ls", "/data"},
})
if err != nil {
t.Fatalf("buildDockerExecArgs returned error: %v", err)
}
joined := strings.Join(args, " ")
if tc.want == "" {
if strings.Contains(joined, " -i ") || strings.Contains(joined, " -it ") {
t.Fatalf("expected no -i / -it flag, got: %s", joined)
}
} else if !strings.Contains(joined, " "+tc.want+" ") {
t.Fatalf("expected to find %q in args, got: %s", tc.want, joined)
}
})
}
}
func TestBuildDockerExecArgsUser(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerDockerLocal,
HostPath: "/var/lib/dokku/data/storage/demo-data",
Chown: "false",
}
args, err := buildDockerExecArgs(entry, StorageExecInput{
Image: "alpine:3",
Command: []string{"ls"},
})
if err != nil {
t.Fatalf("err: %v", err)
}
if strings.Contains(strings.Join(args, " "), "--user") {
t.Fatalf("did not expect --user flag with chown=false, got: %v", args)
}
args, err = buildDockerExecArgs(entry, StorageExecInput{
Image: "alpine:3",
AsUser: "1234",
Command: []string{"ls"},
})
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(strings.Join(args, " "), "--user 1234:1234") {
t.Fatalf("expected --user 1234:1234, got: %v", args)
}
}
func TestBuildDockerExecArgsDefaultsToShellWhenNoCommand(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerDockerLocal,
HostPath: "/var/lib/dokku/data/storage/demo-data",
}
args, err := buildDockerExecArgs(entry, StorageExecInput{
Image: "alpine:3",
})
if err != nil {
t.Fatalf("err: %v", err)
}
last := args[len(args)-3:]
if last[0] != "sh" || last[1] != "-c" {
t.Fatalf("expected the trailing command to be a shell fallback, got: %v", last)
}
}
func TestBuildDockerExecArgsMountsHostPath(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerDockerLocal,
HostPath: "/srv/demo",
}
args, err := buildDockerExecArgs(entry, StorageExecInput{
Image: "alpine:3",
Command: []string{"ls"},
})
if err != nil {
t.Fatalf("err: %v", err)
}
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-v /srv/demo:/data") {
t.Fatalf("expected -v /srv/demo:/data, got: %s", joined)
}
}

View File

@@ -1,5 +1,5 @@
SUBCOMMANDS = subcommands/annotations:set subcommands/autoscaling-auth:set subcommands/autoscaling-auth:report subcommands/cluster:add subcommands/cluster:list subcommands/cluster:remove subcommands/ensure-charts subcommands/initialize subcommands/labels:set subcommands/profiles:add subcommands/profiles:list subcommands/profiles:remove subcommands/report subcommands/set subcommands/show-kubeconfig subcommands/uninstall
TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write triggers/storage-create triggers/storage-destroy triggers/storage-status
TRIGGERS = triggers/core-post-deploy triggers/core-post-extract triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-certs-update triggers/post-certs-remove triggers/post-create triggers/post-delete triggers/report triggers/scheduler-app-status triggers/scheduler-deploy triggers/scheduler-enter triggers/scheduler-is-deployed triggers/scheduler-logs triggers/scheduler-proxy-config triggers/scheduler-proxy-logs triggers/scheduler-post-delete triggers/scheduler-run triggers/scheduler-run-list triggers/scheduler-stop triggers/scheduler-cron-write triggers/storage-create triggers/storage-destroy triggers/storage-status triggers/scheduler-storage-exec
BUILD = commands subcommands triggers
PLUGIN_NAME = scheduler-k3s

View File

@@ -19,6 +19,9 @@ func main() {
parts := strings.Split(os.Args[0], "/")
trigger := parts[len(parts)-1]
podIdentifier := flag.String("container-id", "", "--container-id: A pod identifier")
storageExecInteractive := flag.Bool("interactive", false, "--interactive: stdin is open (storage:exec)")
storageExecTty := flag.Bool("tty", false, "--tty: stdin is a terminal (storage:exec)")
storageExecAsUser := flag.String("as-user", "", "--as-user: numeric uid override (storage:exec)")
flag.Parse()
var err error
@@ -168,6 +171,27 @@ func main() {
case "storage-status":
entryName := flag.Arg(0)
err = scheduler_k3s.TriggerStorageStatus(context.Background(), entryName)
case "scheduler-storage-exec":
positional := flag.Args()
if len(positional) < 3 {
err = fmt.Errorf("scheduler-storage-exec requires <scheduler> <entry-name> <image>")
break
}
schedulerName := positional[0]
entryName := positional[1]
image := positional[2]
var cmd []string
if len(positional) > 3 {
cmd = positional[3:]
}
err = scheduler_k3s.TriggerSchedulerStorageExec(context.Background(), schedulerName, scheduler_k3s.StorageExecInput{
EntryName: entryName,
Image: image,
Interactive: *storageExecInteractive,
Tty: *storageExecTty,
AsUser: *storageExecAsUser,
Command: cmd,
})
default:
err = fmt.Errorf("Invalid plugin trigger call: %s", trigger)
}

View File

@@ -0,0 +1,236 @@
package scheduler_k3s
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/storage"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilexec "k8s.io/client-go/util/exec"
)
// StorageExecInput captures the inputs forwarded by the storage plugin
// over the scheduler-storage-exec trigger.
type StorageExecInput struct {
EntryName string
Image string
Interactive bool
Tty bool
AsUser string
Command []string
}
// TriggerSchedulerStorageExec runs an interactive or non-interactive
// command against a k3s storage entry by creating a throwaway Pod that
// mounts the PVC at /data, exec-ing the user's command via the
// kubernetes API, and deleting the Pod afterwards. Errors at every
// stage (PVC missing, image pull failure, etc.) come through the
// structured Kubernetes API objects rather than kubectl-shaped strings.
func TriggerSchedulerStorageExec(ctx context.Context, scheduler string, input StorageExecInput) error {
if scheduler != storage.SchedulerK3s {
return nil
}
if !storage.EntryExists(input.EntryName) {
return fmt.Errorf("storage entry %q does not exist", input.EntryName)
}
entry, err := storage.LoadEntry(input.EntryName)
if err != nil {
return err
}
if entry.Scheduler != storage.SchedulerK3s {
return fmt.Errorf("storage entry %q has scheduler %q, not k3s", entry.Name, entry.Scheduler)
}
if err := isKubernetesAvailable(); err != nil {
return fmt.Errorf("kubernetes not available: %w", err)
}
clientset, err := NewKubernetesClient()
if err != nil {
return err
}
namespace := entry.Namespace
if namespace == "" {
namespace = "default"
}
if _, err := clientset.Client.CoreV1().PersistentVolumeClaims(namespace).Get(ctx, entry.Name, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return fmt.Errorf("PVC %s not found in namespace %s", entry.Name, namespace)
}
return fmt.Errorf("error reading PVC %s in namespace %s: %w", entry.Name, namespace, err)
}
podName := fmt.Sprintf("dokku-storage-exec-%s-%d", entry.Name, time.Now().UnixNano()/int64(time.Millisecond)%1000000)
pod, err := buildStoragePodSpec(podName, namespace, entry, input)
if err != nil {
return err
}
common.LogVerboseQuiet(fmt.Sprintf("Creating exec pod %s/%s", namespace, podName))
if _, err := clientset.Client.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}); err != nil {
return fmt.Errorf("error creating exec pod %s/%s: %w", namespace, podName, err)
}
defer func() {
bg := context.Background()
gracePeriod := int64(0)
_ = clientset.Client.CoreV1().Pods(namespace).Delete(bg, podName, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod})
}()
if err := waitForPodRunning(ctx, clientset, namespace, podName, 60*time.Second); err != nil {
return err
}
command := input.Command
if len(command) == 0 {
command = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
}
execErr := clientset.ExecCommand(ctx, ExecCommandInput{
Name: podName,
Namespace: namespace,
ContainerName: "exec",
Command: command,
})
if execErr == nil {
return nil
}
var coded utilexec.CodeExitError
if errors.As(execErr, &coded) {
os.Exit(coded.Code)
}
return execErr
}
// buildStoragePodSpec assembles the throwaway Pod that mounts the PVC
// for storage:exec. Split out so the unit test can verify the spec
// without hitting a cluster.
func buildStoragePodSpec(name string, namespace string, entry *storage.Entry, input StorageExecInput) (*corev1.Pod, error) {
uid, err := resolveStorageExecUID(entry.Chown, input.AsUser)
if err != nil {
return nil, err
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/managed-by": "dokku",
"dokku.com/storage-entry": entry.Name,
"dokku.com/purpose": "storage-exec",
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Volumes: []corev1.Volume{
{
Name: "data",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: entry.Name,
},
},
},
},
Containers: []corev1.Container{
{
Name: "exec",
Image: input.Image,
Command: []string{"sleep", "infinity"},
Stdin: input.Interactive,
TTY: input.Tty,
VolumeMounts: []corev1.VolumeMount{
{Name: "data", MountPath: "/data"},
},
},
},
},
}
if uid != nil {
pod.Spec.SecurityContext = &corev1.PodSecurityContext{
RunAsUser: uid,
RunAsGroup: uid,
FSGroup: uid,
}
}
return pod, nil
}
// resolveStorageExecUID picks the UID to run the exec Pod as. Returns
// nil to mean "leave the image's default user in place".
func resolveStorageExecUID(chown string, asUser string) (*int64, error) {
if asUser != "" {
n, err := strconv.ParseInt(asUser, 10, 64)
if err != nil {
return nil, fmt.Errorf("--as-user must be a numeric uid, got %q", asUser)
}
return &n, nil
}
if chown == "" || chown == "false" {
return nil, nil
}
uidStr, err := storage.ResolveChownID(chown)
if err != nil {
return nil, err
}
if uidStr == "" || uidStr == "false" {
return nil, nil
}
n, err := strconv.ParseInt(uidStr, 10, 64)
if err != nil {
return nil, fmt.Errorf("could not parse resolved chown UID %q: %w", uidStr, err)
}
return &n, nil
}
// waitForPodRunning polls the Pod until its phase is Running, surfacing
// the structured Kubernetes API state on failure (image pull failures,
// scheduling problems, etc.) rather than a generic timeout.
func waitForPodRunning(ctx context.Context, clientset KubernetesClient, namespace, name string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
pod, err := clientset.Client.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("error reading exec pod %s/%s: %w", namespace, name, err)
}
switch pod.Status.Phase {
case corev1.PodRunning:
return nil
case corev1.PodFailed, corev1.PodSucceeded:
return fmt.Errorf("exec pod %s/%s reached terminal phase %s before exec could attach", namespace, name, pod.Status.Phase)
}
for _, cs := range pod.Status.ContainerStatuses {
if cs.State.Waiting == nil {
continue
}
reason := cs.State.Waiting.Reason
switch reason {
case "ImagePullBackOff", "ErrImagePull", "CreateContainerConfigError", "InvalidImageName":
return fmt.Errorf("exec pod %s/%s container %q failed to start: %s: %s", namespace, name, cs.Name, reason, cs.State.Waiting.Message)
}
}
if time.Now().After(deadline) {
return fmt.Errorf("exec pod %s/%s did not reach Running within %s (current phase: %s)", namespace, name, timeout, pod.Status.Phase)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
}

View File

@@ -0,0 +1,103 @@
package scheduler_k3s
import (
"testing"
"github.com/dokku/dokku/plugins/storage"
)
func TestBuildStoragePodSpec(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerK3s,
Namespace: "dokku",
}
pod, err := buildStoragePodSpec("dokku-storage-exec-demo-data-1", "dokku", entry, StorageExecInput{
Image: "alpine:3",
Interactive: true,
Tty: true,
Command: []string{"sh"},
})
if err != nil {
t.Fatalf("buildStoragePodSpec err: %v", err)
}
if pod.Spec.RestartPolicy != "Never" {
t.Fatalf("expected RestartPolicy=Never, got %s", pod.Spec.RestartPolicy)
}
if len(pod.Spec.Volumes) != 1 || pod.Spec.Volumes[0].PersistentVolumeClaim == nil {
t.Fatalf("expected one PVC volume, got %+v", pod.Spec.Volumes)
}
if pod.Spec.Volumes[0].PersistentVolumeClaim.ClaimName != "demo-data" {
t.Fatalf("expected PVC claim name demo-data, got %s", pod.Spec.Volumes[0].PersistentVolumeClaim.ClaimName)
}
if len(pod.Spec.Containers) != 1 {
t.Fatalf("expected one container, got %d", len(pod.Spec.Containers))
}
c := pod.Spec.Containers[0]
if c.Image != "alpine:3" {
t.Fatalf("expected image alpine:3, got %s", c.Image)
}
if !c.Stdin || !c.TTY {
t.Fatalf("expected stdin and tty true on the container, got stdin=%v tty=%v", c.Stdin, c.TTY)
}
if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].MountPath != "/data" {
t.Fatalf("expected one volume mount at /data, got %+v", c.VolumeMounts)
}
}
func TestBuildStoragePodSpecChownToSecurityContext(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerK3s,
Chown: "herokuish",
}
pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{Image: "alpine:3"})
if err != nil {
t.Fatalf("err: %v", err)
}
if pod.Spec.SecurityContext == nil || pod.Spec.SecurityContext.RunAsUser == nil {
t.Fatalf("expected SecurityContext.RunAsUser to be populated for chown=herokuish")
}
// Don't assert exact UID because ResolveChownID consults docker for
// userns offset; only assert non-nil and non-zero.
if *pod.Spec.SecurityContext.RunAsUser == 0 {
t.Fatalf("expected non-zero UID for chown=herokuish, got 0")
}
}
func TestBuildStoragePodSpecAsUserOverride(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerK3s,
Chown: "herokuish",
}
pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{
Image: "alpine:3",
AsUser: "0",
})
if err != nil {
t.Fatalf("err: %v", err)
}
if pod.Spec.SecurityContext == nil || pod.Spec.SecurityContext.RunAsUser == nil {
t.Fatalf("expected RunAsUser to be set")
}
if *pod.Spec.SecurityContext.RunAsUser != 0 {
t.Fatalf("expected RunAsUser to be 0 (override), got %d", *pod.Spec.SecurityContext.RunAsUser)
}
}
func TestBuildStoragePodSpecChownFalseLeavesDefaultUser(t *testing.T) {
entry := &storage.Entry{
Name: "demo-data",
Scheduler: storage.SchedulerK3s,
Chown: "false",
}
pod, err := buildStoragePodSpec("p", "default", entry, StorageExecInput{Image: "alpine:3"})
if err != nil {
t.Fatalf("err: %v", err)
}
if pod.Spec.SecurityContext != nil {
t.Fatalf("expected no SecurityContext when chown=false, got %+v", pod.Spec.SecurityContext)
}
}

View File

@@ -244,87 +244,80 @@ func CommandSet(input CommandSetInput) error {
return nil
}
// CommandExec spawns a temporary container/Pod that mounts the entry
// and runs the given command (or an interactive shell when args is
// empty). It execs into the underlying CLI (docker or kubectl) so
// stdin/stdout/stderr stream naturally.
func CommandExec(name string, image string, args []string) error {
if !EntryExists(name) {
return fmt.Errorf("storage entry %q does not exist", name)
// CommandExecInput captures the storage:exec subcommand inputs.
type CommandExecInput struct {
Name string
Image string
AsUser string
Args []string
}
// CommandExec delegates the actual exec to the scheduler plugin that owns
// the storage entry, by firing the scheduler-storage-exec plugn trigger
// with stdin/stdout/stderr streamed through. The handler decides between
// docker run, the k8s exec SDK, etc. - storage stays scheduler-agnostic.
//
// Exit codes from the underlying tool are propagated verbatim via
// os.Exit so callers in scripts see the right status.
func CommandExec(input CommandExecInput) error {
if !EntryExists(input.Name) {
return fmt.Errorf("storage entry %q does not exist", input.Name)
}
entry, err := LoadEntry(name)
entry, err := LoadEntry(input.Name)
if err != nil {
return err
}
image := input.Image
if image == "" {
image = "alpine:3"
}
cmd := args
if len(cmd) == 0 {
cmd = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
interactive, tty := stdinModes()
triggerArgs := []string{
entry.Scheduler,
entry.Name,
image,
}
triggerArgs = append(triggerArgs, fmt.Sprintf("--interactive=%t", interactive))
triggerArgs = append(triggerArgs, fmt.Sprintf("--tty=%t", tty))
if input.AsUser != "" {
triggerArgs = append(triggerArgs, "--as-user", input.AsUser)
}
if len(input.Args) > 0 {
triggerArgs = append(triggerArgs, "--")
triggerArgs = append(triggerArgs, input.Args...)
}
if entry.Scheduler == SchedulerDockerLocal {
dockerArgs := []string{"run", "--rm", "-it", "-v", fmt.Sprintf("%s:/data", entry.HostPath), image}
dockerArgs = append(dockerArgs, cmd...)
return execStream(common.DockerBin(), dockerArgs)
}
// k3s: render a Pod-spec override that mounts the PVC, then kubectl run.
namespace := entry.Namespace
if namespace == "" {
namespace = "default"
}
overrides := map[string]any{
"apiVersion": "v1",
"spec": map[string]any{
"containers": []map[string]any{
{
"name": "exec",
"image": image,
"stdin": true,
"tty": true,
"volumeMounts": []map[string]any{{"name": "data", "mountPath": "/data"}},
},
},
"volumes": []map[string]any{
{"name": "data", "persistentVolumeClaim": map[string]any{"claimName": entry.Name}},
},
},
}
overrideJSON, err := json.Marshal(overrides)
if err != nil {
return err
}
kubectlArgs := []string{
"run", "dokku-storage-exec-" + entry.Name,
"--rm", "-it",
"--namespace=" + namespace,
"--image=" + image,
"--restart=Never",
"--overrides=" + string(overrideJSON),
"--",
}
kubectlArgs = append(kubectlArgs, cmd...)
return execStream("kubectl", kubectlArgs)
}
func execStream(bin string, args []string) error {
result, err := common.CallExecCommand(common.ExecCommandInput{
Command: bin,
Args: args,
results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "scheduler-storage-exec",
Args: triggerArgs,
StreamStdio: true,
})
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("%s exited with %d", bin, result.ExitCode)
if results.ExitCode != 0 {
os.Exit(results.ExitCode)
}
return nil
}
// stdinModes inspects os.Stdin to decide whether docker / the k8s exec
// SDK should request an interactive session and a TTY.
func stdinModes() (interactive bool, tty bool) {
fi, err := os.Stdin.Stat()
if err != nil || fi == nil {
return false, false
}
mode := fi.Mode()
tty = mode&os.ModeCharDevice != 0
hasStdinData := mode&os.ModeNamedPipe != 0
interactive = tty || hasStdinData
return interactive, tty
}
// CommandWait blocks until a k3s storage entry's PVC is bound.
func CommandWait(name string) error {
if !EntryExists(name) {
@@ -463,7 +456,7 @@ func ensureDockerLocalPath(entry *Entry) error {
}
if entry.Chown != "" && entry.Chown != "false" {
chownID, err := resolveChownID(entry.Chown)
chownID, err := ResolveChownID(entry.Chown)
if err != nil {
return err
}

View File

@@ -44,6 +44,10 @@ var (
// required for resource names like PVCs and Helm releases.
dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
// dockerNamedVolumeRegexp matches a Docker named-volume token. Same
// rule docker uses to disambiguate volume names from bind paths.
dockerNamedVolumeRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
// supportedSchedulers lists the values accepted for --scheduler.
supportedSchedulers = map[string]bool{
SchedulerDockerLocal: true,
@@ -225,8 +229,8 @@ func (e *Entry) Validate() error {
if e.HostPath == "" {
return fmt.Errorf("storage entry %q (docker-local) is missing host_path", e.Name)
}
if !filepath.IsAbs(e.HostPath) {
return fmt.Errorf("storage entry %q host_path must be absolute, got %q", e.Name, e.HostPath)
if !filepath.IsAbs(e.HostPath) && !dockerNamedVolumeRegexp.MatchString(e.HostPath) {
return fmt.Errorf("storage entry %q host_path must be an absolute path or a docker named volume token, got %q", e.Name, e.HostPath)
}
if e.Size != "" {
return fmt.Errorf("storage entry %q (docker-local) does not accept --size", e.Name)

View File

@@ -65,9 +65,18 @@ func TestEntryValidateDockerLocal(t *testing.T) {
noPath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal}
Expect(noPath.Validate()).To(HaveOccurred())
// A leading-slash-less but DNS-1123-ish token is treated as a docker
// named volume and accepted.
namedVolume := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "myvolume"}
Expect(namedVolume.Validate()).To(Succeed())
// Slash-containing relative paths still fail, as do tokens with bad chars.
relativePath := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "relative/path"}
Expect(relativePath.Validate()).To(HaveOccurred())
badToken := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "with spaces"}
Expect(badToken.Validate()).To(HaveOccurred())
withSize := &Entry{Name: "foo", Scheduler: SchedulerDockerLocal, HostPath: "/data", Size: "2Gi"}
Expect(withSize.Validate()).To(HaveOccurred())

View File

@@ -113,6 +113,7 @@ func main() {
case "exec":
args := flag.NewFlagSet("storage:exec", flag.ExitOnError)
image := args.String("image", "", "--image: container image to use (default alpine:3)")
asUser := args.String("as-user", "", "--as-user: numeric uid to run the exec container as (overrides the entry's chown)")
args.Parse(os.Args[2:])
positional := args.Args()
if len(positional) == 0 {
@@ -124,7 +125,12 @@ func main() {
if len(positional) > 1 {
cmd = positional[1:]
}
err = storage.CommandExec(name, *image, cmd)
err = storage.CommandExec(storage.CommandExecInput{
Name: name,
Image: *image,
AsUser: *asUser,
Args: cmd,
})
case "wait":
args := flag.NewFlagSet("storage:wait", flag.ExitOnError)
args.Parse(os.Args[2:])

View File

@@ -46,7 +46,7 @@ func CommandEnsureDirectory(directory string, chownFlag string) error {
return err
}
chownID, err := resolveChownID(chownFlag)
chownID, err := ResolveChownID(chownFlag)
if err != nil {
return err
}
@@ -80,8 +80,8 @@ func CommandEnsureDirectory(directory string, chownFlag string) error {
return nil
}
// resolveChownID converts a chown flag value to a numeric UID
func resolveChownID(chownFlag string) (string, error) {
// ResolveChownID converts a chown flag value to a numeric UID
func ResolveChownID(chownFlag string) (string, error) {
var chownID string
switch chownFlag {

View File

@@ -299,6 +299,29 @@ teardown() {
assert_success
}
@test "(storage) storage:exec runs a non-interactive command and propagates exit code" {
run /bin/bash -c "dokku storage:create rdmtest-exec"
assert_success
# docker pull image up-front so the test isn't gated on registry latency.
run /bin/bash -c "docker pull alpine:3 >/dev/null"
assert_success
run /bin/bash -c "dokku storage:exec rdmtest-exec -- /bin/sh -c 'touch /data/marker && ls /data/marker'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output_contains "/data/marker"
run /bin/bash -c "dokku storage:exec rdmtest-exec -- /bin/sh -c 'exit 42'"
echo "output: $output"
echo "status: $status"
assert_equal "$status" 42
run /bin/bash -c "dokku storage:destroy rdmtest-exec"
assert_success
}
@test "(storage) storage:ensure-directory emits deprecation warning" {
run /bin/bash -c "dokku storage:ensure-directory $TEST_APP 2>&1"
echo "output: $output"