Repository: compose-spec/compose-go
Pull request #625 (2024-05-13 13:42:20)

Fix compose schema
This PR replaces [gojsonschema](https://github.com/xeipuuv/gojsonschema) with [jsonschema](https://github.com/santhosh-tekuri/jsonschema) since the first does not validate correctly our schema (if at all), leading to unvalidated  compose-spec.json schema. If you open VSCode, and write the example
```
services:
  web:
    image: alpine
include:
  - some/path <<
```
VSCode (and other IDEs) will complain about `some/path`.

Some tests were introduced, but nearly not enough to fully cover the current compose schema.

Also, changed from pseudo draft2019 to draft07 since this is the most adopted draft spec.


Patch:
diff --git a/Makefile b/Makefile
index 5420345..442771e 100644
--- a/Makefile
+++ b/Makefile
@@ -46,3 +46,6 @@ 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 592e6f0..99cfcaa 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -27,3 +27,4 @@ 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 3e8d4f0..8eac19b 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,7 +24,5 @@ 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 a557685..9aac724 100644
--- a/go.sum
+++ b/go.sum
@@ -22,19 +22,14 @@ 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 78e2242..bd0a563 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -309,6 +309,7 @@ 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
@@ -366,6 +367,11 @@ 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 {
@@ -427,7 +433,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 			}
 
 			if !opts.SkipValidation {
-				if err := schema.Validate(dict); err != nil {
+				if err := sch.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 33a79e9..85083f9 100644
--- a/schema/compose-spec.json
+++ b/schema/compose-spec.json
@@ -1,6 +1,6 @@
 {
-  "$schema": "https://json-schema.org/draft/2019-09/schema#",
-  "id": "compose_spec.json",
+  "$schema": "http://json-schema.org/draft-07/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,14 +20,13 @@
     "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._-]+$": {
@@ -38,7 +37,7 @@
     },
 
     "networks": {
-      "id": "#/properties/networks",
+      "$id": "#/properties/networks",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -48,7 +47,7 @@
     },
 
     "volumes": {
-      "id": "#/properties/volumes",
+      "$id": "#/properties/volumes",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -59,7 +58,7 @@
     },
 
     "secrets": {
-      "id": "#/properties/secrets",
+      "$id": "#/properties/secrets",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -70,7 +69,7 @@
     },
 
     "configs": {
-      "id": "#/properties/configs",
+      "$id": "#/properties/configs",
       "type": "object",
       "patternProperties": {
         "^[a-zA-Z0-9._-]+$": {
@@ -87,7 +86,7 @@
   "definitions": {
 
     "service": {
-      "id": "#/definitions/service",
+      "$id": "#/definitions/service",
       "type": "object",
 
       "properties": {
@@ -222,8 +221,13 @@
         "expose": {
           "type": "array",
           "items": {
-            "type": ["string", "number"],
-            "format": "expose"
+            "oneOf": [
+              { "type": "number" },
+              { 
+                "type": "string", 
+                "pattern": "^\\d{1,5}(-\\d{1,5})?(\/(tcp|udp))?$"
+              }
+            ]
           },
           "uniqueItems": true
         },
@@ -292,8 +296,8 @@
                       "type": "object",
                       "properties": {
                         "aliases": {"$ref": "#/definitions/list_of_strings"},
-                        "ipv4_address": {"type": "string"},
-                        "ipv6_address": {"type": "string"},
+                        "ipv4_address": {"type": "string", "format": "ipv4"},
+                        "ipv6_address": {"type": "string", "format": "ipv6"},
                         "link_local_ips": {"$ref": "#/definitions/list_of_strings"},
                         "mac_address": {"type": "string"},
                         "driver_opts": {
@@ -324,18 +328,48 @@
           "type": "array",
           "items": {
             "oneOf": [
-              {"type": "number", "format": "ports"},
-              {"type": "string", "format": "ports"},
+              {"type": "number"},
+              {
+                "type": "string", 
+                "pattern": "^((\\d+((\\.\\d+)+|(-\\d+))*):?){1,3}(\/(tcp|udp))?$"
+              },
               {
                 "type": "object",
                 "properties": {
-                  "name": {"type": "string"},
-                  "mode": {"type": "string"},
-                  "host_ip": {"type": "string"},
-                  "target": {"type": "integer"},
-                  "published": {"type": ["string", "integer"]},
-                  "protocol": {"type": "string"},
-                  "app_protocol": {"type": "string"}
+                  "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"
+                  }
                 },
                 "additionalProperties": false,
                 "patternProperties": {"^x-": {}}
@@ -362,7 +396,7 @@
         "secrets": {"$ref": "#/definitions/service_config_or_secret"},
         "sysctls": {"$ref": "#/definitions/list_or_dict"},
         "stdin_open": {"type": "boolean"},
-        "stop_grace_period": {"type": "string", "format": "duration"},
+        "stop_grace_period": {"$ref": "#/definitions/duration_format"},
         "stop_signal": {"type": "string"},
         "storage_opt": {"type": "object"},
         "tmpfs": {"$ref": "#/definitions/string_or_list"},
@@ -375,7 +409,6 @@
           "type": "array",
           "items": {
             "oneOf": [
-              {"type": "string"},
               {
                 "type": "object",
                 "required": ["type"],
@@ -421,7 +454,8 @@
                 },
                 "additionalProperties": false,
                 "patternProperties": {"^x-": {}}
-              }
+              },
+              {"type": "string"}
             ]
           },
           "uniqueItems": true
@@ -438,11 +472,11 @@
     },
 
     "healthcheck": {
-      "id": "#/definitions/healthcheck",
+      "$id": "#/definitions/healthcheck",
       "type": "object",
       "properties": {
         "disable": {"type": "boolean"},
-        "interval": {"type": "string", "format": "duration"},
+        "interval": {"$ref": "#/definitions/duration_format"},
         "retries": {"type": "number"},
         "test": {
           "oneOf": [
@@ -450,36 +484,39 @@
             {"type": "array", "items": {"type": "string"}}
           ]
         },
-        "timeout": {"type": "string", "format": "duration"},
-        "start_period": {"type": "string", "format": "duration"},
-        "start_interval": {"type": "string", "format": "duration"}
+        "timeout": {"$ref": "#/definitions/duration_format"},
+        "start_period": {"$ref": "#/definitions/duration_format"},
+        "start_interval": {"$ref": "#/definitions/duration_format"}
       },
       "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"},
@@ -490,9 +527,9 @@
           "type": "object",
           "properties": {
             "parallelism": {"type": "integer"},
-            "delay": {"type": "string", "format": "duration"},
+            "delay": {"$ref": "#/definitions/duration_format"},
             "failure_action": {"type": "string"},
-            "monitor": {"type": "string", "format": "duration"},
+            "monitor": {"$ref": "#/definitions/duration_format"},
             "max_failure_ratio": {"type": "number"},
             "order": {"type": "string", "enum": [
               "start-first", "stop-first"
@@ -505,9 +542,9 @@
           "type": "object",
           "properties": {
             "parallelism": {"type": "integer"},
-            "delay": {"type": "string", "format": "duration"},
+            "delay": {"$ref": "#/definitions/duration_format"},
             "failure_action": {"type": "string"},
-            "monitor": {"type": "string", "format": "duration"},
+            "monitor": {"$ref": "#/definitions/duration_format"},
             "max_failure_ratio": {"type": "number"},
             "order": {"type": "string", "enum": [
               "start-first", "stop-first"
@@ -548,9 +585,9 @@
           "type": "object",
           "properties": {
             "condition": {"type": "string"},
-            "delay": {"type": "string", "format": "duration"},
+            "delay": {"$ref": "#/definitions/duration_format"},
             "max_attempts": {"type": "integer"},
-            "window": {"type": "string", "format": "duration"}
+            "window": {"$ref": "#/definitions/duration_format"}
           },
           "additionalProperties": false,
           "patternProperties": {"^x-": {}}
@@ -576,12 +613,12 @@
           "patternProperties": {"^x-": {}}
         }
       },
-      "additionalProperties": false,
-      "patternProperties": {"^x-": {}}
+      "patternProperties": {"^x-": {}},
+      "additionalProperties": false
     },
 
     "generic_resources": {
-      "id": "#/definitions/generic_resources",
+      "$id": "#/definitions/generic_resources",
       "type": "array",
       "items": {
         "type": "object",
@@ -602,13 +639,18 @@
     },
 
     "devices": {
-      "id": "#/definitions/devices",
+      "$id": "#/definitions/devices",
       "type": "array",
       "items": {
         "type": "object",
         "properties": {
           "capabilities": {"$ref": "#/definitions/list_of_strings"},
-          "count": {"type": ["string", "integer"]},
+          "count": { 
+            "anyOf": [
+              {"type": "string"},
+              {"type": "integer"}
+            ]
+          },
           "device_ids": {"$ref": "#/definitions/list_of_strings"},
           "driver":{"type": "string"},
           "options":{"$ref": "#/definitions/list_or_dict"}
@@ -619,7 +661,7 @@
     },
 
     "include": {
-      "id": "#/definitions/include",
+      "$id": "#/definitions/include",
       "oneOf": [
         {"type": "string"},
         {
@@ -635,7 +677,7 @@
     },
 
     "network": {
-      "id": "#/definitions/network",
+      "$id": "#/definitions/network",
       "type": ["object", "null"],
       "properties": {
         "name": {"type": "string"},
@@ -655,7 +697,11 @@
               "items": {
                 "type": "object",
                 "properties": {
-                  "subnet": {"type": "string", "format": "subnet_ip_address"},
+                  "subnet": {"anyOf":[
+                    {"$ref": "#/definitions/ipv4_subnet_format"},
+                    {"$ref": "#/definitions/ipv6_subnet_format"}
+                  ]
+                  },
                   "ip_range": {"type": "string"},
                   "gateway": {"type": "string"},
                   "aux_addresses": {
@@ -698,7 +744,7 @@
     },
 
     "volume": {
-      "id": "#/definitions/volume",
+      "$id": "#/definitions/volume",
       "type": ["object", "null"],
       "properties": {
         "name": {"type": "string"},
@@ -727,7 +773,7 @@
     },
 
     "secret": {
-      "id": "#/definitions/secret",
+      "$id": "#/definitions/secret",
       "type": "object",
       "properties": {
         "name": {"type": "string"},
@@ -754,7 +800,7 @@
     },
 
     "config": {
-      "id": "#/definitions/config",
+      "$id": "#/definitions/config",
       "type": "object",
       "properties": {
         "name": {"type": "string"},
@@ -814,7 +860,6 @@
         }
       ]
     },
-
     "string_or_list": {
       "oneOf": [
         {"type": "string"},
@@ -827,7 +872,31 @@
       "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": [
         {
@@ -901,7 +970,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 bfbaa93..3539c13 100644
--- a/schema/schema.go
+++ b/schema/schema.go
@@ -17,37 +17,22 @@
 package schema
 
 import (
+	"bytes"
+	"errors"
+	"regexp"
+	"slices"
+	"strings"
+
 	// Enable support for embedded static resources
 	_ "embed"
-	"fmt"
-	"strings"
-	"time"
+	"encoding/json"
 
-	"github.com/xeipuuv/gojsonschema"
+	"github.com/compose-spec/compose-go/v2/utils"
+	"github.com/santhosh-tekuri/jsonschema"
 )
 
-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{})
+type ComposeSchema struct {
+	schema *jsonschema.Schema
 }
 
 // Schema is the compose-spec JSON schema
@@ -55,110 +40,131 @@ func init() {
 //go:embed compose-spec.json
 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)
+func CreateComposeSchema() (ComposeSchema, error) {
+	c := jsonschema.NewCompiler()
+	c.Draft = jsonschema.Draft7
 
-	result, err := gojsonschema.Validate(schemaLoader, dataLoader)
+	err := c.AddResource("compose-spec", strings.NewReader(Schema))
 	if err != nil {
-		return err
+		return ComposeSchema{}, err
 	}
-
-	if !result.Valid() {
-		return toError(result)
+	sch, err := c.Compile("compose-spec")
+	if err != nil {
+		return ComposeSchema{}, err
 	}
-
-	return nil
+	return ComposeSchema{schema: sch}, nil
 }
 
-func toError(result *gojsonschema.Result) error {
-	err := getMostSpecificError(result.Errors())
-	return err
+func (c *ComposeSchema) Validate(config map[string]interface{}) error {
+	v, err := json.Marshal(config)
+	if err != nil {
+		return err
+	}
+	err = c.schema.Validate(bytes.NewReader(v))
+	if err != nil {
+		return formatError(err)
+	}
+	return nil
 }
 
-const (
-	jsonschemaOneOf = "number_one_of"
-	jsonschemaAnyOf = "number_any_of"
-)
+func formatError(result error) error {
+	message := result.Error()
+	allErrors := strings.Split(message, "\n")
 
-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))
+	// 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)
 		}
-	case jsonschemaOneOf, jsonschemaAnyOf:
-		if err.child == nil {
-			return err.parent.Description()
-		}
-		return err.child.Description()
 	}
-	return err.parent.Description()
-}
-
-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)
+	// 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"])
 		}
-		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
+		return depthB - depthA
+	})
+	description := getDescription(infos[0]["Message"], infos[0]["Path"])
+	return errors.New(description)
 }
 
-type validationError struct {
-	parent gojsonschema.ResultError
-	child  gojsonschema.ResultError
+func depth(s string) int {
+	return len(strings.Split(s, "/"))
 }
 
-func (err validationError) Error() string {
-	description := getDescription(err)
-	return fmt.Sprintf("%s %s", err.parent.Field(), description)
-}
-
-func getMostSpecificError(errors []gojsonschema.ResultError) validationError {
-	mostSpecificError := 0
-	for i, err := range errors {
-		if specificity(err) > specificity(errors[mostSpecificError]) {
-			mostSpecificError = i
-			continue
+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")
 		}
+	case strings.Contains(message, "expected"):
+		res = utils.TrimRightFrom(message, ", ")
+		res = strings.Replace(res, "expected", "must be a", 1)
 
-		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
-			}
-		}
+		res = strings.ReplaceAll(res, "object", "mapping")
+		res = strings.ReplaceAll(res, "array", "list")
+
+		or := strings.Count(res, "or")
+		res = strings.Replace(res, " or", ",", or-1)
+
+	case strings.Contains(message, "additionalProperties"):
+		res = strings.ReplaceAll(message, "additionalProperties \"", "Additional property ")
+		res = strings.ReplaceAll(res, "\" not allowed", " is not allowed")
+
+	case strings.Contains(message, "missing properties"):
+		res = strings.ReplaceAll(message, "\"", "")
+		res = strings.ReplaceAll(res, "missing properties: ", "")
+		res += " is required"
 	}
 
-	if mostSpecificError+1 == len(errors) {
-		return validationError{parent: errors[mostSpecificError]}
+	if prefix == "#" {
+		prefix = ""
+	} else {
+		prefix = utils.TrimLeftFrom(prefix, "#/")
+		prefix = strings.ReplaceAll(prefix, "/", ".")
 	}
+	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)
 
-	switch errors[mostSpecificError].Type() {
-	case "number_one_of", "number_any_of":
-		return validationError{
-			parent: errors[mostSpecificError],
-			child:  errors[mostSpecificError+1],
+	info := make(map[string]string)
+	for i, name := range compRegEx.SubexpNames() {
+		if i > 0 && i <= len(match) && name != "" {
+			info[name] = match[i]
 		}
-	default:
-		return validationError{parent: errors[mostSpecificError]}
 	}
+	return info
 }
 
-func specificity(err gojsonschema.ResultError) int {
-	return len(strings.Split(err.Field(), "."))
+func ignoreError(message string) bool {
+	return strings.Contains(message, "doesn't validate with") || strings.Contains(message, "oneOf failed")
 }
diff --git a/utils/stringutils.go b/utils/stringutils.go
index dfabf6c..d13ca4b 100644
--- a/utils/stringutils.go
+++ b/utils/stringutils.go
@@ -46,3 +46,19 @@ 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 8467cde..46a4168 100644
--- a/schema/schema_test.go
+++ b/schema/schema_test.go
@@ -23,8 +23,28 @@ import (
 )
 
 type dict map[string]interface{}
+type array []interface{}
+
+type ComposeTestCase struct {
+	name    string
+	config  dict
+	invalid bool
+}
 
-func TestValidate(t *testing.T) {
+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 {
 	config := dict{
 		"services": dict{
 			"foo": dict{
@@ -32,34 +52,44 @@ func TestValidate(t *testing.T) {
 			},
 		},
 	}
-
-	assert.NilError(t, Validate(config))
+	config[key] = value
+	return config
 }
 
-func TestValidateUndefinedTopLevelOption(t *testing.T) {
-	config := dict{
-		"helicopters": dict{
+func createServiceConfig(key string, value interface{}) dict {
+	return dict{
+		"services": dict{
 			"foo": dict{
 				"image": "busybox",
+				key:     value,
 			},
 		},
 	}
+}
 
-	err := Validate(config)
-	assert.ErrorContains(t, err, "Additional property helicopters is not allowed")
+func TestComposeSpecSchemaIsValid(t *testing.T) {
+	_, err := CreateComposeSchema()
+	assert.NilError(t, err)
 }
 
-func TestValidateAllowsXTopLevelFields(t *testing.T) {
+var schema, _ = CreateComposeSchema()
+
+func TestValidateUndefinedTopLevelOption(t *testing.T) {
 	config := dict{
-		"x-extra-stuff": dict{},
+		"helicopters": dict{
+			"foo": dict{
+				"image": "busybox",
+			},
+		},
 	}
 
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	err := schema.Validate(config)
+	assert.ErrorContains(t, err, `Additional property helicopters is not allowed`)
 }
 
 func TestValidateAllowsXFields(t *testing.T) {
 	config := dict{
+		"x-test": dict{},
 		"services": dict{
 			"bar": dict{
 				"x-extra-stuff": dict{},
@@ -86,8 +116,7 @@ func TestValidateAllowsXFields(t *testing.T) {
 			},
 		},
 	}
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	assert.NilError(t, schema.Validate(config))
 }
 
 func TestValidateSecretConfigNames(t *testing.T) {
@@ -104,132 +133,309 @@ func TestValidateSecretConfigNames(t *testing.T) {
 		},
 	}
 
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	assert.NilError(t, schema.Validate(config))
 }
 
-type array []interface{}
+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",
+	})
 
-func TestValidatePlacement(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
-				"image": "busybox",
-				"deploy": dict{
-					"placement": dict{
-						"preferences": array{
-							dict{
-								"spread": "node.labels.az",
-							},
-						},
-					},
-				},
-			},
+	assert.NilError(t, schema.Validate(config))
+}
+
+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,
 		},
 	}
 
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	ValidateAll(t, tests)
 }
-
-func TestValidateIsolation(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
-				"image":     "busybox",
-				"isolation": "some-isolation-value",
-			},
+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,
 		},
 	}
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	ValidateAll(t, tests)
 }
 
-func TestValidateRollbackConfig(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
-				"image": "busybox",
-				"deploy": dict{
-					"rollback_config": dict{
-						"parallelism": 1,
-					},
-				},
-			},
+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,
 		},
 	}
-
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	ValidateAll(t, tests)
 }
 
-func TestValidateRollbackConfigWithOrder(t *testing.T) {
-	config := dict{
-		"services": dict{
-			"foo": dict{
-				"image": "busybox",
-				"deploy": dict{
-					"rollback_config": dict{
-						"parallelism": 1,
-						"order":       "start-first",
-					},
+func TestValidatePlacement(t *testing.T) {
+	config := createServiceConfig("deploy", dict{
+		"placement": dict{
+			"preferences": array{
+				dict{
+					"spread": "node.labels.az",
 				},
 			},
 		},
-	}
+	})
 
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	assert.NilError(t, schema.Validate(config))
 }
 
-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",
-					},
+func TestValidateIsolation(t *testing.T) {
+	config := createServiceConfig("isolation", "some-isolation-value")
+
+	assert.NilError(t, schema.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",
+				},
+			}),
 		},
 	}
+	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"}),
+		},
+	}
+	ValidateAll(t, tests)
 }
 
-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",
+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",
 					},
-					"rollback_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",
 					},
 				},
-			},
+			}),
+			invalid: true,
 		},
 	}
 
-	assert.NilError(t, Validate(config))
-	assert.NilError(t, Validate(config))
+	ValidateAll(t, tests)
 }