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

@@ -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)
}
}