Skip to content

Commit a88b73f

Browse files
authored
feat(all): add exec field for supporting external containers (#1522)
* **New Features** * Execute commands inside existing running containers ("exec" mode) with optional user, working directory, and env overrides. * Container field accepts either a simple string (container name) or an object configuration. * **Bug Fixes / Reliability** * Runtime now waits for target containers to be running and reports clearer container states and errors. * Skips container creation when using exec mode. * **Documentation** * Schema updated to enforce mutual exclusivity between exec and image and clarify field applicability.
1 parent 19aa69e commit a88b73f

14 files changed

Lines changed: 869 additions & 55 deletions

File tree

internal/core/container.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77

88
// Container defines the container configuration for the DAG.
99
type Container struct {
10+
// Exec specifies an existing container name to exec into.
11+
// Mutually exclusive with Image.
12+
Exec string `yaml:"exec,omitempty"`
1013
// Name is the container name to use. If empty, Docker generates a random name.
1114
Name string `yaml:"name,omitempty"`
1215
// Image is the container image to use.
@@ -70,6 +73,11 @@ func (ct Container) GetWorkingDir() string {
7073
return ct.WorkingDir
7174
}
7275

76+
// IsExecMode returns true if this container is configured to exec into an existing container
77+
func (ct Container) IsExecMode() bool {
78+
return ct.Exec != ""
79+
}
80+
7381
// PullPolicy defines image pull policy for a container execution
7482
type PullPolicy int
7583

internal/core/spec/builder_test.go

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2219,6 +2219,96 @@ steps:
22192219
})
22202220
}
22212221

