Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ require (
github.com/google/go-cmp v0.5.9
github.com/mattn/go-shellwords v1.0.12
github.com/opencontainers/go-digest v1.0.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.4
github.com/xeipuuv/gojsonschema v1.2.0
github.com/xhit/go-str2duration/v2 v2.1.0
golang.org/x/sync v0.3.0
gopkg.in/yaml.v3 v3.0.1
Expand All @@ -22,7 +22,6 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
19 changes: 10 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
Expand All @@ -18,19 +20,16 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand All @@ -50,10 +49,12 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
Expand Down
14 changes: 7 additions & 7 deletions loader/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ services:
environment:
FOO: ["1"]
`)
assert.ErrorContains(t, err, "services.dict-env.environment.FOO must be a string, number, boolean or null")
assert.ErrorContains(t, err, "services.dict-env.environment.FOO must be a boolean, null, number or string")
}

func TestInvalidEnvironmentObject(t *testing.T) {
Expand Down Expand Up @@ -1054,7 +1054,7 @@ func TestInvalidResource(t *testing.T) {
impossible:
x: 1
`)
assert.ErrorContains(t, err, "Additional property impossible is not allowed")
assert.ErrorContains(t, err, "additional properties 'impossible' not allowed")
}

func TestInvalidExternalAndDriverCombination(t *testing.T) {
Expand Down Expand Up @@ -1196,7 +1196,7 @@ services:
foo:
bar: zot
`)
assert.ErrorContains(t, err, "services.tmpfs.volumes.0 Additional property foo is not allowed")
assert.ErrorContains(t, err, "services.tmpfs.volumes.0 additional properties 'foo' not allowed")
}

func TestLoadBindMountSourceMustNotBeEmpty(t *testing.T) {
Expand Down Expand Up @@ -1350,7 +1350,7 @@ services:
tmpfs:
size: -1
`)
assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size Must be greater than or equal to 0")
assert.ErrorContains(t, err, "services.tmpfs.volumes.0.tmpfs.size must be greater than or equal to 0")
}

func TestLoadTmpfsVolumeSizeMustBeInteger(t *testing.T) {
Expand Down Expand Up @@ -2264,7 +2264,7 @@ services:
- driver: nvidia
count: 2
`)
assert.ErrorContains(t, err, `capabilities is required`)
assert.ErrorContains(t, err, "missing property 'capabilities'")
}

func TestServiceGpus(t *testing.T) {
Expand Down Expand Up @@ -3132,7 +3132,7 @@ services:
`, nil), func(options *Options) {
options.ResolvePaths = false
})
assert.ErrorContains(t, err, "validating filename0.yml: services.frontend.develop.watch.0 action is required")
assert.ErrorContains(t, err, "services.frontend.develop.watch.0 missing property 'action'")
}

