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

@@ -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 {