2222+
// Exec mode tests (container as string or object with exec field)
2223+
t.Run("ContainerStringForm", func(t *testing.T) {
2224+
t.Parallel()
2225+
yaml := `
2226+
container: my-running-container
2227+
steps:
2228+
- name: step1
2229+
command: echo test
2230+
`
2231+
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
2232+
require.NoError(t, err)
2233+
require.NotNil(t, dag.Container)
2234+
assert.Equal(t, "my-running-container", dag.Container.Exec)
2235+
assert.Empty(t, dag.Container.Image)
2236+
assert.True(t, dag.Container.IsExecMode())
2237+
})
2238+
2239+
t.Run("ContainerStringFormTrimmed", func(t *testing.T) {
2240+
t.Parallel()
2241+
yaml := `
2242+
container: " my-container "
2243+
steps:
2244+
- name: step1
2245+
command: echo test
2246+
`
2247+
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
2248+
require.NoError(t, err)
2249+
require.NotNil(t, dag.Container)
2250+
assert.Equal(t, "my-container", dag.Container.Exec)
2251+
})
2252+
2253+
t.Run("ContainerObjectExecForm", func(t *testing.T) {
2254+
t.Parallel()
2255+
yaml := `
2256+
container:
2257+
exec: my-container
2258+
user: root
2259+
workingDir: /app
2260+
env:
2261+
- MY_VAR: value
2262+
steps:
2263+
- name: step1
2264+
command: echo test
2265+
`
2266+
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
2267+
require.NoError(t, err)
2268+
require.NotNil(t, dag.Container)
2269+
assert.Equal(t, "my-container", dag.Container.Exec)
2270+
assert.Empty(t, dag.Container.Image)
2271+
assert.Equal(t, "root", dag.Container.User)
2272+
assert.Equal(t, "/app", dag.Container.WorkingDir)
2273+
assert.Contains(t, dag.Container.Env, "MY_VAR=value")
2274+
assert.True(t, dag.Container.IsExecMode())
2275+
})
2276+
2277+
t.Run("StepContainerStringForm", func(t *testing.T) {
2278+
t.Parallel()
2279+
yaml := `
2280+
steps:
2281+
- name: step1
2282+
container: my-step-container
2283+
command: echo test
2284+
`
2285+
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
2286+
require.NoError(t, err)
2287+
require.NotNil(t, dag.Steps[0].Container)
2288+
assert.Equal(t, "my-step-container", dag.Steps[0].Container.Exec)
2289+
assert.True(t, dag.Steps[0].Container.IsExecMode())
2290+
})
2291+
2292+
t.Run("StepContainerObjectExecForm", func(t *testing.T) {
2293+
t.Parallel()
2294+
yaml := `
2295+
steps:
2296+
- name: step1
2297+
container:
2298+
exec: my-step-container
2299+
user: nobody
2300+
workingDir: /tmp
2301+
command: echo test
2302+
`
2303+
dag, err := spec.LoadYAML(context.Background(), []byte(yaml))
2304+
require.NoError(t, err)
2305+
require.NotNil(t, dag.Steps[0].Container)
2306+
assert.Equal(t, "my-step-container", dag.Steps[0].Container.Exec)
2307+
assert.Equal(t, "nobody", dag.Steps[0].Container.User)
2308+
assert.Equal(t, "/tmp", dag.Steps[0].Container.WorkingDir)
2309+
assert.True(t, dag.Steps[0].Container.IsExecMode())
2310+
})
2311+
22222312
// Error tests
22232313
errorTests := []struct {
22242314
name string
@@ -2248,7 +2338,91 @@ steps:
22482338
- name: step1
22492339
command: echo test
22502340
`,
2251-
errContains: "image is required when container is specified",
2341+
errContains: "either 'exec' or 'image' must be specified",
2342+
},
2343+
{
2344+
name: "ContainerExecAndImageMutualExclusive",
2345+
yaml: `
2346+
container:
2347+
exec: my-container
2348+
image: alpine:latest
2349+
steps:
2350+
- name: step1
2351+
command: echo test
2352+
`,
2353+
errContains: "'exec' and 'image' are mutually exclusive",
2354+
},
2355+
{
2356+
name: "ContainerExecWithInvalidVolumes",
2357+
yaml: `
2358+
container:
2359+
exec: my-container
2360+
volumes:
2361+
- /data:/data
2362+
steps:
2363+
- name: step1
2364+
command: echo test
2365+
`,
2366+
errContains: "cannot be used with 'exec'",
2367+
},
2368+
{
2369+
name: "ContainerExecWithInvalidPorts",
2370+
yaml: `
2371+
container:
2372+
exec: my-container
2373+
ports:
2374+
- "8080:80"
2375+
steps:
2376+
- name: step1
2377+
command: echo test
2378+
`,
2379+
errContains: "cannot be used with 'exec'",
2380+
},
2381+
{
2382+
name: "ContainerExecWithInvalidNetwork",
2383+
yaml: `
2384+
container:
2385+
exec: my-container
2386+
network: bridge
2387+
steps:
2388+
- name: step1
2389+
command: echo test
2390+
`,
2391+
errContains: "cannot be used with 'exec'",
2392+
},
2393+
{
2394+
name: "ContainerExecWithInvalidPullPolicy",
2395+
yaml: `
2396+
container:
2397+
exec: my-container
2398+
pullPolicy: always
2399+
steps:
2400+
- name: step1
2401+
command: echo test
2402+
`,
2403+
errContains: "cannot be used with 'exec'",
2404+
},
2405+
{
2406+
name: "ContainerStringFormEmpty",
2407+
yaml: `
2408+
container: " "
2409+
steps:
2410+
- name: step1
2411+
command: echo test
2412+
`,
2413+
errContains: "container name cannot be empty",
2414+
},
2415+
{
2416+
name: "StepContainerExecAndImageMutualExclusive",
2417+
yaml: `
2418+
steps:
2419+
- name: step1
2420+
container:
2421+
exec: my-container
2422+
image: alpine:latest
2423+
command: echo test
2424+
`,
2425+
errContains: "'exec' and 'image' are mutually exclusive",
22522426
},
22532427
}
22542428

internal/core/spec/dag.go

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ type dag struct {
9797
// WorkerSelector specifies required worker labels for execution.
9898
WorkerSelector map[string]string
9999
// Container is the container definition for the DAG.
100-
Container *container
100+
// Can be a string (existing container name to exec into) or an object (container configuration).
101+
Container any
101102
// RunConfig contains configuration for controlling user interactions during DAG runs.
102103
RunConfig *runConfig
103104
// RegistryAuths maps registry hostnames to authentication configs.
@@ -153,6 +154,9 @@ type mailOn struct {
153154

154155
// container defines the container configuration for the DAG.
155156
type container struct {
157+
// Exec specifies an existing container to exec into.
158+
// Mutually exclusive with Image.
159+
Exec string `yaml:"exec,omitempty"`
156160
// Name is the container name to use. If empty, Docker generates a random name.
157161
Name string `yaml:"name,omitempty"`
158162
// Image is the container image to use.
@@ -767,20 +771,147 @@ func buildWorkingDir(ctx BuildContext, d *dag) (string, error) {
767771
}
768772

769773
func buildContainer(ctx BuildContext, d *dag) (*core.Container, error) {
770-
if d.Container == nil {
774+
return buildContainerField(ctx, d.Container)
775+
}
776+
777+
// buildContainerField handles both string and object forms of container field.
778+
// String form: "container-name" -> exec into existing container
779+
// Object form: {image: "...", ...} or {exec: "...", ...} -> create new or exec into existing
780+
func buildContainerField(ctx BuildContext, raw any) (*core.Container, error) {
781+
if raw == nil {
771782
return nil, nil
772783
}
773-
return buildContainerFromSpec(ctx, d.Container)
784+
785+
switch v := raw.(type) {
786+
case string:
787+
// String mode: exec into existing container with defaults
788+
name := strings.TrimSpace(v)
789+
if name == "" {
790+
return nil, core.NewValidationError("container", nil,
791+
fmt.Errorf("container name cannot be empty"))
792+
}
793+
return &core.Container{
794+
Exec: name,
795+
}, nil
796+
797+
case map[string]any:
798+
// Object mode: decode and validate
799+
var c container
800+
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
801+
Result: &c,
802+
WeaklyTypedInput: true,
803+
})
804+
if err != nil {
805+
return nil, core.NewValidationError("container", nil,
806+
fmt.Errorf("failed to create decoder: %w", err))
807+
}
808+
if err := decoder.Decode(v); err != nil {
809+
return nil, core.NewValidationError("container", nil,
810+
fmt.Errorf("failed to decode container: %w", err))
811+
}
812+
return buildContainerFromSpec(ctx, &c)
813+
814+
case *container:
815+
// Already decoded container struct (for backward compatibility)
816+
if v == nil {
817+
return nil, nil
818+
}
819+
return buildContainerFromSpec(ctx, v)
820+
821+
default:
822+
return nil, core.NewValidationError("container", nil,
823+
fmt.Errorf("container must be a string or object, got %T", raw))
824+
}
774825
}
775826

776827
// buildContainerFromSpec is a shared function that builds a core.Container from a container spec.
777828
// It is used by both DAG-level and step-level container configuration.
778829
func buildContainerFromSpec(ctx BuildContext, c *container) (*core.Container, error) {
779-
// If container is specified but image is empty, return an error
780-
if c.Image == "" {
781-
return nil, core.NewValidationError("container.image", c.Image, fmt.Errorf("image is required when container is specified"))
830+
// Validate mutual exclusivity
831+
if c.Exec != "" && c.Image != "" {
832+
return nil, core.NewValidationError("container", nil,
833+
fmt.Errorf("'exec' and 'image' are mutually exclusive"))
834+
}
835+
836+
// Require one of exec or image
837+
if c.Exec == "" && c.Image == "" {
838+
return nil, core.NewValidationError("container", nil,
839+
fmt.Errorf("either 'exec' or 'image' must be specified"))
840+
}
841+
842+
// Handle exec mode
843+
if c.Exec != "" {
844+
// Validate no incompatible fields in exec mode
845+
var invalidFields []string
846+
if c.Name != "" {
847+
invalidFields = append(invalidFields, "name")
848+
}
849+
if c.PullPolicy != nil {
850+
invalidFields = append(invalidFields, "pullPolicy")
851+
}
852+
if len(c.Volumes) > 0 {
853+
invalidFields = append(invalidFields, "volumes")
854+
}
855+
if len(c.Ports) > 0 {
856+
invalidFields = append(invalidFields, "ports")
857+
}
858+
if c.Network != "" {
859+
invalidFields = append(invalidFields, "network")
860+
}
861+
if c.Platform != "" {
862+
invalidFields = append(invalidFields, "platform")
863+
}
864+
if c.Startup != "" {
865+
invalidFields = append(invalidFields, "startup")
866+
}
867+
if len(c.Command) > 0 {
868+
invalidFields = append(invalidFields, "command")
869+
}
870+
if c.WaitFor != "" {
871+
invalidFields = append(invalidFields, "waitFor")
872+
}
873+
if c.LogPattern != "" {
874+
invalidFields = append(invalidFields, "logPattern")
875+
}
876+
if c.RestartPolicy != "" {
877+
invalidFields = append(invalidFields, "restartPolicy")
878+
}
879+
if c.KeepContainer {
880+
invalidFields = append(invalidFields, "keepContainer")
881+
}
882+
883+
if len(invalidFields) > 0 {
884+
return nil, core.NewValidationError("container", nil,
885+
fmt.Errorf("fields %v cannot be used with 'exec'", invalidFields))
886+
}
887+
888+
// Parse env for exec mode
889+
vars, err := loadVariables(ctx, c.Env)
890+
if err != nil {
891+
return nil, core.NewValidationError("container.env", c.Env, err)
892+
}
893+
894+
var envs []string
895+
for k, v := range vars {
896+
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
897+
}
898+
899+
// Determine working dir
900+
workingDir := c.WorkingDir
901+
if c.WorkDir != "" {
902+
workingDir = c.WorkDir
903+
}
904+
905+
// Build exec-mode container
906+
return &core.Container{
907+
Exec: strings.TrimSpace(c.Exec),
908+
User: c.User,
909+
WorkingDir: workingDir,
910+
Env: envs,
911+
}, nil
782912
}
783913

914+
// Handle image mode (existing behavior)
784915
pullPolicy, err := core.ParsePullPolicy(c.PullPolicy)
785916
if err != nil {
786917
return nil, core.NewValidationError("container.pullPolicy", c.PullPolicy, err)

0 commit comments

Comments
 (0)