Skip to content

Commit b1a035a

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/schema-validate-concurrency
2 parents ff93851 + 9bc8469 commit b1a035a

11 files changed

Lines changed: 242 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Checkout code
3333
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
3434
- name: Install Go
35-
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
35+
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
3636
with:
3737
go-version: ${{ matrix.go-version }}
3838
check-latest: true

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
1919
-
2020
name: Setup Go
21-
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
21+
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
2222
with:
2323
go-version-file: go.mod
2424
- run: go version

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/reset.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type nodeCache struct {
4040
type ResetProcessor struct {
4141
target any
4242
paths []tree.Path
43-
visitedNodes map[*yaml.Node][]string
43+
visitedNodes map[*yaml.Node][]tree.Path
4444
resolvedNodes map[*yaml.Node]nodeCache
4545
visitCount int
4646
// maxNodeVisits is the per-document cap; when zero, defaultMaxNodeVisits is used.
@@ -49,7 +49,7 @@ type ResetProcessor struct {
4949

5050
// UnmarshalYAML implement yaml.Unmarshaler
5151
func (p *ResetProcessor) UnmarshalYAML(value *yaml.Node) error {
52-
p.visitedNodes = make(map[*yaml.Node][]string)
52+
p.visitedNodes = make(map[*yaml.Node][]tree.Path)
5353
p.resolvedNodes = make(map[*yaml.Node]nodeCache)
5454
p.visitCount = 0
5555
defer func() {
@@ -283,36 +283,41 @@ func (p *ResetProcessor) applyNullOverrides(target any, path tree.Path) error {
283283

284284
func (p *ResetProcessor) checkForCycle(node *yaml.Node, path tree.Path) error {
285285
paths := p.visitedNodes[node]
286-
pathStr := path.String()
287286

288287
for _, prevPath := range paths {
289288
// If we're visiting the exact same path, it's not a cycle
290-
if pathStr == prevPath {
289+
if path == prevPath {
291290
continue
292291
}
293292

293+
// Compare on the raw form so dots inside escaped segment names (e.g.
294+
// service names containing ".") aren't conflated with path separators.
295+
pathStr := string(path)
296+
prevStr := string(prevPath)
297+
294298
// If either path is using a merge key, it's legitimate YAML merging
295-
if strings.Contains(prevPath, "<<") || strings.Contains(pathStr, "<<") {
299+
if strings.Contains(prevStr, "<<") || strings.Contains(pathStr, "<<") {
296300
continue
297301
}
298302

299303
// Only consider it a cycle if one path is contained within the other
300304
// and they're not in different service definitions
301-
if (strings.HasPrefix(pathStr, prevPath+".") ||
302-
strings.HasPrefix(prevPath, pathStr+".")) &&
303-
!areInDifferentServices(pathStr, prevPath) {
304-
return fmt.Errorf("cycle detected: node at path %s references node at path %s", pathStr, prevPath)
305+
if (strings.HasPrefix(pathStr, prevStr+".") ||
306+
strings.HasPrefix(prevStr, pathStr+".")) &&
307+
!areInDifferentServices(path, prevPath) {
308+
return fmt.Errorf("cycle detected: node at path %s references node at path %s",
309+
path.String(), prevPath.String())
305310
}
306311
}
307312

308-
p.visitedNodes[node] = append(paths, pathStr)
313+
p.visitedNodes[node] = append(paths, path)
309314
return nil
310315
}
311316

312317
// areInDifferentServices checks if two paths are in different service definitions
313-
func areInDifferentServices(path1, path2 string) bool {
314-
parts1 := strings.Split(path1, ".")
315-
parts2 := strings.Split(path2, ".")
318+
func areInDifferentServices(path1, path2 tree.Path) bool {
319+
parts1 := path1.Parts()
320+
parts2 := path2.Parts()
316321
for i := 0; i < len(parts1) && i < len(parts2); i++ {
317322
if parts1[i] == "services" && i+1 < len(parts1) &&
318323
parts2[i] == "services" && i+1 < len(parts2) {

loader/reset_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ services:
143143
backend-worker:
144144
<<: *common
145145
image: alpine:latest
146+
`,
147+
expectError: false,
148+
},
149+
{
150+
name: "dotted_service_names_prefix_no_cycle",
151+
config: `
152+
name: test
153+
services:
154+
test.unit: &shared
155+
image: alpine
156+
test: *shared
157+
test.other: *shared
146158
`,
147159
expectError: false,
148160
},

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.

0 commit comments

Comments
 (0)