Repository: compose-spec/compose-go
Pull request #630 (2024-05-16 11:30:54)

Revert "Fix compose schema"
Reverts compose-spec/compose-go#625

see https://github.com/compose-spec/compose-spec/issues/501

#625 adds a bunch of regexp to validate formats. We also have to support variable being used as values for interpolation, so can't add such a validation in the spec 


Patch:
diff --git a/Makefile b/Makefile
index 442771e..5420345 100644
--- a/Makefile
+++ b/Makefile
@@ -46,6 +46,3 @@ check-license: build-validate-image
 setup: ## Setup the precommit hook
 	@which pre-commit > /dev/null 2>&1 || (echo "pre-commit not installed see README." && false)
 	@pre-commit install
-
-.PHONY: validate
-validate: lint test check-license
\ No newline at end of file
diff --git a/consts/consts.go b/consts/consts.go
index 99cfcaa..592e6f0 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -27,4 +27,3 @@ const (
 const Extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
 
 type ComposeFileKey struct{}
-type ComposeSchema struct{}
diff --git a/go.mod b/go.mod
index 8eac19b..3e8d4f0 100644
--- a/go.mod
+++ b/go.mod
@@ -11,9 +11,9 @@ require (
 	github.com/mitchellh/copystructure v1.2.0
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/opencontainers/go-digest v1.0.0
-	github.com/santhosh-tekuri/jsonschema v1.2.4
 	github.com/sirupsen/logrus v1.9.0
 	github.com/stretchr/testify v1.8.4
+	github.com/xeipuuv/gojsonschema v1.2.0
 	golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3
 	golang.org/x/sync v0.3.0
 	gopkg.in/yaml.v3 v3.0.1
@@ -24,5 +24,7 @@ require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // 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
 )
diff --git a/go.sum b/go.sum
index 9aac724..a557685 100644
--- a/go.sum
+++ b/go.sum
@@ -22,14 +22,19 @@ 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/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/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
diff --git a/loader/loader.go b/loader/loader.go
index fc08983..cd8f281 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -309,7 +309,6 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 // LoadWithContext reads a ConfigDetails and returns a fully loaded configuration as a compose-go Project
 func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
 	opts := toOptions(&configDetails, options)
-
 	dict, err := loadModelWithContext(ctx, &configDetails, opts)
 	if err != nil {
 		return nil, err
@@ -359,11 +358,6 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 		dict = map[string]interface{}{}
 		err  error
 	)
-	sch, err := schema.CreateComposeSchema()
-	if err != nil {
-		return nil, fmt.Errorf("could not initialise schema: %w", err)
-	}
-
 	for _, file := range config.ConfigFiles {
 		fctx := context.WithValue(ctx, consts.ComposeFileKey{}, file.Filename)
 		if file.Content == nil && file.Config == nil {
@@ -425,7 +419,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 			}
 
 			if !opts.SkipValidation {
-				if err := sch.Validate(dict); err != nil {
+				if err := schema.Validate(dict); err != nil {
 					return fmt.Errorf("validating %s: %w", file.Filename, err)
 				}
 				if _, ok := dict["version"]; ok {
diff --git a/schema/compose-spec.json b/schema/compose-spec.json
index 85083f9..33a79e9 100644
--- a/schema/compose-spec.json
+++ b/schema/compose-spec.json
@@ -1,6 +1,6 @@
 {
-  "$schema": "http://json-schema.org/draft-07/schema#",
-  "$id": "compose_spec.json",
+  "$schema": "https://json-schema.org/draft/2019-09/schema#",
+  "id": "compose_spec.json",
   "type": "object",
   "title": "Compose Specification",
   "description": "The Compose file is a YAML file defining a multi-containers based application.",
@@ -20,13 +20,14 @@
     "include": {
       "type": "array",
       "items": {
+        "type": "object",
         "$ref": "#/definitions/include"
       },
       "description": "compose sub-projects to be included."
     },
 
     "services": {
-      "$id": "#/properties/services",
+      "id": "#/properties/services",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -37,7 +38,7 @@
     },
 
     "networks": {
-      "$id": "#/properties/networks",
+      "id": "#/properties/networks",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -47,7 +48,7 @@
     },
 
     "volumes": {
-      "$id": "#/properties/volumes",
+      "id": "#/properties/volumes",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -58,7 +59,7 @@
     },
 
     "secrets": {
-      "$id": "#/properties/secrets",
+      "id": "#/properties/secrets",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -69,7 +70,7 @@
     },
 
     "configs": {
-      "$id": "#/properties/configs",
+      "id": "#/properties/configs",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -86,7 +87,7 @@
   "definitions": {
 
     "service": {
-      "$id": "#/definitions/service",
+      "id": "#/definitions/service",
       "type": "object",
 
       "properties": {
@@ -221,13 +222,8 @@
         "expose": {
           "type": "array",
           "items": {
-            "oneOf": [
-              { "type": "number" },
-              { 
-                "type": "string", 
-                "pattern": "^\\d{1,5}(-\\d{1,5})?(\/(tcp|udp))?$"
-              }
-            ]
+            "type": ["string", "number"],
+            "format": "expose"
           },
           "uniqueItems": true
         },
@@ -296,8 +292,8 @@
                       "type": "object",
                       "properties": {
                         "aliases": {"$ref": "#/definitions/list_of_strings"},
-                        "ipv4_address": {"type": "string", "format": "ipv4"},
-                        "ipv6_address": {"type": "string", "format": "ipv6"},
+                        "ipv4_address": {"type": "string"},
+                        "ipv6_address": {"type": "string"},
                         "link_local_ips": {"$ref": "#/definitions/list_of_strings"},
                         "mac_address": {"type": "string"},
                         "driver_opts": {
@@ -328,48 +324,18 @@
           "type": "array",
           "items": {
             "oneOf": [
-              {"type": "number"},
-              {
-                "type": "string", 
-                "pattern": "^((\\d+((\\.\\d+)+|(-\\d+))*):?){1,3}(\/(tcp|udp))?$"
-              },
+              {"type": "number", "format": "ports"},
+              {"type": "string", "format": "ports"},
               {
                 "type": "object",
                 "properties": {
-                  "name": {
-                    "type": "string",
-                    "description": "A human-readable name for the port, used to document it's usage within the service"
-                  },
-                  "mode": {
-                    "type": "string", 
-                    "pattern": "^ingress|host$",
-                    "description": "host: For publishing a host port on each node, or ingress: for a port to be load balanced. Defaults to ingress."
-                  },
-                  "host_ip": {
-                    "type": "string", 
-                    "format": "ipv4",
-                    "description": "The Host IP mapping, unspecified means all network interfaces (0.0.0.0)"
-                  },
-                  "target": {
-                    "$ref": "#/definitions/port_format",
-                    "description": "The container port"      
-                  },
-                  "published": {
-                    "anyOf":[
-                      { "$ref": "#/definitions/port_format" },
-                      { "$ref": "#/definitions/port_published_format" }
-                    ],
-                    "description": "The publicly exposed port. It is defined as a string and can be set as a range using syntax start-end. It means the actual port is assigned a remaining available port, within the set range."
-                  },
-                  "protocol": {
-                    "type": "string", 
-                    "pattern": "^tcp|udp$",
-                    "description": "The port protocol (tcp or udp). Defaults to tcp"
-                  },
-                  "app_protocol": {
-                    "type": "string",
-                    "description": "The application protocol (TCP/IP level 4 / OSI level 7) this port is used for. This is optional and can be used as a hint for Compose to offer richer behavior for protocols that it understands"
-                  }
+                  "name": {"type": "string"},
+                  "mode": {"type": "string"},
+                  "host_ip": {"type": "string"},
+                  "target": {"type": "integer"},
+                  "published": {"type": ["string", "integer"]},
+                  "protocol": {"type": "string"},
+                  "app_protocol": {"type": "string"}
                 },
                 "additionalProperties": false,
                 "patternProperties": {"^x-": {}}
@@ -396,7 +362,7 @@
         "secrets": {"$ref": "#/definitions/service_config_or_secret"},
         "sysctls": {"$ref": "#/definitions/list_or_dict"},
         "stdin_open": {"type": "boolean"},
-        "stop_grace_period": {"$ref": "#/definitions/duration_format"},
+        "stop_grace_period": {"type": "string", "format": "duration"},
         "stop_signal": {"type": "string"},
         "storage_opt": {"type": "object"},
         "tmpfs": {"$ref": "#/definitions/string_or_list"},
@@ -409,6 +375,7 @@
           "type": "array",
           "items": {
             "oneOf": [
+              {"type": "string"},
               {
                 "type": "object",
                 "required": ["type"],
@@ -454,8 +421,7 @@
                 },
                 "additionalProperties": false,
                 "patternProperties": {"^x-": {}}
-              },
-              {"type": "string"}
+              }
             ]
           },
           "uniqueItems": true
@@ -472,11 +438,11 @@
     },
 
     "healthcheck": {
-      "$id": "#/definitions/healthcheck",
+      "id": "#/definitions/healthcheck",
       "type": "object",
       "properties": {
         "disable": {"type": "boolean"},
-        "interval": {"$ref": "#/definitions/duration_format"},
+        "interval": {"type": "string", "format": "duration"},
         "retries": {"type": "number"},
         "test": {
           "oneOf": [
@@ -484,39 +450,36 @@
             {"type": "array", "items": {"type": "string"}}
           ]
         },
-        "timeout": {"$ref": "#/definitions/duration_format"},
-        "start_period": {"$ref": "#/definitions/duration_format"},
-        "start_interval": {"$ref": "#/definitions/duration_format"}
+        "timeout": {"type": "string", "format": "duration"},
+        "start_period": {"type": "string", "format": "duration"},
+        "start_interval": {"type": "string", "format": "duration"}
       },
       "additionalProperties": false,
       "patternProperties": {"^x-": {}}
     },
     "development": {
-      "$id": "#/definitions/development",
+      "id": "#/definitions/development",
       "type": ["object", "null"],
-      "additionalProperties": false,
-      "patternProperties": {"^x-": {}},
       "properties": {
         "watch": {
           "type": "array",
-          "minItems": 1,
           "items": {
             "type": "object",
             "required": ["path", "action"],
-            "additionalProperties": false,
-            "patternProperties": {"^x-": {}},
             "properties": {
+              "ignore": {"type": "array", "items": {"type": "string"}},
               "path": {"type": "string"},
               "action": {"type": "string", "enum": ["rebuild", "sync", "sync+restart"]},
-              "ignore": {"type": "array", "items": {"type": "string"}},
               "target": {"type": "string"}
             }
-          }
+          },
+          "additionalProperties": false,
+          "patternProperties": {"^x-": {}}
         }
       }
     },
     "deployment": {
-      "$id": "#/definitions/deployment",
+      "id": "#/definitions/deployment",
       "type": ["object", "null"],
       "properties": {
         "mode": {"type": "string"},
@@ -527,9 +490,9 @@
           "type": "object",
           "properties": {
             "parallelism": {"type": "integer"},
-            "delay": {"$ref": "#/definitions/duration_format"},
+            "delay": {"type": "string", "format": "duration"},
             "failure_action": {"type": "string"},
-            "monitor": {"$ref": "#/definitions/duration_format"},
+            "monitor": {"type": "string", "format": "duration"},
             "max_failure_ratio": {"type": "number"},
             "order": {"type": "string", "enum": [
               "start-first", "stop-first"
@@ -542,9 +505,9 @@
           "type": "object",
           "properties": {
             "parallelism": {"type": "integer"},
-            "delay": {"$ref": "#/definitions/duration_format"},
+            "delay": {"type": "string", "format": "duration"},
             "failure_action": {"type": "string"},
-            "monitor": {"$ref": "#/definitions/duration_format"},
+            "monitor": {"type": "string", "format": "duration"},
             "max_failure_ratio": {"type": "number"},
             "order": {"type": "string", "enum": [
               "start-first", "stop-first"
@@ -585,9 +548,9 @@
           "type": "object",
           "properties": {
             "condition": {"type": "string"},
-            "delay": {"$ref": "#/definitions/duration_format"},
+            "delay": {"type": "string", "format": "duration"},
             "max_attempts": {"type": "integer"},
-            "window": {"$ref": "#/definitions/duration_format"}
+            "window": {"type": "string", "format": "duration"}
           },
           "additionalProperties": false,
           "patternProperties": {"^x-": {}}
@@ -613,12 +576,12 @@
           "patternProperties": {"^x-": {}}
         }
       },
-      "patternProperties": {"^x-": {}},
-      "additionalProperties": false
+      "additionalProperties": false,
+      "patternProperties": {"^x-": {}}
     },
 
     "generic_resources": {
-      "$id": "#/definitions/generic_resources",
+      "id": "#/definitions/generic_resources",
       "type": "array",
       "items": {
         "type": "object",
@@ -639,18 +602,13 @@
     },
 
     "devices": {
-      "$id": "#/definitions/devices",
+      "id": "#/definitions/devices",
       "type": "array",
       "items": {
         "type": "object",
         "properties": {
           "capabilities": {"$ref": "#/definitions/list_of_strings"},
-          "count": { 
-            "anyOf": [
-              {"type": "string"},
-              {"type": "integer"}
-            ]
-          },
+          "count": {"type": ["string", "integer"]},
           "device_ids": {"$ref": "#/definitions/list_of_strings"},
           "driver":{"type": "string"},
           "options":{"$ref": "#/definitions/list_or_dict"}
@@ -661,7 +619,7 @@
     },
 
     "include": {
-      "$id": "#/definitions/include",
+      "id": "#/definitions/include",
       "oneOf": [
         {"type": "string"},
         {
@@ -677,7 +635,7 @@
     },
 
     "network": {
-      "$id": "#/definitions/network",
+      "id": "#/definitions/network",
       "type": ["object", "null"],
       "properties": {
         "name": {"type": "string"},
@@ -697,11 +655,7 @@
               "items": {
                 "type": "object",
                 "properties": {
-                  "subnet": {"anyOf":[
-                    {"$ref": "#/definitions/ipv4_subnet_format"},
-                    {"$ref": "#/definitions/ipv6_subnet_format"}
-                  ]
-                  },
+                  "subnet": {"type": "string", "format": "subnet_ip_address"},
                   "ip_range": {"type": "string"},
                   "gateway": {"type": "string"},
                   "aux_addresses": {
@@ -744,7 +698,7 @@
     },
 
     "volume": {
-      "$id": "#/definitions/volume",
+      "id": "#/definitions/volume",
       "type": ["object", "null"],
       "properties": {
         "name": {"type": "string"},
@@ -773,7 +727,7 @@
     },
 
     "secret": {
-      "$id": "#/definitions/secret",
+      "id": "#/definitions/secret",
       "type": "object",
       "properties": {
         "name": {"type": "string"},
@@ -800,7 +754,7 @@
     },
 
     "config": {
-      "$id": "#/definitions/config",
+      "id": "#/definitions/config",
       "type": "object",
       "properties": {
         "name": {"type": "string"},
@@ -860,6 +814,7 @@
         }
       ]
     },
+
     "string_or_list": {
       "oneOf": [
         {"type": "string"},
@@ -872,31 +827,7 @@
       "items": {"type": "string"},
       "uniqueItems": true
     },
-    "duration_format": {
-      "type": "string",
-      "pattern": "^([0-9]+h)?([0-9]+m)?([0-9]+s)?([0-9]+ms)?([0-9]+us)?([0-9]+ns)?$"
-    },
-    "expose_format": {
-      "type": "string",
-      "pattern": "^((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0][0-9]{1,4})|([0-9]{1,4}))(-((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0][0-9]{1,4})|([0-9]{1,4})))?(/tcp|udp)?$"
-    },
-    "port_published_format": {
-      "type": "string",
-      "pattern": "^((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0][0-9]{1,4})|([0-9]{1,4}))(-((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0][0-9]{1,4})|([0-9]{1,4})))?$"
-    },
-    "port_format": {
-      "type": "integer",
-      "minimum": 0,
-      "maximum": 65535
-    },
-    "ipv6_subnet_format": {
-      "type": "string",
-      "pattern": "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(?:\\d|[12]\\d|3[01])"
-    },
-    "ipv4_subnet_format": {
-      "type": "string",
-      "pattern": "((^|\\.)((25[0-5])|(2[0-4]\\d)|(1\\d\\d)|([1-9]?\\d))){4}\/(?:\\d|[12]\\d|3[01])"
-    },
+
     "list_or_dict": {
       "oneOf": [
         {
@@ -970,7 +901,7 @@
     },
     "constraints": {
       "service": {
-        "$id": "#/definitions/constraints/service",
+        "id": "#/definitions/constraints/service",
         "anyOf": [
           {"required": ["build"]},
           {"required": ["image"]}
diff --git a/schema/schema.go b/schema/schema.go
index 3539c13..bfbaa93 100644
--- a/schema/schema.go
+++ b/schema/schema.go
@@ -17,22 +17,37 @@
 package schema
 
 import (
-	"bytes"
-	"errors"
-	"regexp"
-	"slices"
-	"strings"
-
 	// Enable support for embedded static resources
 	_ "embed"
-	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
 
-	"github.com/compose-spec/compose-go/v2/utils"
-	"github.com/santhosh-tekuri/jsonschema"
+	"github.com/xeipuuv/gojsonschema"
 )
 
-type ComposeSchema struct {
-	schema *jsonschema.Schema
+type portsFormatChecker struct{}
+
+func (checker portsFormatChecker) IsFormat(_ interface{}) bool {
+	// TODO: implement this
+	return true
+}
+
+type durationFormatChecker struct{}
+
+func (checker durationFormatChecker) IsFormat(input interface{}) bool {
+	value, ok := input.(string)
+	if !ok {
+		return false
+	}
+	_, err := time.ParseDuration(value)
+	return err == nil
+}
+
+func init() {
+	gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{})
+	gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{})
+	gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
 }
 
 // Schema is the compose-spec JSON schema
@@ -40,131 +55,110 @@ type ComposeSchema struct {
 //go:embed compose-spec.json
 var Schema string
 
-func CreateComposeSchema() (ComposeSchema, error) {
-	c := jsonschema.NewCompiler()
-	c.Draft = jsonschema.Draft7
+// Validate uses the jsonschema to validate the configuration
+func Validate(config map[string]interface{}) error {
+	schemaLoader := gojsonschema.NewStringLoader(Schema)
+	dataLoader := gojsonschema.NewGoLoader(config)
 
-	err := c.AddResource("compose-spec", strings.NewReader(Schema))
-	if err != nil {
-		return ComposeSchema{}, err
-	}
-	sch, err := c.Compile("compose-spec")
-	if err != nil {
-		return ComposeSchema{}, err
-	}
-	return ComposeSchema{schema: sch}, nil
-}
-
-func (c *ComposeSchema) Validate(config map[string]interface{}) error {
-	v, err := json.Marshal(config)
+	result, err := gojsonschema.Validate(schemaLoader, dataLoader)
 	if err != nil {
 		return err
 	}
-	err = c.schema.Validate(bytes.NewReader(v))
-	if err != nil {
-		return formatError(err)
+
+	if !result.Valid() {
+		return toError(result)
 	}
+
 	return nil
 }
 
-func formatError(result error) error {
-	message := result.Error()
-	allErrors := strings.Split(message, "\n")
+func toError(result *gojsonschema.Result) error {
+	err := getMostSpecificError(result.Errors())
+	return err
+}
+
+const (
+	jsonschemaOneOf = "number_one_of"
+	jsonschemaAnyOf = "number_any_of"
+)
 
-	// Iterate over errors and...
-	infos := []map[string]string{}
-	for _, e := range allErrors {
-		if !ignoreError(e) {
-			info := getErrorInfo(e) // ...extract metadata from each line
-			infos = append(infos, info)
+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))
 		}
-	}
-	// Sort so that the first element is the deepest leaf
-	slices.SortStableFunc(infos, func(a, b map[string]string) int {
-		depthA := depth(a["Path"])
-		depthB := depth(b["Path"])
-		if depthB == depthA {
-			return depth(b["Definition"]) - depth(a["Definition"])
+	case jsonschemaOneOf, jsonschemaAnyOf:
+		if err.child == nil {
+			return err.parent.Description()
 		}
-		return depthB - depthA
-	})
-	description := getDescription(infos[0]["Message"], infos[0]["Path"])
-	return errors.New(description)
-}
-
-func depth(s string) int {
-	return len(strings.Split(s, "/"))
+		return err.child.Description()
+	}
+	return err.parent.Description()
 }
 
-func getDescription(message, prefix string) string {
-	var res string
-	switch {
-	case strings.Contains(message, "must be"):
-		res = strings.Replace(message, "must", "Must", 1)
-		res = strings.ReplaceAll(res, "but found", "")
-		switch {
-		case strings.Contains(message, ">="):
-			res = strings.ReplaceAll(res, ">=", "greater than or equal to")
-		case strings.Contains(message, "<="):
-			res = strings.ReplaceAll(res, "<=", "lesser than or equal to")
-		case strings.Contains(message, ">"):
-			res = strings.ReplaceAll(res, ">", "greater than")
-		case strings.Contains(message, "<"):
-			res = strings.ReplaceAll(res, "<", "lesser than")
-		case strings.Contains(message, "="):
-			res = strings.ReplaceAll(res, "=", "equal to")
+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)
 		}
-	case strings.Contains(message, "expected"):
-		res = utils.TrimRightFrom(message, ", ")
-		res = strings.Replace(res, "expected", "must be a", 1)
+		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
+}
 
-		res = strings.ReplaceAll(res, "object", "mapping")
-		res = strings.ReplaceAll(res, "array", "list")
+type validationError struct {
+	parent gojsonschema.ResultError
+	child  gojsonschema.ResultError
+}
 
-		or := strings.Count(res, "or")
-		res = strings.Replace(res, " or", ",", or-1)
+func (err validationError) Error() string {
+	description := getDescription(err)
+	return fmt.Sprintf("%s %s", err.parent.Field(), description)
+}
 
-	case strings.Contains(message, "additionalProperties"):
-		res = strings.ReplaceAll(message, "additionalProperties \"", "Additional property ")
-		res = strings.ReplaceAll(res, "\" not allowed", " is not allowed")
+func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
+	mostSpecificError := 0
+	for i, err := range errors {
+		if specificity(err) > specificity(errors[mostSpecificError]) {
+			mostSpecificError = i
+			continue
+		}
 
-	case strings.Contains(message, "missing properties"):
-		res = strings.ReplaceAll(message, "\"", "")
-		res = strings.ReplaceAll(res, "missing properties: ", "")
-		res += " is required"
+		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 prefix == "#" {
-		prefix = ""
-	} else {
-		prefix = utils.TrimLeftFrom(prefix, "#/")
-		prefix = strings.ReplaceAll(prefix, "/", ".")
+	if mostSpecificError+1 == len(errors) {
+		return validationError{parent: errors[mostSpecificError]}
 	}
-	if res == "" {
-		// This error was not contemplated
-		res = message
-	}
-	return prefix + " " + res
-}
-
-// Creates a map with the tree <Path> from yaml of the keyword
-// <Definition> rule from compose-spec.json that caused the fail
-// and the error <Message>
-func getErrorInfo(message string) map[string]string {
-	message = strings.TrimLeft(message, " ")
-
-	var compRegEx = regexp.MustCompile(`I\[(?P<Path>#\/?(\w*\/*\W*)+)\]\ S\[(?P<Definition>#\/?(\w*\/*\W*)+)\]\ (?P<Message>(\w+\W*)+)`)
-	match := compRegEx.FindStringSubmatch(message)
 
-	info := make(map[string]string)
-	for i, name := range compRegEx.SubexpNames() {
-		if i > 0 && i <= len(match) && name != "" {
-			info[name] = match[i]
+	switch errors[mostSpecificError].Type() {
+	case "number_one_of", "number_any_of":
+		return validationError{
+			parent: errors[mostSpecificError],
+			child:  errors[mostSpecificError+1],
 		}
+	default:
+		return validationError{parent: errors[mostSpecificError]}
 	}
-	return info
 }
 
-func ignoreError(message string) bool {
-	return strings.Contains(message, "doesn't validate with") || strings.Contains(message, "oneOf failed")
+func specificity(err gojsonschema.ResultError) int {
+	return len(strings.Split(err.Field(), "."))
 }
diff --git a/utils/stringutils.go b/utils/stringutils.go
index d13ca4b..dfabf6c 100644
--- a/utils/stringutils.go
+++ b/utils/stringutils.go
@@ -46,19 +46,3 @@ func GetAsStringList(em map[string]string) []string {
 	}
 	return m
 }
-
-// Trims all characters from the delimter to the right
-func TrimRightFrom(s, delimeter string) string {
-	if idx := strings.Index(s, delimeter); idx != -1 {
-		return s[:idx]
-	}
-	return s
-}
-
-// Trims all characters from the delimter to the left
-func TrimLeftFrom(s, delimeter string) string {
-	if idx := strings.Index(s, delimeter); idx != -1 {
-		return s[(len(delimeter) + idx):]
-	}
-	return s
-}


Test patch:
diff --git a/schema/schema_test.go b/schema/schema_test.go
index 46a4168..8467cde 100644
--- a/schema/schema_test.go
+++ b/schema/schema_test.go
@@ -23,28 +23,8 @@ import (
 )
 
 type dict map[string]interface{}
-type array []interface{}
-
-type ComposeTestCase struct {
-	name    string
-	config  dict
-	invalid bool
-}
 
-func ValidateAll(t *testing.T, tests []ComposeTestCase) {
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			err := schema.Validate(tt.config)
-			if tt.invalid && err == nil {
-				t.Fail()
-			} else if !tt.invalid {
-				assert.NilError(t, err)
-			}
-		})
-	}
-}
-
-func createConfig(key string, value interface{}) dict {
+func TestValidate(t *testing.T) {
 	config := dict{
 		"services": dict{
 			"foo": dict{
@@ -52,44 +32,34 @@ func createConfig(key string, value interface{}) dict {
 			},
 		},
 	}
-	config[key] = value
-	return config
+
+	assert.NilError(t, Validate(config))
 }
 
-func createServiceConfig(key string, value interface{}) dict {
-	return dict{
-		"services": dict{
+func TestValidateUndefinedTopLevelOption(t *testing.T) {
+	config := dict{
+		"helicopters": dict{
 			"foo": dict{
 				"image": "busybox",
-				key:     value,
 			},
 		},
 	}
-}
 
-func TestComposeSpecSchemaIsValid(t *testing.T) {
-	_, err := CreateComposeSchema()
-	assert.NilError(t, err)
+	err := Validate(config)
+	assert.ErrorContains(t, err, "Additional property helicopters is not allowed")
 }
 
-var schema, _ = CreateComposeSchema()
-
-func TestValidateUndefinedTopLevelOption(t *testing.T) {
+func TestValidateAllowsXTopLevelFields(t *testing.T) {
 	config := dict{
-		"helicopters": dict{
-			"foo": dict{
-				"image": "busybox",
-			},
-		},
+		"x-extra-stuff": dict{},
 	}
 
-	err := schema.Validate(config)
-	assert.ErrorContains(t, err, `Additional property helicopters is not allowed`)
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
 func TestValidateAllowsXFields(t *testing.T) {
 	config := dict{
-		"x-test": dict{},
 		"services": dict{
 			"bar": dict{
 				"x-extra-stuff": dict{},
@@ -116,7 +86,8 @@ func TestValidateAllowsXFields(t *testing.T) {
 			},
 		},
 	}
-	assert.NilError(t, schema.Validate(config))
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
 func TestValidateSecretConfigNames(t *testing.T) {
@@ -133,309 +104,132 @@ func TestValidateSecretConfigNames(t *testing.T) {
 		},
 	}
 
-	assert.NilError(t, schema.Validate(config))
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestHealthcheck(t *testing.T) {
-	config := createServiceConfig("healthcheck", dict{
-		"test":           array{"CMD", "curl", "-f", "http://localhost"},
-		"interval":       "1m30s",
-		"timeout":        "10s",
-		"retries":        3,
-		"start_period":   "40s",
-		"start_interval": "5s",
-	})
-
-	assert.NilError(t, schema.Validate(config))
-}
+type array []interface{}
 
-func TestServiceExpose(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name: "[#/services/service/expose] expose port has right format",
-			config: createServiceConfig("expose", array{
-				"12345-12345/tcp",
-				"12345-12345/udp",
-				"12345/udp",
-				"12345-12345",
-				"1234-1234",
-				"123-123",
-				"12-12",
-				"1-1",
-				"12345",
-				"1234",
-				"123",
-				"12",
-				"1",
-			}),
-		},
-		{
-			name:    "[#/services/service/expose] expose port is too long",
-			config:  createServiceConfig("expose", array{"123456"}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/expose] expose second port is too long",
-			config:  createServiceConfig("expose", array{"1234-123456"}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/expose] expose missing first port",
-			config:  createServiceConfig("expose", array{"-123"}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/expose] expose with wrong protocol",
-			config:  createServiceConfig("expose", array{"123-123/random"}),
-			invalid: true,
+func TestValidatePlacement(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+				"deploy": dict{
+					"placement": dict{
+						"preferences": array{
+							dict{
+								"spread": "node.labels.az",
+							},
+						},
+					},
+				},
+			},
 		},
 	}
 
-	ValidateAll(t, tests)
-}
-func TestServicePorts(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name: "[#/services/service/ports] ports must have the [HOST:]CONTAINER[/PROTOCOL] format",
-			config: createServiceConfig("ports", array{
-				"3000",
-				"3000-3005",
-				"8000:8000",
-				"9090-9091:8080-8081",
-				"49100:22",
-				"8000-9000:80",
-				"127.0.0.1:8001:8001",
-				"127.0.0.1:5000-5010:5000-5010",
-				"6060:6060/udp",
-			}),
-		},
-		{
-			name:    "[#/services/service/ports] ports cannot be empty",
-			config:  createServiceConfig("ports", array{""}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/ports] ports cannot be negative",
-			config:  createServiceConfig("ports", array{"-3000"}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/ports] ports cannot be incomplete",
-			config:  createServiceConfig("ports", array{":80"}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/ports] ports must have valid protocol",
-			config:  createServiceConfig("ports", array{"6060:6060/random"}),
-			invalid: true,
-		},
-		{
-			name: "[#/services/service/ports] port protocol should be tcp or udp",
-			config: createServiceConfig("ports", array{
-				dict{"protocol": "tcp"},
-				dict{"protocol": "udp"},
-			}),
-		},
-		{
-			name: "[#/services/service/ports] port protocol should ONLY be tcp or udp",
-			config: createServiceConfig("ports", array{
-				dict{"protocol": "random"},
-			}),
-			invalid: true,
-		},
-		{
-			name: "[#/services/service/ports] port published within range",
-			config: createServiceConfig("ports", array{
-				dict{"published": "0"},
-				dict{"published": "00000"},
-				dict{"published": "0-0"},
-				dict{"published": "00000-00000"},
-				dict{"published": "65535"},
-				dict{"published": "65535-65535"},
-				dict{"published": 0},
-				dict{"published": 65535},
-			}),
-		},
-		{
-			name:    "[#/services/service/ports] port published should not be over 65535 (integer)",
-			config:  createServiceConfig("ports", array{dict{"published": 65536}}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/ports] published should not be over 65535 (string)",
-			config:  createServiceConfig("ports", array{dict{"published": "65536"}}),
-			invalid: true,
-		},
-		{
-			name:    "[#/services/service/ports] port published should not be over 65535 (string)",
-			config:  createServiceConfig("ports", array{dict{"published": "65535-65536"}}),
-			invalid: true,
-		},
-	}
-	ValidateAll(t, tests)
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestStopGracePeriod(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (micro second)",
-			config: createServiceConfig("stop_grace_period", "10us"),
-		},
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (millisecond)",
-			config: createServiceConfig("stop_grace_period", "10ms"),
-		},
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (second)",
-			config: createServiceConfig("stop_grace_period", "10s"),
-		},
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (minute)",
-			config: createServiceConfig("stop_grace_period", "10m"),
-		},
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (hour)",
-			config: createServiceConfig("stop_grace_period", "10h"),
-		},
-		{
-			name:   "[#/services/service/stop_grace_period] must be a valid duration (combined)",
-			config: createServiceConfig("stop_grace_period", "1h2m3s4ms5us"),
-		},
-		{
-			name:    "[services/service/stop_grace_period] cannot be given a duration with wrong order",
-			config:  createServiceConfig("stop_grace_period", "1s10h"),
-			invalid: true,
-		},
-		{
-			name:    "stop_grace_period cannot be given a duration with wrong unit",
-			config:  createServiceConfig("stop_grace_period", "1kg"),
-			invalid: true,
+func TestValidateIsolation(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image":     "busybox",
+				"isolation": "some-isolation-value",
+			},
 		},
 	}
-	ValidateAll(t, tests)
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestValidatePlacement(t *testing.T) {
-	config := createServiceConfig("deploy", dict{
-		"placement": dict{
-			"preferences": array{
-				dict{
-					"spread": "node.labels.az",
+func TestValidateRollbackConfig(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+				"deploy": dict{
+					"rollback_config": dict{
+						"parallelism": 1,
+					},
 				},
 			},
 		},
-	})
-
-	assert.NilError(t, schema.Validate(config))
-}
-
-func TestValidateIsolation(t *testing.T) {
-	config := createServiceConfig("isolation", "some-isolation-value")
+	}
 
-	assert.NilError(t, schema.Validate(config))
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestRollbackConfig(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name: "[#/services/service/deploy/rollback_config] should be valid",
-			config: createServiceConfig("deploy", dict{
-				"rollback_config": dict{
-					"parallelism":    1,
-					"order":          "start-first",
-					"delay":          "10s",
-					"failure_action": "pause",
-					"monitor":        "10s",
-				},
-			}),
-		},
-		{
-			name: "[#/services/service/deploy/rollback_config/monitor] is a duration",
-			config: createServiceConfig("deploy", dict{
-				"rollback_config": dict{
-					"monitor": "1h2s3ms4us5ns",
-				},
-			}),
-		},
-		{
-			name: "[#/services/service/deploy/rollback_config/delay] is a duration",
-			config: createServiceConfig("deploy", dict{
-				"rollback_config": dict{
-					"delay": "1h2s3ms4us",
-				},
-			}),
-		},
-		{
-			name: "[#/services/service/deploy] with rollback and update config",
-			config: createServiceConfig("deploy", dict{
-				"update_config": dict{
-					"parallelism":    1,
-					"order":          "start-first",
-					"delay":          "10s",
-					"failure_action": "pause",
-					"monitor":        "10s",
-				},
-				"rollback_config": dict{
-					"parallelism":    1,
-					"order":          "start-first",
-					"delay":          "10s",
-					"failure_action": "pause",
-					"monitor":        "10s",
+func TestValidateRollbackConfigWithOrder(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+				"deploy": dict{
+					"rollback_config": dict{
+						"parallelism": 1,
+						"order":       "start-first",
+					},
 				},
-			}),
+			},
 		},
 	}
-	ValidateAll(t, tests)
+
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestInclude(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name:   "[#/services/service/develop/include] should be an object array",
-			config: createConfig("include", array{dict{"path": "foo"}}),
-		},
-		{
-			name:   "[#/services/service/develop/include] should be a string array",
-			config: createConfig("include", array{"foo"}),
+func TestValidateRollbackConfigWithUpdateConfig(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+				"deploy": dict{
+					"update_config": dict{
+						"parallelism": 1,
+						"order":       "start-first",
+					},
+					"rollback_config": dict{
+						"parallelism": 1,
+						"order":       "start-first",
+					},
+				},
+			},
 		},
 	}
-	ValidateAll(t, tests)
+
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }
 
-func TestWatch(t *testing.T) {
-	tests := []ComposeTestCase{
-		{
-			name: "[#/services/service/develop/watch] should require action and path",
-			config: createServiceConfig("develop", dict{
-				"x-develop": "foo",
-				"watch": array{
-					dict{
-						"action":  "sync",
-						"path":    "foo",
-						"x-watch": "foo",
+func TestValidateRollbackConfigWithUpdateConfigFull(t *testing.T) {
+	config := dict{
+		"services": dict{
+			"foo": dict{
+				"image": "busybox",
+				"deploy": dict{
+					"update_config": dict{
+						"parallelism":    1,
+						"order":          "start-first",
+						"delay":          "10s",
+						"failure_action": "pause",
+						"monitor":        "10s",
 					},
-				},
-			}),
-		},
-		{
-			name: "[services/service/develop/watch] should require at least one element",
-			config: createServiceConfig("develop", dict{
-				"watch": array{},
-			}),
-			invalid: true,
-		},
-		{
-			name: "[services/service/develop/watch] should require at least one element with path and action",
-			config: createServiceConfig("develop", dict{
-				"watch": array{
-					dict{
-						"action": "rebuild",
+					"rollback_config": dict{
+						"parallelism":    1,
+						"order":          "start-first",
+						"delay":          "10s",
+						"failure_action": "pause",
+						"monitor":        "10s",
 					},
 				},
-			}),
-			invalid: true,
+			},
 		},
 	}
 
-	ValidateAll(t, tests)
+	assert.NilError(t, Validate(config))
+	assert.NilError(t, Validate(config))
 }