diff --git a/cmd/ateapi/internal/controlapi/workload_spec.go b/cmd/ateapi/internal/controlapi/workload_spec.go index a0d0a806b..baa915238 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec.go +++ b/cmd/ateapi/internal/controlapi/workload_spec.go @@ -59,6 +59,7 @@ func workloadSpecFromActorTemplate(actorTemplate *atev1alpha1.ActorTemplate) *at Name: ctr.Name, Image: ctr.Image, Command: ctr.Command, + Args: ctr.Args, Readyz: toAteletReadyz(ctr.Readyz), } for _, mount := range ctr.VolumeMounts { diff --git a/cmd/ateapi/internal/controlapi/workload_spec_test.go b/cmd/ateapi/internal/controlapi/workload_spec_test.go index 24918f17f..4113420a4 100644 --- a/cmd/ateapi/internal/controlapi/workload_spec_test.go +++ b/cmd/ateapi/internal/controlapi/workload_spec_test.go @@ -168,6 +168,30 @@ func TestWorkloadSpecFromActorTemplate(t *testing.T) { Containers: []*ateletpb.Container{{Name: "main", Image: "main"}}, }, }, + { + name: "maps command and args", + template: &atev1alpha1.ActorTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "tmpl1", Namespace: "agent-ns"}, + Spec: atev1alpha1.ActorTemplateSpec{ + Containers: []atev1alpha1.Container{ + { + Name: "main", + Image: "main", + Command: []string{"/entrypoint"}, + Args: []string{"--foo", "--bar"}, + }, + }, + }, + }, + want: &ateletpb.WorkloadSpec{ + Containers: []*ateletpb.Container{{ + Name: "main", + Image: "main", + Command: []string{"/entrypoint"}, + Args: []string{"--foo", "--bar"}, + }}, + }, + }, } for _, tt := range tests { diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index e99e42815..33a0d5331 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -244,7 +244,7 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (*atele if err := s.prepareOCIBundles(ctx, actorUID, actorName, req.GetSpec(), req.GetTargetAteomUid(), ); err != nil { - return nil, err + return nil, ateerrors.CrashIfReason(ctx, err, ateerrors.ReasonInvalidContainerConfig) } client, err := s.dialAteom(ctx, req.GetTargetAteomUid()) @@ -534,7 +534,7 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) } t := time.Now() if err := s.prepareOCIBundles(gctx, actorUID, actorName, req.GetSpec(), req.GetTargetAteomUid()); err != nil { - return ateerrors.CrashIfReason(ctx, err, ateerrors.ReasonTerminalFileSystemError) + return ateerrors.CrashIfReason(ctx, err, ateerrors.ReasonTerminalFileSystemError, ateerrors.ReasonInvalidContainerConfig) } dBundles = time.Since(t) return nil @@ -700,6 +700,7 @@ func (s *AteomHerder) prepareOCIBundles( spec.GetPauseImage(), []string{"/pause"}, nil, + nil, annotations, ateompath.AteomNetNSPath(targetAteomUid), "", // pause is sandbox infra; it gets no actor identity mount. @@ -731,6 +732,7 @@ func (s *AteomHerder) prepareOCIBundles( ctr.GetName(), ctr.GetImage(), ctr.GetCommand(), + ctr.GetArgs(), envs, map[string]string{ "io.kubernetes.cri.container-type": "container", diff --git a/cmd/atelet/oci.go b/cmd/atelet/oci.go index d4b7f5b24..aceb4e54d 100644 --- a/cmd/atelet/oci.go +++ b/cmd/atelet/oci.go @@ -30,6 +30,7 @@ import ( "strings" "github.com/agent-substrate/substrate/cmd/atelet/internal/memorypullcache" + "github.com/agent-substrate/substrate/internal/ateerrors" "github.com/agent-substrate/substrate/internal/ateompath" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/opencontainers/runtime-spec/specs-go" @@ -56,7 +57,7 @@ const ( ActorIDFileName = "actor-id" ) -func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, actorUID, containerName, ref string, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) error { +func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryPullCache, actorUID, containerName, ref string, command, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) error { tracer := otel.Tracer("prepareOCIDirectory") ctx, span := tracer.Start(ctx, "prepareOCIDirectory") @@ -80,6 +81,14 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP } defer tarData.Close() + // Argv and env need only the image config, so resolve them before the + // untar to fail fast on an invalid container config. + resolvedArgs, err := resolveProcessArgs(imageCfg, command, args) + if err != nil { + return fmt.Errorf("while resolving process args for container %q: %w", containerName, err) + } + resolvedEnv := resolveActorEnv(imageCfg, env) + if err := untar(ctx, tarData, rootPath); err != nil { return fmt.Errorf("in untar: %w", err) } @@ -93,7 +102,7 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP } } - ociSpec := buildActorOCISpec(actorUID, imageCfg, args, env, annotations, netns, identityDir, durableDirVolumeMounts) + ociSpec := buildActorOCISpec(actorUID, resolvedArgs, resolvedEnv, annotations, netns, identityDir, durableDirVolumeMounts) ociSpecBytes, err := json.MarshalIndent(ociSpec, "", " ") if err != nil { return fmt.Errorf("while marshaling OCI spec: %w", err) @@ -106,10 +115,16 @@ func prepareOCIDirectory(ctx context.Context, pullCache *memorypullcache.MemoryP return nil } -// mergeActorEnv merges the ActorTemplate env and the image's ENV, with the template taking precedence. -// duplicated keys are removed in favor of the following precedence template env > image env. -// default PATH stands in for an image config with no env -func mergeActorEnv(imageEnv, templateEnv []string) []string { +// resolveActorEnv computes the final container environment from the image's ENV +// and the ActorTemplate env, with the template taking precedence. Duplicate keys +// are removed in favor of template env > image env, and a default PATH stands in +// when neither source sets one. +func resolveActorEnv(imageCfg *v1.Config, templateEnv []string) []string { + var imageEnv []string + if imageCfg != nil { + imageEnv = imageCfg.Env + } + seen := make(map[string]struct{}) var out []string add := func(entries ...string) { @@ -132,17 +147,39 @@ func mergeActorEnv(imageEnv, templateEnv []string) []string { return out } -// buildActorOCISpec assembles the OCI runtime spec for an actor container. -// When identityDir is non-empty it adds a read-only bind mount of that host -// directory at IdentityMountPath so the actor can read its own ID (see -// IdentityMountPath for why this is a bind mount rather than env vars). -func buildActorOCISpec(actorUID string, imageCfg *v1.Config, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) *specs.Spec { - var imageEnv []string +// resolveProcessArgs computes the final process argv for a container, +// following Kubernetes Pod semantics: setting command overrides both the +// image's ENTRYPOINT and its CMD (CMD is dropped, not appended), while +// setting only args overrides just the image's CMD. +func resolveProcessArgs(imageCfg *v1.Config, command, args []string) ([]string, error) { + var entrypoint, cmd []string if imageCfg != nil { - imageEnv = imageCfg.Env + entrypoint = imageCfg.Entrypoint + cmd = imageCfg.Cmd + } + if len(command) > 0 { + entrypoint = command + cmd = nil + } + if len(args) > 0 { + cmd = args + } + + argv := make([]string, 0, len(entrypoint)+len(cmd)) + argv = append(argv, entrypoint...) + argv = append(argv, cmd...) + if len(argv) == 0 { + return nil, fmt.Errorf("%w: no command specified: image defines neither ENTRYPOINT nor CMD and the container sets neither command nor args", ateerrors.ReasonInvalidContainerConfig) } - envVars := mergeActorEnv(imageEnv, env) + return argv, nil +} +// buildActorOCISpec assembles the OCI runtime spec for an actor container from +// already-resolved args and env (see resolveProcessArgs and resolveActorEnv). +// When identityDir is non-empty it adds a read-only bind mount of that host +// directory at IdentityMountPath so the actor can read its own ID (see +// IdentityMountPath for why this is a bind mount rather than env vars). +func buildActorOCISpec(actorUID string, args []string, env []string, annotations map[string]string, netns string, identityDir string, durableDirVolumeMounts []*ateletpb.VolumeMount) *specs.Spec { mounts := []specs.Mount{ { Destination: "/proc", @@ -188,7 +225,7 @@ func buildActorOCISpec(actorUID string, imageCfg *v1.Config, args []string, env GID: 0, }, Args: args, - Env: envVars, + Env: env, Cwd: "/", Capabilities: &specs.LinuxCapabilities{ Bounding: []string{ diff --git a/cmd/atelet/oci_test.go b/cmd/atelet/oci_test.go index 56cb66847..cdc12dcbd 100644 --- a/cmd/atelet/oci_test.go +++ b/cmd/atelet/oci_test.go @@ -18,14 +18,17 @@ import ( "archive/tar" "bytes" "context" + "errors" "os" "path/filepath" "slices" "strings" "testing" + "github.com/agent-substrate/substrate/internal/ateerrors" "github.com/agent-substrate/substrate/internal/ateompath" "github.com/agent-substrate/substrate/internal/proto/ateletpb" + v1 "github.com/google/go-containerregistry/pkg/v1" ) type tarEntry struct { @@ -88,7 +91,6 @@ func runUntar(t *testing.T, entries []tarEntry) (string, error) { func TestBuildActorOCISpec_IdentityMount(t *testing.T) { spec := buildActorOCISpec( "actor_uid", - nil, []string{"/app"}, []string{"FOO=bar"}, map[string]string{"k": "v"}, @@ -117,49 +119,135 @@ func TestBuildActorOCISpec_IdentityMount(t *testing.T) { } } -func TestMergeActorEnv(t *testing.T) { +func TestResolveActorEnv(t *testing.T) { defaultPath := "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" tests := []struct { name string - imageEnv []string + image *v1.Config templateEnv []string want []string }{ { name: "template overrides image by key", - imageEnv: []string{"FOO=image"}, + image: &v1.Config{Env: []string{"FOO=image"}}, templateEnv: []string{"FOO=template"}, want: []string{"FOO=template", defaultPath}, }, { name: "default PATH applies when neither sets it", - imageEnv: []string{"FOO=image"}, + image: &v1.Config{Env: []string{"FOO=image"}}, templateEnv: []string{"BAR=template"}, want: []string{"BAR=template", "FOO=image", defaultPath}, }, { - name: "image PATH overrides default", - imageEnv: []string{"PATH=/image/bin"}, - want: []string{"PATH=/image/bin"}, + name: "image PATH overrides default", + image: &v1.Config{Env: []string{"PATH=/image/bin"}}, + want: []string{"PATH=/image/bin"}, }, { name: "template PATH overrides default", + image: &v1.Config{}, templateEnv: []string{"PATH=/template/bin"}, want: []string{"PATH=/template/bin"}, }, { - name: "blank and keyless entries are dropped", - imageEnv: []string{"", "=novalue"}, - want: []string{defaultPath}, + name: "blank and keyless entries are dropped", + image: &v1.Config{Env: []string{"", "=novalue"}}, + want: []string{defaultPath}, + }, + { + name: "nil image config uses template env and default PATH", + image: nil, + templateEnv: []string{"FOO=template"}, + want: []string{"FOO=template", defaultPath}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveActorEnv(tc.image, tc.templateEnv) + if !slices.Equal(got, tc.want) { + t.Errorf("resolveActorEnv(%v, %v) =\n %v\nwant:\n %v", tc.image, tc.templateEnv, got, tc.want) + } + }) + } +} + +func TestResolveProcessArgs(t *testing.T) { + cfg := func(entrypoint, cmd []string) *v1.Config { + return &v1.Config{Entrypoint: entrypoint, Cmd: cmd} + } + + tests := []struct { + name string + image *v1.Config + command []string + args []string + want []string + wantErr bool + }{ + { + name: "image ENTRYPOINT+CMD used when neither is overridden", + image: cfg([]string{"/app"}, []string{"--serve"}), + want: []string{"/app", "--serve"}, + }, + { + name: "args override CMD, image ENTRYPOINT kept", + image: cfg([]string{"/init", "/wrapper.sh"}, nil), + args: []string{"serve"}, + want: []string{"/init", "/wrapper.sh", "serve"}, + }, + { + name: "command overrides both ENTRYPOINT and CMD", + image: cfg([]string{"/app"}, []string{"--serve"}), + command: []string{"/other"}, + want: []string{"/other"}, + }, + { + name: "command and args override both", + image: cfg([]string{"/app"}, []string{"--serve"}), + command: []string{"/other"}, + args: []string{"--flag"}, + want: []string{"/other", "--flag"}, + }, + { + name: "image ENTRYPOINT only, no CMD", + image: cfg([]string{"/ko-app/counter"}, nil), + want: []string{"/ko-app/counter"}, + }, + { + name: "no image config, command supplies argv", + image: nil, + command: []string{"/pause"}, + want: []string{"/pause"}, + }, + { + name: "empty argv is an error", + image: cfg(nil, nil), + wantErr: true, + }, + { + name: "nil image and no overrides is an error", + image: nil, + wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := mergeActorEnv(tc.imageEnv, tc.templateEnv) + got, err := resolveProcessArgs(tc.image, tc.command, tc.args) + if (err != nil) != tc.wantErr { + t.Fatalf("resolveProcessArgs(%v, %v, %v) err = %v, wantErr %v", tc.image, tc.command, tc.args, err, tc.wantErr) + } + if err != nil { + if !errors.Is(err, ateerrors.ReasonInvalidContainerConfig) { + t.Errorf("empty-argv error must carry ReasonInvalidContainerConfig, got: %v", err) + } + return + } if !slices.Equal(got, tc.want) { - t.Errorf("mergeActorEnv(%v, %v) =\n %v\nwant:\n %v", tc.imageEnv, tc.templateEnv, got, tc.want) + t.Errorf("resolveProcessArgs(%v, %v, %v) = %v, want %v", tc.image, tc.command, tc.args, got, tc.want) } }) } @@ -167,7 +255,7 @@ func TestMergeActorEnv(t *testing.T) { // Without an identity dir (the pause container), no identity mount appears. func TestBuildActorOCISpec_NoIdentityMountForPause(t *testing.T) { - bare := buildActorOCISpec("actor_uid", nil, []string{"/pause"}, nil, nil, "/run/netns/x", "", nil) + bare := buildActorOCISpec("actor_uid", []string{"/pause"}, nil, nil, "/run/netns/x", "", nil) for _, m := range bare.Mounts { if m.Destination == IdentityMountPath { t.Errorf("identity mount must be absent when identityDir is empty") @@ -185,7 +273,7 @@ func TestBuildActorOCISpec_DurableDirVolumeMounts(t *testing.T) { } spec := buildActorOCISpec( actorUID, - nil, []string{"/app"}, nil, nil, + []string{"/app"}, nil, nil, "/run/netns/x", "", durableDirs, diff --git a/demos/agent-secret/agent-secret.yaml.tmpl b/demos/agent-secret/agent-secret.yaml.tmpl index ac2c9c6a4..442f2e2b6 100644 --- a/demos/agent-secret/agent-secret.yaml.tmpl +++ b/demos/agent-secret/agent-secret.yaml.tmpl @@ -66,7 +66,6 @@ spec: containers: - name: agent-secret image: ko://github.com/agent-substrate/substrate/demos/agent-secret - command: ["/ko-app/agent-secret"] env: - name: PORT value: "80" diff --git a/demos/counter/counter-microvm.yaml.tmpl b/demos/counter/counter-microvm.yaml.tmpl index c20e626ac..05a6bf25c 100644 --- a/demos/counter/counter-microvm.yaml.tmpl +++ b/demos/counter/counter-microvm.yaml.tmpl @@ -115,7 +115,6 @@ spec: containers: - name: counter image: ko://github.com/agent-substrate/substrate/demos/counter - command: ["/ko-app/counter"] readyz: httpGet: path: /readyz diff --git a/demos/counter/counter.yaml.tmpl b/demos/counter/counter.yaml.tmpl index f1dc416b7..d1c411d35 100644 --- a/demos/counter/counter.yaml.tmpl +++ b/demos/counter/counter.yaml.tmpl @@ -42,7 +42,6 @@ spec: containers: - name: counter image: ko://github.com/agent-substrate/substrate/demos/counter - command: ["/ko-app/counter"] readyz: httpGet: path: /readyz diff --git a/demos/multi-template/multi-template.yaml.tmpl b/demos/multi-template/multi-template.yaml.tmpl index 43812706c..b4d0fc4e4 100644 --- a/demos/multi-template/multi-template.yaml.tmpl +++ b/demos/multi-template/multi-template.yaml.tmpl @@ -58,7 +58,6 @@ spec: containers: - name: counter image: ko://github.com/agent-substrate/substrate/demos/counter - command: ["/ko-app/counter"] workerSelector: matchLabels: workload: multi-template-shared @@ -75,7 +74,6 @@ spec: containers: - name: fspersist image: ko://github.com/agent-substrate/substrate/demos/multi-template/fspersist - command: ["/ko-app/fspersist"] workerSelector: matchLabels: workload: multi-template-shared diff --git a/demos/sandbox/sandbox.yaml.tmpl b/demos/sandbox/sandbox.yaml.tmpl index 50609193a..0265eea62 100644 --- a/demos/sandbox/sandbox.yaml.tmpl +++ b/demos/sandbox/sandbox.yaml.tmpl @@ -41,7 +41,6 @@ spec: containers: - name: sandbox image: ko://github.com/agent-substrate/substrate/demos/sandbox - command: ["/ko-app/sandbox"] env: - name: PORT value: "80" diff --git a/docs/api-guide.md b/docs/api-guide.md index c5ba25252..a67776c75 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -88,7 +88,7 @@ The `ActorTemplate` defines the code, environment, and state-management policies | Field | Type | Description | | :--- | :--- | :--- | -| `containers` | `[]Container` | **Required.** The workload definition (image, command, env, ports). Each container may also declare an optional `readyz` HTTP probe — see [Container Readiness Probe](#container-readiness-probe-readyz). | +| `containers` | `[]Container` | **Required.** The workload definition — see [Container Fields](#container-fields) below. Each container may also declare an optional `readyz` HTTP probe — see [Container Readiness Probe](#container-readiness-probe-readyz). | | `sandboxClass` | `string` | Optional. The sandbox runtime family this template's actors require: `gvisor` (default) or `microvm`. Only `WorkerPool`s whose `sandboxClass` matches are eligible. | | `workerSelector` | `*LabelSelector` | Optional. Gates which `WorkerPool`s actors from this template may use, by matching against each pool's labels. If unset, all pools are eligible (subject to the actor's own `worker_selector`). | | `snapshotsConfig` | `SnapshotsConfig` | **Required.** GCS bucket and folder where memory snapshots are stored. | @@ -110,6 +110,22 @@ Substrate bind-mounts a read-only, per-actor identity directory at **`/run/ate`* Read it fresh rather than caching it at process start. It is delivered as a per-actor bind mount, not an environment variable, precisely so it carries the correct name after a resume from the golden snapshot — an env var (or a file baked into the image) would be frozen at the *golden* actor's name, since it lives in the checkpointed process memory, and would therefore be identical for every actor of the template. +### Container Fields + +Each entry in `containers` describes one process to run in the actor's sandbox. + +| Field | Type | Description | +| :--- | :--- | :--- | +| `name` | `string` | **Required.** DNS-label-safe container name. | +| `image` | `string` | **Required.** Must be pinned by digest (`...@sha256:...`) — changing the image invalidates snapshots. | +| `command` | `[]string` | Optional. Entrypoint array. If unset, the image's `ENTRYPOINT` is used. If set, it replaces **both** the image's `ENTRYPOINT` and `CMD`. | +| `args` | `[]string` | Optional. Arguments to the entrypoint. If unset, the image's `CMD` is used (unless `command` is set, which discards the image's `CMD`). If set, it replaces the image's `CMD`. | +| `env` | `[]EnvVar` | Optional. Literal `value` entries or `valueFrom.secretKeyRef`. | +| `readyz` | `ContainerReadyz` | Optional. HTTP readiness probe — see [Container Readiness Probe](#container-readiness-probe-readyz). | +| `volumeMounts` | `[]VolumeMount` | Optional. Mounts a `spec.volumes` entry (e.g. `durableDir`) into this container. | + +`command` and `args` resolve against the container image's `ENTRYPOINT`/`CMD` the same way [Kubernetes Pod `command`/`args`](https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/) resolve against `ENTRYPOINT`/`CMD`. If the resolved argv is empty — the image sets neither `ENTRYPOINT` nor `CMD`, and the container sets neither `command` nor `args` — `Run`/`Restore` fails. + ### Container Readiness Probe (`readyz`) Each entry in `containers` may declare an optional **HTTP readiness probe** so the platform only treats the actor as "started" once the workload is actually serving traffic. This mirrors the role of `readinessProbe.httpGet` on a Kubernetes Pod container, but the gate is enforced inside ateom (the in-pod sandbox driver) rather than by the kubelet. @@ -144,9 +160,6 @@ spec: containers: - name: agent image: gcr.io/my-project/my-agent:latest - command: ["/app/server"] - ports: - - containerPort: 80 # Optional: gate Run/Restore on the agent's HTTP readiness endpoint. # See "Container Readiness Probe (readyz)" above. readyz: diff --git a/internal/ateerrors/ateerrors.go b/internal/ateerrors/ateerrors.go index 5d178c62a..a1c38bfe7 100644 --- a/internal/ateerrors/ateerrors.go +++ b/internal/ateerrors/ateerrors.go @@ -48,6 +48,10 @@ const ( ReasonFaileSaveSnapshot Reason = "FAILED_SAVE_SNAPSHOT" ReasonInvalidObjectURL Reason = "INVALID_OBJECT_URL" ReasonFailedGetExternalObject Reason = "FAILED_GET_EXTERNAL_OBJECT" + // ReasonInvalidContainerConfig marks a container whose configuration cannot + // produce a runnable process (e.g. the resolved argv is empty because the + // image defines no ENTRYPOINT/CMD and the ActorTemplate sets no command/args). + ReasonInvalidContainerConfig Reason = "INVALID_CONTAINER_CONFIG" ) // MetadataKeyActorCrashed marks (in ErrorInfo.Metadata) a failure that requires diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index b24d7036a..073abc8ee 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -685,6 +685,7 @@ type Container struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Image string `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"` Command []string `protobuf:"bytes,3,rep,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,7,rep,name=args,proto3" json:"args,omitempty"` Env []*EnvEntry `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty"` Readyz *Readyz `protobuf:"bytes,5,opt,name=readyz,proto3" json:"readyz,omitempty"` VolumeMounts []*VolumeMount `protobuf:"bytes,6,rep,name=volume_mounts,json=volumeMounts,proto3" json:"volume_mounts,omitempty"` @@ -743,6 +744,13 @@ func (x *Container) GetCommand() []string { return nil } +func (x *Container) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + func (x *Container) GetEnv() []*EnvEntry { if x != nil { return x.Env @@ -1494,11 +1502,12 @@ const file_atelet_proto_rawDesc = "" + "\vVolumeMount\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "mount_path\x18\x02 \x01(\tR\tmountPath\"\xd5\x01\n" + + "mount_path\x18\x02 \x01(\tR\tmountPath\"\xe9\x01\n" + "\tContainer\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05image\x18\x02 \x01(\tR\x05image\x12\x18\n" + - "\acommand\x18\x03 \x03(\tR\acommand\x12\"\n" + + "\acommand\x18\x03 \x03(\tR\acommand\x12\x12\n" + + "\x04args\x18\a \x03(\tR\x04args\x12\"\n" + "\x03env\x18\x04 \x03(\v2\x10.atelet.EnvEntryR\x03env\x12&\n" + "\x06readyz\x18\x05 \x01(\v2\x0e.atelet.ReadyzR\x06readyz\x128\n" + "\rvolume_mounts\x18\x06 \x03(\v2\x13.atelet.VolumeMountR\fvolumeMounts\"4\n" + diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 98706bf9d..120c13c80 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -108,6 +108,7 @@ message Container { string name = 1; string image = 2; repeated string command = 3; + repeated string args = 7; repeated EnvEntry env = 4; Readyz readyz = 5; repeated VolumeMount volume_mounts = 6; diff --git a/manifests/ate-install/generated/ate.dev_actortemplates.yaml b/manifests/ate-install/generated/ate.dev_actortemplates.yaml index dfc94dca3..cbb06d361 100644 --- a/manifests/ate-install/generated/ate.dev_actortemplates.yaml +++ b/manifests/ate-install/generated/ate.dev_actortemplates.yaml @@ -65,8 +65,26 @@ spec: description: A single application container that you want to run within a WorkerPool. properties: + args: + description: |- + Arguments to the entrypoint. Not executed within a shell. The container + image's CMD is used if this is not provided (unless command is set, + which discards the image's CMD). + + Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded. + items: + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: atomic command: - description: Entrypoint array. Not executed within a shell. + description: |- + Entrypoint array. Not executed within a shell. The container image's + ENTRYPOINT is used if this is not provided; if it is provided, the + image's ENTRYPOINT and CMD are both ignored and the process argv is + command + args. + + Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded. items: type: string maxItems: 64 diff --git a/pkg/api/v1alpha1/actortemplate_types.go b/pkg/api/v1alpha1/actortemplate_types.go index 192a5932f..fbf94998e 100644 --- a/pkg/api/v1alpha1/actortemplate_types.go +++ b/pkg/api/v1alpha1/actortemplate_types.go @@ -92,13 +92,29 @@ type Container struct { // +kubebuilder:validation:XValidation:rule="self.contains('@')",message="All images must be pinned (changing the image invalidates snapshots)" Image string `json:"image,omitempty"` - // Entrypoint array. Not executed within a shell. + // Entrypoint array. Not executed within a shell. The container image's + // ENTRYPOINT is used if this is not provided; if it is provided, the + // image's ENTRYPOINT and CMD are both ignored and the process argv is + // command + args. + // + // Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded. // // +optional // +kubebuilder:validation:MaxItems=64 // +listType=atomic Command []string `json:"command,omitempty"` + // Arguments to the entrypoint. Not executed within a shell. The container + // image's CMD is used if this is not provided (unless command is set, + // which discards the image's CMD). + // + // Unlike Kubernetes, variable references $(VAR_NAME) are NOT expanded. + // + // +optional + // +kubebuilder:validation:MaxItems=64 + // +listType=atomic + Args []string `json:"args,omitempty"` + // Environment variables to set in the worker replicas. // // +optional diff --git a/pkg/api/v1alpha1/actortemplate_validation_test.go b/pkg/api/v1alpha1/actortemplate_validation_test.go index 86557ef9d..df9062a2c 100644 --- a/pkg/api/v1alpha1/actortemplate_validation_test.go +++ b/pkg/api/v1alpha1/actortemplate_validation_test.go @@ -199,6 +199,29 @@ func TestActorTemplateValidation(t *testing.T) { }, wantErr: true, errMsg: "Too many", + }, { + name: "valid container Args", + mutate: func(at *ActorTemplate) { + at.Spec.Containers[0].Args = []string{"arg"} + }, + wantErr: false, + }, { + name: "long container Args", + mutate: func(at *ActorTemplate) { + for range 64 { + at.Spec.Containers[0].Args = append(at.Spec.Containers[0].Args, "x") + } + }, + wantErr: false, + }, { + name: "too-many container Args", + mutate: func(at *ActorTemplate) { + for range 65 { + at.Spec.Containers[0].Args = append(at.Spec.Containers[0].Args, "x") + } + }, + wantErr: true, + errMsg: "Too many", }, { name: "valid EnvVar", mutate: func(at *ActorTemplate) { diff --git a/pkg/api/v1alpha1/zz_generated.deepcopy.go b/pkg/api/v1alpha1/zz_generated.deepcopy.go index e977908e9..10062d142 100644 --- a/pkg/api/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/api/v1alpha1/zz_generated.deepcopy.go @@ -164,6 +164,11 @@ func (in *Container) DeepCopyInto(out *Container) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Args != nil { + in, out := &in.Args, &out.Args + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Env != nil { in, out := &in.Env, &out.Env *out = make([]EnvVar, len(*in))