Skip to content
This repository has been archived by the owner on Jun 13, 2021. It is now read-only.

Commit

Permalink
Merge pull request #760 from rumpl/compose-validation
Browse files Browse the repository at this point in the history
Compose validation
  • Loading branch information
eunomie authored Nov 28, 2019
2 parents 6c39340 + 582f377 commit e18898a
Show file tree
Hide file tree
Showing 14 changed files with 500 additions and 95 deletions.
12 changes: 12 additions & 0 deletions e2e/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ maintainers:
golden.Assert(t, stdOut, "validate-output.golden")
}

func TestInitWithInvalidCompose(t *testing.T) {
cmd, cleanup := dockerCli.createTestCmd()
defer cleanup()
composePath := filepath.Join("testdata", "invalid-compose", "docker-compose.yml")

cmd.Command = dockerCli.Command("app", "init", "invalid", "--compose-file", composePath)
stdOut := icmd.RunCmd(cmd).Assert(t, icmd.Expected{
ExitCode: 1,
}).Combined()
golden.Assert(t, stdOut, "init-invalid-output.golden")
}

func TestInspectApp(t *testing.T) {
runWithDindSwarmAndRegistry(t, func(info dindSwarmAndRegistryInfo) {
cmd := info.configuredCmd
Expand Down
4 changes: 4 additions & 0 deletions e2e/testdata/init-invalid-output.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Compose file validation failed:
* can't use relative path as volume source ("./src:/src") in service "api"
* can't use relative path as volume source ("./static:/opt/${static_subdir}") in service "web"
* secret "my_secret" must be external
15 changes: 15 additions & 0 deletions e2e/testdata/invalid-compose/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: "3.6"
services:
api:
image: python:3.6
volumes:
- ./src:/src
web:
image: nginx
networks:
- front
volumes:
- ./static:/opt/${static_subdir}
secrets:
my_secret:
first: ./path/to/secret.txt
9 changes: 0 additions & 9 deletions internal/commands/build/compose.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package build

import (
"fmt"
"path"
"path/filepath"
"strings"

"github.com/docker/app/render"
Expand All @@ -26,13 +24,6 @@ func parseCompose(app *types.App, contextPath string, options buildOptions) (map
pulledServices := []compose.ServiceConfig{}
opts := map[string]build.Options{}
for _, service := range comp.Services {
// Sanity check
for _, vol := range service.Volumes {
if vol.Type == "bind" && !filepath.IsAbs(vol.Source) {
return nil, nil, fmt.Errorf("invalid service %q: can't use relative path as volume source", service.Name)
}
}

if service.Build.Context == "" {
pulledServices = append(pulledServices, service)
continue
Expand Down
7 changes: 7 additions & 0 deletions internal/packager/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/docker/app/internal"
"github.com/docker/app/internal/validator"
"github.com/docker/app/loader"
"github.com/docker/app/types"
"github.com/pkg/errors"
Expand Down Expand Up @@ -64,6 +65,12 @@ func Extract(name string, ops ...func(*types.App) error) (*types.App, error) {
return nil, errors.Wrapf(err, "cannot locate application %q in filesystem", name)
}
if s.IsDir() {
v := validator.NewValidatorWithDefaults()
err := v.Validate(filepath.Join(appname, internal.ComposeFileName))
if err != nil {
return nil, err
}

// directory: already decompressed
appOpts := append(ops,
types.WithPath(appname),
Expand Down
59 changes: 9 additions & 50 deletions internal/packager/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/docker/app/internal"
"github.com/docker/app/internal/compose"
"github.com/docker/app/internal/validator"
"github.com/docker/app/internal/yaml"
"github.com/docker/app/types"
"github.com/docker/app/types/metadata"
Expand Down Expand Up @@ -49,6 +50,11 @@ func Init(errWriter io.Writer, name string, composeFile string) (string, error)
if composeFile == "" {
err = initFromScratch(name)
} else {
v := validator.NewValidatorWithDefaults()
err = v.Validate(composeFile)
if err != nil {
return "", err
}
err = initFromComposeFile(errWriter, name, composeFile)
}
if err != nil {
Expand Down Expand Up @@ -82,11 +88,11 @@ func checkComposeFileVersion(compose map[string]interface{}) error {

func getEnvFiles(svcName string, envFileEntry interface{}) ([]string, error) {
var envFiles []string
switch envFileEntry.(type) {
switch envFileEntry := envFileEntry.(type) {
case string:
envFiles = append(envFiles, envFileEntry.(string))
envFiles = append(envFiles, envFileEntry)
case []interface{}:
for _, env := range envFileEntry.([]interface{}) {
for _, env := range envFileEntry {
envFiles = append(envFiles, env.(string))
}
default:
Expand Down Expand Up @@ -125,50 +131,6 @@ func checkEnvFiles(errWriter io.Writer, appName string, cfgMap map[string]interf
return nil
}

func checkRelativePaths(cfgMap map[string]interface{}) error {
services := cfgMap["services"]
servicesMap, ok := services.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid Compose file")
}
for svcName, svc := range servicesMap {
svcContent, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service %q", svcName)
}
v, ok := svcContent["volumes"]
if !ok {
continue
}
volumes, ok := v.([]interface{})
if !ok {
return fmt.Errorf("invalid Compose file")
}
for _, volume := range volumes {
switch volume.(type) {
case string:
svol := volume.(string)
source := strings.TrimRight(svol, ":")
if !filepath.IsAbs(source) {
return fmt.Errorf("invalid service %q: can't use relative path as volume source", svcName)
}
case map[string]interface{}:
lvol := volume.(map[string]interface{})
src, ok := lvol["source"]
if !ok {
return fmt.Errorf("invalid volume in service %q", svcName)
}
if !filepath.IsAbs(src.(string)) {
return fmt.Errorf("invalid service %q: can't use relative path as volume source", svcName)
}
default:
return fmt.Errorf("invalid Compose file")
}
}
}
return nil
}

func getParamsFromDefaultEnvFile(composeFile string, composeRaw []byte) (map[string]string, bool, error) {
params := make(map[string]string)
envs, err := opts.ParseEnvFile(filepath.Join(filepath.Dir(composeFile), ".env"))
Expand Down Expand Up @@ -217,9 +179,6 @@ func initFromComposeFile(errWriter io.Writer, name string, composeFile string) e
if err := checkEnvFiles(errWriter, name, cfgMap); err != nil {
return err
}
if err := checkRelativePaths(cfgMap); err != nil {
return err
}
params, needsFilling, err := getParamsFromDefaultEnvFile(composeFile, composeRaw)
if err != nil {
return err
Expand Down
36 changes: 0 additions & 36 deletions internal/packager/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,39 +186,3 @@ maintainers:
)
assert.Assert(t, fs.Equal(tmpdir.Path(), manifest))
}

func TestInitRelativeVolumePath(t *testing.T) {
for _, composeData := range []string{`
version: '3.7'
services:
nginx:
image: nginx
volumes:
- ./foo:/data
`,
`
version: '3.7'
services:
nginx:
image: nginx
volumes:
- type: bind
source: ./foo
target: /data
`,
} {
inputDir := fs.NewDir(t, "app_input_",
fs.WithFile(internal.ComposeFileName, composeData),
)
defer inputDir.Remove()

appName := "my.dockerapp"
dir := fs.NewDir(t, "app_",
fs.WithDir(appName),
)
defer dir.Remove()

err := initFromComposeFile(nil, dir.Join(appName), inputDir.Join(internal.ComposeFileName))
assert.ErrorContains(t, err, "can't use relative path")
}
}
39 changes: 39 additions & 0 deletions internal/validator/rules/externalsecrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package rules

import (
"github.com/pkg/errors"
)

type externalSecretsValidator struct {
}

func NewExternalSecretsRule() Rule {
return &externalSecretsValidator{}
}

func (s *externalSecretsValidator) Collect(parent string, key string, value interface{}) {
}

func (s *externalSecretsValidator) Accept(parent string, key string) bool {
return key == "secrets"
}

func (s *externalSecretsValidator) Validate(cfgMap interface{}) []error {
errs := []error{}
if value, ok := cfgMap.(map[string]interface{}); ok {
for secretName, secret := range value {
if secretMap, ok := secret.(map[string]interface{}); ok {
var hasExternal = false
for key := range secretMap {
if key == "external" {
hasExternal = true
}
}
if !hasExternal {
errs = append(errs, errors.Errorf(`secret %q must be external`, secretName))
}
}
}
}
return errs
}
55 changes: 55 additions & 0 deletions internal/validator/rules/externalsecrets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package rules

import (
"testing"

"gotest.tools/assert"
)

func TestExternalSecrets(t *testing.T) {
s := NewExternalSecretsRule()

t.Run("should accept secrets", func(t *testing.T) {
// The secrets key is on the root path, that's why it doesn't
// have a parent
assert.Equal(t, s.Accept("", "secrets"), true)
})

t.Run("should return nil if all secrets are external", func(t *testing.T) {
input := map[string]interface{}{
"my_secret": map[string]interface{}{
"external": "true",
},
}

errs := s.Validate(input)
assert.Equal(t, len(errs), 0)
})

t.Run("should return error if no external secrets", func(t *testing.T) {
input := map[string]interface{}{
"my_secret": map[string]interface{}{
"file": "./my_secret.txt",
},
}

errs := s.Validate(input)
assert.Equal(t, len(errs), 1)
assert.ErrorContains(t, errs[0], `secret "my_secret" must be external`)
})

t.Run("should return all errors", func(t *testing.T) {
input := map[string]interface{}{
"my_secret": map[string]interface{}{
"file": "./my_secret.txt",
},
"my_other_secret": map[string]interface{}{
"file": "./my_secret.txt",
},
}

errs := s.Validate(input)
assert.Equal(t, len(errs), 2)
})

}
74 changes: 74 additions & 0 deletions internal/validator/rules/relativepath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package rules

import (
"fmt"
"path/filepath"
"regexp"
"strings"
)

type relativePathRule struct {
volumes map[string]interface{}
service string
}

func NewRelativePathRule() Rule {
return &relativePathRule{
volumes: map[string]interface{}{},
}
}

func (s *relativePathRule) Collect(parent string, key string, value interface{}) {
if parent == "volumes" {
s.volumes[key] = value
}
}

func (s *relativePathRule) Accept(parent string, key string) bool {
if parent == "services" {
s.service = key
}
return regexp.MustCompile("services.(.*).volumes").MatchString(parent + "." + key)
}

func (s *relativePathRule) Validate(value interface{}) []error {
if m, ok := value.(map[string]interface{}); ok {
src, ok := m["source"]
if !ok {
return []error{fmt.Errorf("invalid volume in service %q", s.service)}
}
_, volumeExists := s.volumes[src.(string)]
if !filepath.IsAbs(src.(string)) && !volumeExists {
return []error{fmt.Errorf("can't use relative path as volume source (%q) in service %q", src, s.service)}
}
}

if m, ok := value.([]interface{}); ok {
errs := []error{}
for _, p := range m {
str, ok := p.(string)
if !ok {
errs = append(errs, fmt.Errorf("invalid volume in service %q", s.service))
continue
}

parts := strings.Split(str, ":")
if len(parts) <= 1 {
errs = append(errs, fmt.Errorf("invalid volume definition (%q) in service %q", str, s.service))
continue
}

volumeName := parts[0]
_, volumeExists := s.volumes[volumeName]
if !filepath.IsAbs(volumeName) && !volumeExists {
errs = append(errs, fmt.Errorf("can't use relative path as volume source (%q) in service %q", str, s.service))
continue
}
}

if len(errs) > 0 {
return errs
}
}
return nil
}
Loading

0 comments on commit e18898a

Please sign in to comment.