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
46 changes: 35 additions & 11 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"slices"
"strings"
"sync"
"time"

"github.com/santhosh-tekuri/jsonschema/v6"
Expand All @@ -46,22 +47,45 @@ func durationFormatChecker(input any) error {
//go:embed compose-spec.json
var Schema string

// compiledSchema is the compose-spec schema compiled once and reused across
// every Validate call. Compiling the schema (which loads and resolves the
// draft 2020-12 meta-schema) is expensive and was previously redone on every
// call; doing it once behind a sync.Once keeps Validate cheap and makes it
// safe to call from several goroutines without sharing any mutable compiler
// state. see https://github.com/docker/compose/issues/13866
var (
compiledSchema *jsonschema.Schema
compiledSchemaErr error
compiledSchemaOnce sync.Once
)

func compileSchema() (*jsonschema.Schema, error) {
compiledSchemaOnce.Do(func() {
compiler := jsonschema.NewCompiler()
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
if err != nil {
compiledSchemaErr = err
return
}
if err := compiler.AddResource("compose-spec.json", shema); err != nil {
compiledSchemaErr = err
return
}
compiler.RegisterFormat(&jsonschema.Format{
Name: "duration",
Validate: durationFormatChecker,
})
compiledSchema, compiledSchemaErr = compiler.Compile("compose-spec.json")
})
return compiledSchema, compiledSchemaErr
}

// Validate uses the jsonschema to validate the configuration
func Validate(config map[string]interface{}) error {
compiler := jsonschema.NewCompiler()
shema, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
schema, err := compileSchema()
if err != nil {
return err
}
err = compiler.AddResource("compose-spec.json", shema)
if err != nil {
return err
}
compiler.RegisterFormat(&jsonschema.Format{
Name: "duration",
Validate: durationFormatChecker,
})
schema := compiler.MustCompile("compose-spec.json")

// santhosh-tekuri doesn't allow derived types
// see https://github.com/santhosh-tekuri/jsonschema/pull/240
Expand Down
25 changes: 25 additions & 0 deletions schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package schema
import (
"os"
"strings"
"sync"
"testing"

"github.com/santhosh-tekuri/jsonschema/v6"
Expand Down Expand Up @@ -250,6 +251,30 @@ func TestValidateVariables(t *testing.T) {
assert.NilError(t, Validate(config))
}

// TestValidateConcurrent ensures Validate can safely be called from several
// goroutines at once. Run with -race to catch the regression reported in
// https://github.com/docker/compose/issues/13866 ("concurrent map iteration
// and map write" while compiling the schema).
func TestValidateConcurrent(t *testing.T) {
config := map[string]any{
"services": map[string]any{
"foo": map[string]any{
"image": "busybox",
},
},
}

var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
assert.NilError(t, Validate(config))
}()
}
wg.Wait()
}

func TestSchema(t *testing.T) {
compiler := jsonschema.NewCompiler()
json, err := jsonschema.UnmarshalJSON(strings.NewReader(Schema))
Expand Down
Loading