diff --git a/docs/development/plugin-triggers.md b/docs/development/plugin-triggers.md index e2d8ce464..dc2399ace 100644 --- a/docs/development/plugin-triggers.md +++ b/docs/development/plugin-triggers.md @@ -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 `` 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 ` (override `entry.Chown`). + ### `traefik-template-source` - Description: Retrieves an alternative template for the traefik compose config diff --git a/plugins/scheduler-docker-local/Makefile b/plugins/scheduler-docker-local/Makefile index bd7c75ae1..e781a2d7f 100644 --- a/plugins/scheduler-docker-local/Makefile +++ b/plugins/scheduler-docker-local/Makefile @@ -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 diff --git a/plugins/scheduler-docker-local/go.mod b/plugins/scheduler-docker-local/go.mod index 8db9ad568..8eb6eddd8 100644 --- a/plugins/scheduler-docker-local/go.mod +++ b/plugins/scheduler-docker-local/go.mod @@ -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 diff --git a/plugins/scheduler-docker-local/go.sum b/plugins/scheduler-docker-local/go.sum index fd1216a63..caf492d5a 100644 --- a/plugins/scheduler-docker-local/go.sum +++ b/plugins/scheduler-docker-local/go.sum @@ -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= diff --git a/plugins/scheduler-docker-local/src/triggers/triggers.go b/plugins/scheduler-docker-local/src/triggers/triggers.go index 7ee657f92..2e2c4ff61 100644 --- a/plugins/scheduler-docker-local/src/triggers/triggers.go +++ b/plugins/scheduler-docker-local/src/triggers/triggers.go @@ -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 ") + 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) } diff --git a/plugins/scheduler-docker-local/storage_exec.go b/plugins/scheduler-docker-local/storage_exec.go new file mode 100644 index 000000000..15938ba02 --- /dev/null +++ b/plugins/scheduler-docker-local/storage_exec.go @@ -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 +} diff --git a/plugins/scheduler-docker-local/storage_exec_test.go b/plugins/scheduler-docker-local/storage_exec_test.go new file mode 100644 index 000000000..110bab0eb --- /dev/null +++ b/plugins/scheduler-docker-local/storage_exec_test.go @@ -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) + } +} diff --git a/plugins/scheduler-k3s/Makefile b/plugins/scheduler-k3s/Makefile index 69b114ba4..13dc52e1b 100644 --- a/plugins/scheduler-k3s/Makefile +++ b/plugins/scheduler-k3s/Makefile @@ -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 diff --git a/plugins/scheduler-k3s/src/triggers/triggers.go b/plugins/scheduler-k3s/src/triggers/triggers.go index e9720f7d2..6998f9b1d 100644 --- a/plugins/scheduler-k3s/src/triggers/triggers.go +++ b/plugins/scheduler-k3s/src/triggers/triggers.go @@ -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 ") + 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) } diff --git a/plugins/scheduler-k3s/storage_exec.go b/plugins/scheduler-k3s/storage_exec.go new file mode 100644 index 000000000..a7193cde1 --- /dev/null +++ b/plugins/scheduler-k3s/storage_exec.go @@ -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): + } + } +} diff --git a/plugins/scheduler-k3s/storage_exec_test.go b/plugins/scheduler-k3s/storage_exec_test.go new file mode 100644 index 000000000..7bc3b0cf6 --- /dev/null +++ b/plugins/scheduler-k3s/storage_exec_test.go @@ -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) + } +} diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go index d4d62c1c7..71b5bb3e3 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -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 } diff --git a/plugins/storage/entry.go b/plugins/storage/entry.go index 3fae69d66..470ac3781 100644 --- a/plugins/storage/entry.go +++ b/plugins/storage/entry.go @@ -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) diff --git a/plugins/storage/entry_test.go b/plugins/storage/entry_test.go index 804245b1f..d317452dc 100644 --- a/plugins/storage/entry_test.go +++ b/plugins/storage/entry_test.go @@ -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()) diff --git a/plugins/storage/src/subcommands/subcommands.go b/plugins/storage/src/subcommands/subcommands.go index 9fc49a81b..6af83275d 100644 --- a/plugins/storage/src/subcommands/subcommands.go +++ b/plugins/storage/src/subcommands/subcommands.go @@ -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:]) diff --git a/plugins/storage/subcommands.go b/plugins/storage/subcommands.go index 96b76800e..e3d489f1f 100644 --- a/plugins/storage/subcommands.go +++ b/plugins/storage/subcommands.go @@ -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 { diff --git a/tests/unit/storage.bats b/tests/unit/storage.bats index eed154ab3..ad48240ed 100644 --- a/tests/unit/storage.bats +++ b/tests/unit/storage.bats @@ -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"