Skip to content

Commit 4d8d851

Browse files
committed
feat: add pre_start lifecycle hook for init containers
Implements the `pre_start` service field introduced in compose-spec/compose-spec#647: an ordered list of init containers run to completion before the service container starts. Each step runs in its own ephemeral container; a non-zero exit fails the bring-up of the service and its dependents. Compose-go owns parsing, validation via JSON schema, and image inheritance: when a pre_start hook omits `image`, it is normalized to the parent service's image. Volume/network inheritance and the runtime execution stay with docker/compose. Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
1 parent d48021c commit 4d8d851

7 files changed

Lines changed: 210 additions & 1 deletion

File tree

loader/normalize.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
133133
if len(dependsOn) > 0 {
134134
service["depends_on"] = dependsOn
135135
}
136+
137+
inheritPreStartImage(service)
138+
136139
services[name] = service
137140
}
138141

@@ -143,6 +146,25 @@ func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
143146
return dict, nil
144147
}
145148

149+
// inheritPreStartImage propagates the parent service's image to any pre_start
150+
// hook that does not declare its own, per compose-spec PR #647.
151+
func inheritPreStartImage(service map[string]any) {
152+
hooks, ok := service["pre_start"].([]any)
153+
if !ok {
154+
return
155+
}
156+
image, ok := service["image"].(string)
157+
if !ok || image == "" {
158+
return
159+
}
160+
for _, h := range hooks {
161+
hook := h.(map[string]any)
162+
if _, set := hook["image"]; !set {
163+
hook["image"] = image
164+
}
165+
}
166+
}
167+
146168
func normalizeNetworks(dict map[string]any) {
147169
var networks map[string]any
148170
if n, ok := dict["networks"]; ok {

loader/normalize_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,71 @@ services:
376376
assert.NilError(t, err)
377377
assert.DeepEqual(t, expect, model)
378378
}
379+
380+
func TestNormalizePreStartInheritsServiceImage(t *testing.T) {
381+
project := `
382+
name: myProject
383+
services:
384+
app:
385+
image: alpine
386+
pre_start:
387+
- command: ["migrate"]
388+
- image: busybox
389+
command: ["chown", "-R", "1000:1000", "/data"]
390+
builder:
391+
build:
392+
context: .
393+
pre_start:
394+
- command: ["init"]
395+
hybrid:
396+
image: ubuntu
397+
build:
398+
context: .
399+
pre_start:
400+
- command: ["bootstrap"]
401+
`
402+
expected := `
403+
name: myProject
404+
services:
405+
app:
406+
image: alpine
407+
networks:
408+
default: null
409+
pre_start:
410+
- command: ["migrate"]
411+
image: alpine
412+
- image: busybox
413+
command: ["chown", "-R", "1000:1000", "/data"]
414+
builder:
415+
build:
416+
context: .
417+
dockerfile: Dockerfile
418+
networks:
419+
default: null
420+
pre_start:
421+
- command: ["init"]
422+
hybrid:
423+
image: ubuntu
424+
build:
425+
context: .
426+
dockerfile: Dockerfile
427+
networks:
428+
default: null
429+
pre_start:
430+
- command: ["bootstrap"]
431+
image: ubuntu
432+
networks:
433+
default:
434+
name: myProject_default
435+
`
436+
var model map[string]any
437+
err := yaml.Unmarshal([]byte(project), &model)
438+
assert.NilError(t, err)
439+
model, err = Normalize(model, nil)
440+
assert.NilError(t, err)
441+
442+
var expect map[string]any
443+
err = yaml.Unmarshal([]byte(expected), &expect)
444+
assert.NilError(t, err)
445+
assert.DeepEqual(t, expect, model)
446+
}

loader/tests/service_hooks_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
package tests
1818

1919
import (
20+
"context"
2021
"testing"
2122

23+
"github.com/compose-spec/compose-go/v2/loader"
2224
"github.com/compose-spec/compose-go/v2/types"
2325
"gotest.tools/v3/assert"
2426
)
@@ -29,6 +31,17 @@ name: test
2931
services:
3032
test:
3133
image: alpine
34+
pre_start:
35+
- command: ["./manage.py", "migrate"]
36+
user: root
37+
working_dir: /app
38+
environment:
39+
- FOO=BAR
40+
- image: busybox
41+
command: sh -c 'chown -R 1000:1000 /data'
42+
privileged: true
43+
per_replica: true
44+
- image: migrator:latest
3245
post_start:
3346
- command: echo start
3447
user: root
@@ -43,6 +56,25 @@ services:
4356
environment:
4457
FOO: BAR
4558
`)
59+
assert.DeepEqual(t, p.Services["test"].PreStart, []types.ServiceHook{
60+
{
61+
Command: types.ShellCommand{"./manage.py", "migrate"},
62+
User: "root",
63+
WorkingDir: "/app",
64+
Environment: types.MappingWithEquals{
65+
"FOO": ptr("BAR"),
66+
},
67+
},
68+
{
69+
Image: "busybox",
70+
Command: types.ShellCommand{"sh", "-c", "chown -R 1000:1000 /data"},
71+
Privileged: true,
72+
PerReplica: true,
73+
},
74+
{
75+
Image: "migrator:latest",
76+
},
77+
})
4678
assert.DeepEqual(t, p.Services["test"].PostStart, []types.ServiceHook{
4779
{
4880
Command: types.ShellCommand{"echo", "start"},
@@ -65,3 +97,23 @@ services:
6597
},
6698
})
6799
}
100+
101+
func TestPreStartInheritsServiceImage(t *testing.T) {
102+
yaml := `
103+
name: test
104+
services:
105+
test:
106+
image: alpine
107+
pre_start:
108+
- command: ["migrate"]
109+
- image: busybox
110+
command: ["echo", "hi"]
111+
`
112+
p, err := loader.LoadWithContext(context.TODO(), types.ConfigDetails{
113+
ConfigFiles: []types.ConfigFile{{Filename: "compose.yml", Content: []byte(yaml)}},
114+
Environment: map[string]string{},
115+
})
116+
assert.NilError(t, err)
117+
assert.Equal(t, p.Services["test"].PreStart[0].Image, "alpine")
118+
assert.Equal(t, p.Services["test"].PreStart[1].Image, "busybox")
119+
}

schema/compose-spec.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,11 @@
688688
},
689689
"uniqueItems": true
690690
},
691+
"pre_start": {
692+
"type": "array",
693+
"items": {"$ref": "#/$defs/pre_start_hook"},
694+
"description": "Init containers to run to completion before the service container is started. Each step runs in its own ephemeral container, in declared order; a non-zero exit fails the bring-up of the service and its dependents."
695+
},
691696
"post_start": {
692697
"type": "array",
693698
"items": {"$ref": "#/$defs/service_hook"},
@@ -1657,6 +1662,43 @@
16571662
"required": ["command"]
16581663
},
16591664

1665+
"pre_start_hook": {
1666+
"type": "object",
1667+
"description": "Configuration for a pre_start init container, run to completion before the service container starts.",
1668+
"properties": {
1669+
"command": {
1670+
"$ref": "#/$defs/command",
1671+
"description": "Command to execute. Optional when the chosen image's entrypoint already runs the intended command."
1672+
},
1673+
"image": {
1674+
"type": "string",
1675+
"description": "Image used for the ephemeral container. If omitted, the parent service's image is used."
1676+
},
1677+
"user": {
1678+
"type": "string",
1679+
"description": "User to run the command as. Defaults to the user declared in image (or to the service's user when image is omitted)."
1680+
},
1681+
"privileged": {
1682+
"type": ["boolean", "string"],
1683+
"description": "Whether to run the command with extended privileges."
1684+
},
1685+
"working_dir": {
1686+
"type": "string",
1687+
"description": "Working directory for the command. Defaults to the service's working directory."
1688+
},
1689+
"environment": {
1690+
"$ref": "#/$defs/list_or_dict",
1691+
"description": "Environment variables for the command. Appended to or overriding the service environment."
1692+
},
1693+
"per_replica": {
1694+
"type": ["boolean", "string"],
1695+
"description": "Whether the hook runs once per service replica (true), or once for the service as a whole before any replica starts (false, the default)."
1696+
}
1697+
},
1698+
"additionalProperties": false,
1699+
"patternProperties": {"^x-": {}}
1700+
},
1701+
16601702
"env_file": {
16611703
"oneOf": [
16621704
{

types/derived.gen.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

types/hooks.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@
1616

1717
package types
1818

19-
// ServiceHook is a command to exec inside container by some lifecycle events
19+
// ServiceHook is a hook executed at a service lifecycle event: a command exec'd
20+
// inside the service container for post_start/pre_stop, or an ephemeral
21+
// container run before the service starts for pre_start.
2022
type ServiceHook struct {
2123
Command ShellCommand `yaml:"command,omitempty" json:"command"`
24+
Image string `yaml:"image,omitempty" json:"image,omitempty"`
2225
User string `yaml:"user,omitempty" json:"user,omitempty"`
2326
Privileged bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
2427
WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
2528
Environment MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"`
29+
PerReplica bool `yaml:"per_replica,omitempty" json:"per_replica,omitempty"`
2630

2731
Extensions Extensions `yaml:"#extensions,inline,omitempty" json:"-"`
2832
}

types/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ type ServiceConfig struct {
138138
Volumes []ServiceVolumeConfig `yaml:"volumes,omitempty" json:"volumes,omitempty"`
139139
VolumesFrom []string `yaml:"volumes_from,omitempty" json:"volumes_from,omitempty"`
140140
WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
141+
PreStart []ServiceHook `yaml:"pre_start,omitempty" json:"pre_start,omitempty"`
141142
PostStart []ServiceHook `yaml:"post_start,omitempty" json:"post_start,omitempty"`
142143
PreStop []ServiceHook `yaml:"pre_stop,omitempty" json:"pre_stop,omitempty"`
143144

0 commit comments

Comments
 (0)