Skip to content
This repository was archived by the owner on Jul 18, 2025. It is now read-only.

Commit e18898a

Browse files
authored
Merge pull request #760 from rumpl/compose-validation
Compose validation
2 parents 6c39340 + 582f377 commit e18898a

File tree

14 files changed

+500
-95
lines changed

14 files changed

+500
-95
lines changed

e2e/commands_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,18 @@ maintainers:
226226
golden.Assert(t, stdOut, "validate-output.golden")
227227
}
228228

229+
func TestInitWithInvalidCompose(t *testing.T) {
230+
cmd, cleanup := dockerCli.createTestCmd()
231+
defer cleanup()
232+
composePath := filepath.Join("testdata", "invalid-compose", "docker-compose.yml")
233+
234+
cmd.Command = dockerCli.Command("app", "init", "invalid", "--compose-file", composePath)
235+
stdOut := icmd.RunCmd(cmd).Assert(t, icmd.Expected{
236+
ExitCode: 1,
237+
}).Combined()
238+
golden.Assert(t, stdOut, "init-invalid-output.golden")
239+
}
240+
229241
func TestInspectApp(t *testing.T) {
230242
runWithDindSwarmAndRegistry(t, func(info dindSwarmAndRegistryInfo) {
231243
cmd := info.configuredCmd
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Compose file validation failed:
2+
* can't use relative path as volume source ("./src:/src") in service "api"
3+
* can't use relative path as volume source ("./static:/opt/${static_subdir}") in service "web"
4+
* secret "my_secret" must be external
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
version: "3.6"
2+
services:
3+
api:
4+
image: python:3.6
5+
volumes:
6+
- ./src:/src
7+
web:
8+
image: nginx
9+
networks:
10+
- front
11+
volumes:
12+
- ./static:/opt/${static_subdir}
13+
secrets:
14+
my_secret:
15+
first: ./path/to/secret.txt

internal/commands/build/compose.go

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package build
22

33
import (
4-
"fmt"
54
"path"
6-
"path/filepath"
75
"strings"
86

97
"github.com/docker/app/render"
@@ -26,13 +24,6 @@ func parseCompose(app *types.App, contextPath string, options buildOptions) (map
2624
pulledServices := []compose.ServiceConfig{}
2725
opts := map[string]build.Options{}
2826
for _, service := range comp.Services {
29-
// Sanity check
30-
for _, vol := range service.Volumes {
31-
if vol.Type == "bind" && !filepath.IsAbs(vol.Source) {
32-
return nil, nil, fmt.Errorf("invalid service %q: can't use relative path as volume source", service.Name)
33-
}
34-
}
35-
3627
if service.Build.Context == "" {
3728
pulledServices = append(pulledServices, service)
3829
continue

internal/packager/extract.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/docker/app/internal"
11+
"github.com/docker/app/internal/validator"
1112
"github.com/docker/app/loader"
1213
"github.com/docker/app/types"
1314
"github.com/pkg/errors"
@@ -64,6 +65,12 @@ func Extract(name string, ops ...func(*types.App) error) (*types.App, error) {
6465
return nil, errors.Wrapf(err, "cannot locate application %q in filesystem", name)
6566
}
6667
if s.IsDir() {
68+
v := validator.NewValidatorWithDefaults()
69+
err := v.Validate(filepath.Join(appname, internal.ComposeFileName))
70+
if err != nil {
71+
return nil, err
72+
}
73+
6774
// directory: already decompressed
6875
appOpts := append(ops,
6976
types.WithPath(appname),

internal/packager/init.go

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/docker/app/internal"
1616
"github.com/docker/app/internal/compose"
17+
"github.com/docker/app/internal/validator"
1718
"github.com/docker/app/internal/yaml"
1819
"github.com/docker/app/types"
1920
"github.com/docker/app/types/metadata"
@@ -49,6 +50,11 @@ func Init(errWriter io.Writer, name string, composeFile string) (string, error)
4950
if composeFile == "" {
5051
err = initFromScratch(name)
5152
} else {
53+
v := validator.NewValidatorWithDefaults()
54+
err = v.Validate(composeFile)
55+
if err != nil {
56+
return "", err
57+
}
5258
err = initFromComposeFile(errWriter, name, composeFile)
5359
}
5460
if err != nil {
@@ -82,11 +88,11 @@ func checkComposeFileVersion(compose map[string]interface{}) error {
8288

8389
func getEnvFiles(svcName string, envFileEntry interface{}) ([]string, error) {
8490
var envFiles []string
85-
switch envFileEntry.(type) {
91+
switch envFileEntry := envFileEntry.(type) {
8692
case string:
87-
envFiles = append(envFiles, envFileEntry.(string))
93+
envFiles = append(envFiles, envFileEntry)
8894
case []interface{}:
89-
for _, env := range envFileEntry.([]interface{}) {
95+
for _, env := range envFileEntry {
9096
envFiles = append(envFiles, env.(string))
9197
}
9298
default:
@@ -125,50 +131,6 @@ func checkEnvFiles(errWriter io.Writer, appName string, cfgMap map[string]interf
125131
return nil
126132
}
127133

128-
func checkRelativePaths(cfgMap map[string]interface{}) error {
129-
services := cfgMap["services"]
130-
servicesMap, ok := services.(map[string]interface{})
131-
if !ok {
132-
return fmt.Errorf("invalid Compose file")
133-
}
134-
for svcName, svc := range servicesMap {
135-
svcContent, ok := svc.(map[string]interface{})
136-
if !ok {
137-
return fmt.Errorf("invalid service %q", svcName)
138-
}
139-
v, ok := svcContent["volumes"]
140-
if !ok {
141-
continue
142-
}
143-
volumes, ok := v.([]interface{})
144-
if !ok {
145-
return fmt.Errorf("invalid Compose file")
146-
}
147-
for _, volume := range volumes {
148-
switch volume.(type) {
149-
case string:
150-
svol := volume.(string)
151-
source := strings.TrimRight(svol, ":")
152-
if !filepath.IsAbs(source) {
153-
return fmt.Errorf("invalid service %q: can't use relative path as volume source", svcName)
154-
}
155-
case map[string]interface{}:
156-
lvol := volume.(map[string]interface{})
157-
src, ok := lvol["source"]
158-
if !ok {
159-
return fmt.Errorf("invalid volume in service %q", svcName)
160-
}
161-
if !filepath.IsAbs(src.(string)) {
162-
return fmt.Errorf("invalid service %q: can't use relative path as volume source", svcName)
163-
}
164-
default:
165-
return fmt.Errorf("invalid Compose file")
166-
}
167-
}
168-
}
169-
return nil
170-
}
171-
172134
func getParamsFromDefaultEnvFile(composeFile string, composeRaw []byte) (map[string]string, bool, error) {
173135
params := make(map[string]string)
174136
envs, err := opts.ParseEnvFile(filepath.Join(filepath.Dir(composeFile), ".env"))
@@ -217,9 +179,6 @@ func initFromComposeFile(errWriter io.Writer, name string, composeFile string) e
217179
if err := checkEnvFiles(errWriter, name, cfgMap); err != nil {
218180
return err
219181
}
220-
if err := checkRelativePaths(cfgMap); err != nil {
221-
return err
222-
}
223182
params, needsFilling, err := getParamsFromDefaultEnvFile(composeFile, composeRaw)
224183
if err != nil {
225184
return err

internal/packager/init_test.go

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -186,39 +186,3 @@ maintainers:
186186
)
187187
assert.Assert(t, fs.Equal(tmpdir.Path(), manifest))
188188
}
189-
190-
func TestInitRelativeVolumePath(t *testing.T) {
191-
for _, composeData := range []string{`
192-
version: '3.7'
193-
services:
194-
nginx:
195-
image: nginx
196-
volumes:
197-
- ./foo:/data
198-
`,
199-
`
200-
version: '3.7'
201-
services:
202-
nginx:
203-
image: nginx
204-
volumes:
205-
- type: bind
206-
source: ./foo
207-
target: /data
208-
`,
209-
} {
210-
inputDir := fs.NewDir(t, "app_input_",
211-
fs.WithFile(internal.ComposeFileName, composeData),
212-
)
213-
defer inputDir.Remove()
214-
215-
appName := "my.dockerapp"
216-
dir := fs.NewDir(t, "app_",
217-
fs.WithDir(appName),
218-
)
219-
defer dir.Remove()
220-
221-
err := initFromComposeFile(nil, dir.Join(appName), inputDir.Join(internal.ComposeFileName))
222-
assert.ErrorContains(t, err, "can't use relative path")
223-
}
224-
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package rules
2+
3+
import (
4+
"github.com/pkg/errors"
5+
)
6+
7+
type externalSecretsValidator struct {
8+
}
9+
10+
func NewExternalSecretsRule() Rule {
11+
return &externalSecretsValidator{}
12+
}
13+
14+
func (s *externalSecretsValidator) Collect(parent string, key string, value interface{}) {
15+
}
16+
17+
func (s *externalSecretsValidator) Accept(parent string, key string) bool {
18+
return key == "secrets"
19+
}
20+
21+
func (s *externalSecretsValidator) Validate(cfgMap interface{}) []error {
22+
errs := []error{}
23+
if value, ok := cfgMap.(map[string]interface{}); ok {
24+
for secretName, secret := range value {
25+
if secretMap, ok := secret.(map[string]interface{}); ok {
26+
var hasExternal = false
27+
for key := range secretMap {
28+
if key == "external" {
29+
hasExternal = true
30+
}
31+
}
32+
if !hasExternal {
33+
errs = append(errs, errors.Errorf(`secret %q must be external`, secretName))
34+
}
35+
}
36+
}
37+
}
38+
return errs
39+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package rules
2+
3+
import (
4+
"testing"
5+
6+
"gotest.tools/assert"
7+
)
8+
9+
func TestExternalSecrets(t *testing.T) {
10+
s := NewExternalSecretsRule()
11+
12+
t.Run("should accept secrets", func(t *testing.T) {
13+
// The secrets key is on the root path, that's why it doesn't
14+
// have a parent
15+
assert.Equal(t, s.Accept("", "secrets"), true)
16+
})
17+
18+
t.Run("should return nil if all secrets are external", func(t *testing.T) {
19+
input := map[string]interface{}{
20+
"my_secret": map[string]interface{}{
21+
"external": "true",
22+
},
23+
}
24+
25+
errs := s.Validate(input)
26+
assert.Equal(t, len(errs), 0)
27+
})
28+
29+
t.Run("should return error if no external secrets", func(t *testing.T) {
30+
input := map[string]interface{}{
31+
"my_secret": map[string]interface{}{
32+
"file": "./my_secret.txt",
33+
},
34+
}
35+
36+
errs := s.Validate(input)
37+
assert.Equal(t, len(errs), 1)
38+
assert.ErrorContains(t, errs[0], `secret "my_secret" must be external`)
39+
})
40+
41+
t.Run("should return all errors", func(t *testing.T) {
42+
input := map[string]interface{}{
43+
"my_secret": map[string]interface{}{
44+
"file": "./my_secret.txt",
45+
},
46+
"my_other_secret": map[string]interface{}{
47+
"file": "./my_secret.txt",
48+
},
49+
}
50+
51+
errs := s.Validate(input)
52+
assert.Equal(t, len(errs), 2)
53+
})
54+
55+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package rules
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"regexp"
7+
"strings"
8+
)
9+
10+
type relativePathRule struct {
11+
volumes map[string]interface{}
12+
service string
13+
}
14+
15+
func NewRelativePathRule() Rule {
16+
return &relativePathRule{
17+
volumes: map[string]interface{}{},
18+
}
19+
}
20+
21+
func (s *relativePathRule) Collect(parent string, key string, value interface{}) {
22+
if parent == "volumes" {
23+
s.volumes[key] = value
24+
}
25+
}
26+
27+
func (s *relativePathRule) Accept(parent string, key string) bool {
28+
if parent == "services" {
29+
s.service = key
30+
}
31+
return regexp.MustCompile("services.(.*).volumes").MatchString(parent + "." + key)
32+
}
33+
34+
func (s *relativePathRule) Validate(value interface{}) []error {
35+
if m, ok := value.(map[string]interface{}); ok {
36+
src, ok := m["source"]
37+
if !ok {
38+
return []error{fmt.Errorf("invalid volume in service %q", s.service)}
39+
}
40+
_, volumeExists := s.volumes[src.(string)]
41+
if !filepath.IsAbs(src.(string)) && !volumeExists {
42+
return []error{fmt.Errorf("can't use relative path as volume source (%q) in service %q", src, s.service)}
43+
}
44+
}
45+
46+
if m, ok := value.([]interface{}); ok {
47+
errs := []error{}
48+
for _, p := range m {
49+
str, ok := p.(string)
50+
if !ok {
51+
errs = append(errs, fmt.Errorf("invalid volume in service %q", s.service))
52+
continue
53+
}
54+
55+
parts := strings.Split(str, ":")
56+
if len(parts) <= 1 {
57+
errs = append(errs, fmt.Errorf("invalid volume definition (%q) in service %q", str, s.service))
58+
continue
59+
}
60+
61+
volumeName := parts[0]
62+
_, volumeExists := s.volumes[volumeName]
63+
if !filepath.IsAbs(volumeName) && !volumeExists {
64+
errs = append(errs, fmt.Errorf("can't use relative path as volume source (%q) in service %q", str, s.service))
65+
continue
66+
}
67+
}
68+
69+
if len(errs) > 0 {
70+
return errs
71+
}
72+
}
73+
return nil
74+
}

0 commit comments

Comments
 (0)