func TestBadServiceConfig(t *testing.T) {
Expand Down Expand Up @@ -3803,7 +3803,7 @@ services:
numbers: 12
booleans: true
`)
assert.Check(t, strings.Contains(err.Error(), "services.test.provider type is required"))
assert.ErrorContains(t, err, "services.test.provider missing property 'type'")
}

func TestImageVolume(t *testing.T) {
Expand Down
172 changes: 71 additions & 101 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,25 @@ package schema
import (
// Enable support for embedded static resources
_ "embed"
"errors"
"fmt"
"slices"
"strings"
"time"

"github.com/xeipuuv/gojsonschema"
"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/santhosh-tekuri/jsonschema/v6/kind"
"golang.org/x/text/language"
"golang.org/x/text/message"
)

type portsFormatChecker struct{}

func (checker portsFormatChecker) IsFormat(_ interface{}) bool {
// TODO: implement this
return true
}

type durationFormatChecker struct{}

func (checker durationFormatChecker) IsFormat(input interface{}) bool {
func durationFormatChecker(input any) error {
value, ok := input.(string)
if !ok {
return false
return fmt.Errorf("expected string")
}
_, err := time.ParseDuration(value)
return err == nil
}

func init() {
gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{})
gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{})
gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
return err
}

// Schema is the compose-spec JSON schema
Expand All @@ -57,108 +47,88 @@ var Schema string

// Validate uses the jsonschema to validate the configuration
func Validate(config map[string]interface{}) error {
schemaLoader := gojsonschema.NewStringLoader(Schema)
dataLoader := gojsonschema.NewGoLoader(config)

result, err := gojsonschema.Validate(schemaLoader, dataLoader)
compiler := jsonschema.NewCompiler()
json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
if err != nil {
return err
}

if !result.Valid() {
return toError(result)
err = compiler.AddResource("compose-spec.json", json)
if err != nil {
return err
}
compiler.RegisterFormat(&jsonschema.Format{
Name: "duration",
Validate: durationFormatChecker,
})
schema := compiler.MustCompile("compose-spec.json")
err = schema.Validate(config)
var verr *jsonschema.ValidationError
if ok := errors.As(err, &verr); ok {
return validationError{getMostSpecificError(verr)}
}

return nil
}

func toError(result *gojsonschema.Result) error {
err := getMostSpecificError(result.Errors())
return err
}

const (
jsonschemaOneOf = "number_one_of"
jsonschemaAnyOf = "number_any_of"
)
type validationError struct {
err *jsonschema.ValidationError
}

func getDescription(err validationError) string {
switch err.parent.Type() {
case "invalid_type":
if expectedType, ok := err.parent.Details()["expected"].(string); ok {
return fmt.Sprintf("must be a %s", humanReadableType(expectedType))
}
case jsonschemaOneOf, jsonschemaAnyOf:
if err.child == nil {
return err.parent.Description()
}
return err.child.Description()
func (e validationError) Error() string {
path := strings.Join(e.err.InstanceLocation, ".")
p := message.NewPrinter(language.English)
switch k := e.err.ErrorKind.(type) {
case *kind.Type:
return fmt.Sprintf("%s must be a %s", path, humanReadableType(k.Want...))
case *kind.Minimum:
return fmt.Sprintf("%s must be greater than or equal to %s", path, k.Want.Num())
case *kind.Maximum:
return fmt.Sprintf("%s must be less than or equal to %s", path, k.Want.Num())
}
return err.parent.Description()
return fmt.Sprintf("%s %s", path, e.err.ErrorKind.LocalizedString(p))
}

func humanReadableType(definition string) string {
if definition[0:1] == "[" {
allTypes := strings.Split(definition[1:len(definition)-1], ",")
for i, t := range allTypes {
allTypes[i] = humanReadableType(t)
func humanReadableType(want ...string) string {
if len(want) == 1 {
switch want[0] {
case "object":
return "mapping"
default:
return want[0]
}
return fmt.Sprintf(
"%s or %s",
strings.Join(allTypes[0:len(allTypes)-1], ", "),
allTypes[len(allTypes)-1],
)
}
if definition == "object" {
return "mapping"
}
if definition == "array" {
return "list"
}
return definition
}

type validationError struct {
parent gojsonschema.ResultError
child gojsonschema.ResultError
}
for i, s := range want {
want[i] = humanReadableType(s)
}

func (err validationError) Error() string {
description := getDescription(err)
return fmt.Sprintf("%s %s", err.parent.Field(), description)
slices.Sort(want)
return fmt.Sprintf(
"%s or %s",
strings.Join(want[0:len(want)-1], ", "),
want[len(want)-1],
)
}

func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
mostSpecificError := 0
for i, err := range errors {
if specificity(err) > specificity(errors[mostSpecificError]) {
mostSpecificError = i
continue
}

if specificity(err) == specificity(errors[mostSpecificError]) {
// Invalid type errors win in a tie-breaker for most specific field name
if err.Type() == "invalid_type" && errors[mostSpecificError].Type() != "invalid_type" {
mostSpecificError = i
}
}
}

if mostSpecificError+1 == len(errors) {
return validationError{parent: errors[mostSpecificError]}
func getMostSpecificError(err *jsonschema.ValidationError) *jsonschema.ValidationError {
var mostSpecificError *jsonschema.ValidationError
if len(err.Causes) == 0 {
return err
}

switch errors[mostSpecificError].Type() {
case "number_one_of", "number_any_of":
return validationError{
parent: errors[mostSpecificError],
child: errors[mostSpecificError+1],
for _, cause := range err.Causes {
cause = getMostSpecificError(cause)
if specificity(cause) > specificity(mostSpecificError) {
mostSpecificError = cause
}
default:
return validationError{parent: errors[mostSpecificError]}
}
return mostSpecificError
}

func specificity(err gojsonschema.ResultError) int {
return len(strings.Split(err.Field(), "."))
func specificity(err *jsonschema.ValidationError) int {
if err == nil {
return -1
}
if _, ok := err.ErrorKind.(*kind.AdditionalProperties); ok {
return len(err.InstanceLocation) + 1
}
return len(err.InstanceLocation)
}
Loading