Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions pkg/provider/aws/translate.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ func buildUserData(spec InstanceSpec) (string, error) {
// which takes a single program; Command[0] is the entrypoint and Command[1:]
// become leading arguments (Docker only lets --entrypoint carry the program).
// - Args (Pod args) are appended after as CMD arguments.
// When Command is empty the image's own ENTRYPOINT runs; when Args is empty the
// image's own CMD runs — so an image with a baked-in entrypoint launches exactly
// as it would under a kubelet. Env keys are sorted so the rendered script is
// deterministic (stable across reconciles and easy to assert in tests).
// With both empty the image's own ENTRYPOINT and CMD run, as under a kubelet. Either
// field alone drops the image's CMD — Kubernetes falls back to it only when neither is
// set, and Docker's --entrypoint clears it to match. Env keys are sorted so the
// rendered script is deterministic (stable across reconciles and easy to assert in tests).
b.WriteString("docker run --rm --gpus all")
for _, k := range sortedKeys(spec.Env) {
fmt.Fprintf(&b, " -e %s", shellQuote(k+"="+spec.Env[k]))
Expand Down
46 changes: 43 additions & 3 deletions pkg/provider/modal/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -167,7 +168,8 @@ func (c *sdkClient) CreateSandbox(ctx context.Context, spec SandboxSpec) (string

createStart := time.Now()
sb, err := c.mc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{
Command: spec.Command,
Command: entrypointArgs(spec),
Workdir: spec.WorkingDir,
// Env is the whole environment, including values resolved from this cluster's
// Secrets (see provider.ProvisionRequest.Env). No Secrets field alongside it: the
// SDK hydrates this map into an ephemeral server-side Modal Secret before the
Expand Down Expand Up @@ -211,9 +213,47 @@ func (c *sdkClient) CreateSandbox(ctx context.Context, spec SandboxSpec) (string
return sb.SandboxID, cred, nil
}

// imageFor resolves the sandbox's image, attaching pull credentials when the spec carries
// them. Modal takes them as a Secret, so this is where the spec's data becomes an SDK object.
// imageFor resolves the sandbox's image and, when the Pod set a `command`, clears the
// image's ENTRYPOINT so Kubernetes semantics hold.
func (c *sdkClient) imageFor(ctx context.Context, spec SandboxSpec) (*modal.Image, error) {
image, err := c.registryImage(ctx, spec)
if err != nil {
return nil, err
}
if !clearsEntrypoint(spec) {
return image, nil
}
// `ENTRYPOINT ["bash","-l"]` runs `bash -l sh -c <script>`, where bash reads `sh` as a
// script FILENAME, resolves it to the dash binary and dies parsing ELF —
// `/usr/bin/sh: cannot execute binary file`, exit 126, workload never started. A Pod
// that sets only `args` must keep the entrypoint, which is what Modal's prepend already
// means, so the reset is conditional.
return image.DockerfileCommands([]string{entrypointReset}, nil), nil
}

// clearsEntrypoint reports whether this spec needs the image's ENTRYPOINT cleared — see
// imageFor for why, and why `args` alone must not. Split out so the rule is testable
// without an SDK client.
func clearsEntrypoint(spec SandboxSpec) bool { return len(spec.Command) > 0 }

// entrypointReset is the Dockerfile line that clears an inherited ENTRYPOINT. Metadata
// only — it builds no layer content, so the cost is one extra build step, not a re-pull.
const entrypointReset = "ENTRYPOINT []"

// entrypointArgs flattens the Pod's command+args into the single argv Modal accepts. The
// two are kept apart in the spec only to decide the reset above; Modal cannot express the
// entrypoint/cmd distinction at create time.
func entrypointArgs(spec SandboxSpec) []string {
if len(spec.Command) == 0 {
return slices.Clone(spec.Args)
}
return append(slices.Clone(spec.Command), spec.Args...)
}

// registryImage resolves the image from its registry, attaching pull credentials when the
// spec carries them. Modal takes them as a Secret, so this is where the spec's data becomes
// an SDK object.
func (c *sdkClient) registryImage(ctx context.Context, spec SandboxSpec) (*modal.Image, error) {
a := spec.RegistryAuth
switch {
case a == nil:
Expand Down
23 changes: 18 additions & 5 deletions pkg/provider/modal/modal.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ limitations under the License.
// opaque candidate and regionsOf splits it back at the API boundary. The cost is
// blocklist precision, which is free here since no Modal failure is
// region-attributable.
// - The image's ENTRYPOINT is PREPENDED to the sandbox argv, where Kubernetes has
// `command` replace it. The adapter clears the entrypoint to restore the Kubernetes
// contract; see imageFor.
// - Sandboxes carry native tags, so NativeTags=true and ClaimName is a tag rather
// than smuggled into the instance name.
// - There is no preemption push; detection is poll-based.
Expand All @@ -47,6 +50,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -129,8 +133,15 @@ type Client interface {
type SandboxSpec struct {
// Image is the container image, from the Pod's first container.
Image string
// Command is the container command+args, from the Pod.
// Command and Args are the Pod's container command and args. Modal takes a single
// argv (entrypointArgs concatenates them), but they are carried apart because only
// `command` replaces the image's entrypoint under Kubernetes semantics, and that is
// what decides whether the image needs its entrypoint cleared — see imageFor.
Command []string
Args []string
// WorkingDir is the Pod's container workingDir. Empty leaves the image's own WORKDIR
// standing, which is what a Pod that omits the field means.
WorkingDir string
// Env is the environment, taken whole from provider.ProvisionRequest.Env: literals plus
// everything envFrom/valueFrom referenced, already resolved by the caller.
//
Expand Down Expand Up @@ -216,10 +227,10 @@ type SandboxSpec struct {
// (they are in the Pod spec already), values never do. The probe renders as set/unset — it is
// a pointer, so %v would print an address, and only its presence matters.
func (s SandboxSpec) String() string {
return fmt.Sprintf("SandboxSpec{Image:%s Command:%v Env:%s GPU:%s GPUCount:%d CPU:%g "+
return fmt.Sprintf("SandboxSpec{Image:%s Command:%v Args:%v WorkingDir:%s Env:%s GPU:%s GPUCount:%d CPU:%g "+
"CPULimit:%g MemoryMiB:%d MemoryLimitMiB:%d Ports:%v Regions:%v Egress:%s "+
"EgressTargets:%v Timeout:%s Tags:%v ReadinessProbe:%t RegistryAuth:%s}",
s.Image, s.Command, provider.RedactedEnv(s.Env), s.GPU, s.GPUCount, s.CPU,
s.Image, s.Command, s.Args, s.WorkingDir, provider.RedactedEnv(s.Env), s.GPU, s.GPUCount, s.CPU,
s.CPULimit, s.MemoryMiB, s.MemoryLimitMiB, s.Ports, s.Regions, s.EgressMode,
s.EgressTargets, s.Timeout, s.Tags, s.ReadinessProbe != nil, s.RegistryAuth)
}
Expand Down Expand Up @@ -573,8 +584,10 @@ func (p *Provider) sandboxSpecFromPod(pod *corev1.Pod, req provider.ProvisionReq
}

spec := SandboxSpec{
Image: c.Image,
Command: append(append([]string{}, c.Command...), c.Args...),
Image: c.Image,
Command: slices.Clone(c.Command),
Args: slices.Clone(c.Args),
WorkingDir: c.WorkingDir,
// The caller's resolved map is the whole environment — the Pod's literals plus
// everything envFrom/valueFrom referenced. pod.Spec.Containers[0].Env is NOT read
// here: it holds references this adapter has no cluster access to follow. See
Expand Down
71 changes: 64 additions & 7 deletions pkg/provider/modal/modal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,12 @@ func newTestProvider(f *fakeClient) *Provider {
// so tests can also exercise non-canonical casing (e.g. "h100").
func gpuPod(claim, accel string, count int64) *corev1.Pod {
c := corev1.Container{
Name: "main",
Image: "myimg:latest",
Command: []string{"run"},
Args: []string{"--flag"},
Env: []corev1.EnvVar{{Name: "FOO", Value: "bar"}},
Name: "main",
Image: "myimg:latest",
Command: []string{"run"},
Args: []string{"--flag"},
WorkingDir: "/workspace",
Env: []corev1.EnvVar{{Name: "FOO", Value: "bar"}},
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "default"},
Expand Down Expand Up @@ -208,8 +209,64 @@ func TestProvision_GPUPod(t *testing.T) {
if got := f.lastSpec.Tags[ClaimTagKey]; got != "claim-a" {
t.Fatalf("claim tag = %q, want claim-a", got)
}
if len(f.lastSpec.Command) != 2 || f.lastSpec.Command[0] != "run" {
t.Fatalf("command = %v", f.lastSpec.Command)
// Carried apart, not concatenated: the split is what decides the entrypoint reset.
if !slices.Equal(f.lastSpec.Command, []string{"run"}) ||
!slices.Equal(f.lastSpec.Args, []string{"--flag"}) {
t.Fatalf("command = %v, args = %v", f.lastSpec.Command, f.lastSpec.Args)
}
// Dropping this silently ran the workload in the image's WORKDIR instead.
if f.lastSpec.WorkingDir != "/workspace" {
t.Fatalf("workingDir = %q, want /workspace", f.lastSpec.WorkingDir)
}
}

// A Pod's `command` REPLACES the image entrypoint while Modal PREPENDS it, so the adapter
// clears the entrypoint for exactly that case. `args` alone must keep it — Modal's prepend
// IS the Kubernetes args semantics — and clearing it there would drop the workload's
// entrypoint entirely. See imageFor.
func TestClearsEntrypoint(t *testing.T) {
cases := []struct {
name string
command []string
args []string
want bool
}{
{name: "command and args", command: []string{"sh", "-c"}, args: []string{"echo hi"}, want: true},
{name: "command only", command: []string{"/bin/serve"}, want: true},
{name: "args only", args: []string{"--flag"}, want: false},
{name: "neither", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
spec := SandboxSpec{Command: tc.command, Args: tc.args}
if got := clearsEntrypoint(spec); got != tc.want {
t.Fatalf("clearsEntrypoint = %v, want %v", got, tc.want)
}
})
}
}

// Modal accepts one argv, so command+args flatten in Pod order.
func TestEntrypointArgs(t *testing.T) {
cases := []struct {
name string
command []string
args []string
want []string
}{
{name: "command and args", command: []string{"sh", "-c"}, args: []string{"echo hi"},
want: []string{"sh", "-c", "echo hi"}},
{name: "command only", command: []string{"/bin/serve"}, want: []string{"/bin/serve"}},
{name: "args only", args: []string{"--flag"}, want: []string{"--flag"}},
{name: "neither", want: nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
spec := SandboxSpec{Command: tc.command, Args: tc.args}
if got := entrypointArgs(spec); !slices.Equal(got, tc.want) {
t.Fatalf("entrypointArgs = %v, want %v", got, tc.want)
}
})
}
}

Expand Down
Loading