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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
160
plugins/scheduler-docker-local/storage_exec.go
Normal file
160
plugins/scheduler-docker-local/storage_exec.go
Normal 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
|
||||
}
|
||||
118
plugins/scheduler-docker-local/storage_exec_test.go
Normal file
118
plugins/scheduler-docker-local/storage_exec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
236
plugins/scheduler-k3s/storage_exec.go
Normal file
236
plugins/scheduler-k3s/storage_exec.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
103
plugins/scheduler-k3s/storage_exec_test.go
Normal file
103
plugins/scheduler-k3s/storage_exec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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:])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user