From af5b8b9645410e5db37643bf91a92b9defc35194 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 15:53:23 +0100 Subject: [PATCH 1/3] fix(lint): accept configs the platform accepts, and reject web build egress Checked against the platform's validation with the Upsun settings, by validating one config per schema property set to null, and one per object with a "."-prefixed key added. - Explicit null on optional fields is treated as unset: allow it on the 90+ properties where the platform does. Some differ by site (e.g. `mounts: null` is accepted on web and workers, as no override, but not on the application), so null is added where the property is used. - Keys starting with "." are ignored in mappings with fixed keys, and in the applications, services and routes sections. Allow them there, except in the stack, and drop such entries when merging and decoding. They are still rejected where the platform reads them as names (e.g. a cron or mount named ".note") and in tasks. - Accept an integer `request_buffering.max_request_size`, and `dynamic_compression` on locations and rules. - Drop `required: [applications]`: files in .upsun are merged and editors validate each one, and a project without applications (e.g. only redirect routes) is valid. - Allow any container profile name: profiles are per project. The known ones are listed as examples. - Reject `egress.build` on web, as on workers (`worker_egress` is renamed `container_egress`): build-phase egress is only read from the application. This replaces the warning. - Move each `$ref` with sibling keywords into `allOf` (or `anyOf` with null), as draft-07 ignores keywords beside `$ref`, so the override descriptions were never shown. - Omit gojsonschema's "Must validate at least one schema (anyOf)" error when the errors of the closest branch are also reported. A schema test checks that no keyword is beside a `$ref` and that closed objects allow "."-prefixed keys. Co-Authored-By: Claude Opus 5.5 --- internal/lint/config_schema.go | 35 +- internal/lint/linter_test.go | 213 ++++- internal/lint/merge.go | 3 + internal/lint/merge_test.go | 16 + internal/lint/schema/schema_test.go | 29 + internal/lint/schema/upsun-config-schema.json | 827 ++++++++++++++---- internal/lint/web.go | 5 - internal/lint/yaml.go | 21 +- 8 files changed, 980 insertions(+), 169 deletions(-) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index bf492a132..d30db47ca 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -2,6 +2,7 @@ package lint import ( "fmt" + "strings" "gopkg.in/yaml.v3" ) @@ -32,10 +33,6 @@ type Config struct { Relationships map[string]any `yaml:"relationships,omitempty"` Mounts map[string]Mount `yaml:"mounts,omitempty"` - - Egress struct { - Build any `yaml:"build,omitempty"` - } `yaml:"egress,omitempty"` } `yaml:"web,omitempty"` Relationships map[string]any `yaml:"relationships,omitempty"` @@ -139,13 +136,41 @@ type Authorization struct { } func DecodeConfig(content string) (*Config, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(content), &doc); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } var c Config - if err := yaml.Unmarshal([]byte(content), &c); err != nil { + if len(doc.Content) == 0 { + return &c, nil + } + root := resolveAlias(doc.Content[0]) + for _, top := range mappingEntries(root) { + if top.key.Value != keyTasks { + dropDotKeys(resolveAlias(top.value)) + } + } + if err := root.Decode(&c); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) } return &c, nil } +// dropDotKeys removes the entries of a mapping node whose keys start with ".", +// which the platform ignores (e.g. YAML anchors). +func dropDotKeys(node *yaml.Node) { + if node == nil || node.Kind != yaml.MappingNode { + return + } + content := node.Content[:0] + for i := 0; i+1 < len(node.Content); i += 2 { + if !strings.HasPrefix(node.Content[i].Value, ".") { + content = append(content, node.Content[i], node.Content[i+1]) + } + } + node.Content = content +} + // isStackEmpty reports whether a stack declares no runtimes or packages. The // stack is a mapping of "runtimes" and "packages", each a name, a mapping or a // list; an empty list or null is also accepted. diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 1fac7606e..f33049c4d 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -526,8 +526,217 @@ applications: egress: build: {allowed_domains: [{host: repo.packagist.org}]} `, - wantErrors: `linter warnings: - - applications.app.web.egress.build: build-phase egress has no effect on web: set it on the application`, + wantErrors: `linter errors: + - applications.app.web.egress: Additional property build is not allowed`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result, err := CheckContent(c.content) + require.NoError(t, err) + if c.wantErrors == "" { + assert.False(t, result.HasErrors(), "unexpected errors: %s", result) + assert.False(t, result.HasWarnings(), "unexpected warnings: %s", result) + return + } + assert.Equal(t, c.wantErrors, result.Error()) + }) + } +} + +// Configs that the platform accepts with the Upsun settings. +func TestLint_PlatformLenience(t *testing.T) { + cases := []struct { + name string + content string + wantErrors string + }{ + { + name: "null optional fields", + content: ` +applications: + app: + type: php:8.4 + timezone: null + hooks: {build: null, deploy: null, post_deploy: null} + runtime: {extensions: null, sizing_hints: {request_memory: null}, xdebug: null} + source: {root: null} + mounts: + files: {source: storage, service: null} + web: + size: null + mounts: null + relationships: null + operations: null + commands: {start: null} + upstream: {protocol: null} + locations: + /: + root: null + passthru: null + index: null + request_buffering: {max_request_size: null} + rules: + '\.css$': {expires: null, allow: null} + workers: + queue: + size: null + egress: null + commands: {start: php worker.php, pre_start: null} + crons: + report: + spec: "0 * * * *" + shutdown_timeout: null + commands: {start: php report.php, stop: null} + operations: + op: + timeout: null + commands: {start: echo, stop: null} + egress: + runtime: + allowed_domains: + - host: api.example.com + port: null +routes: + "https://{default}/": + type: upstream + upstream: app:http + id: null + primary: null + tls: + strict_transport_security: {enabled: null, preload: null} +tasks: + agent: + type: python:3.14 + timezone: null + egress: null + run: {command: ./run} +`, + }, + { + name: "null where the platform rejects it", + content: ` +applications: + app: + type: php:8.4 + mounts: null + web: + locations: + /: {allow: null} +`, + wantErrors: `linter errors: + - applications.app.mounts: Invalid type. Expected: object, given: null + - applications.app.web.locations./.allow: Invalid type. Expected: boolean, given: null`, + }, + { + name: "keys starting with a dot", + content: ` +.defaults: &defaults + type: php:8.4 +applications: + .base: &base + type: php:8.4 + web: + locations: + /: {root: public} + app: + <<: *base + .note: x + relationships: {db: "db:mysql"} + web: + .note: x + commands: {.note: x, start: php -S 0:8080} + locations: + /: + .note: x + root: public + request_buffering: {.note: x} + rules: + '\.css$': {.note: x, allow: true} + mounts: + files: {.note: x, source: storage} + crons: + report: + .note: x + spec: "0 * * * *" + commands: {.note: x, start: php report.php} + workers: + queue: + .note: {anchor: true} + commands: {start: php worker.php} + egress: + .note: x + runtime: + .note: x + allowed_domains: [{host: api.example.com, .note: x}] +services: + .note: x + db: + .note: x + type: mariadb:11.4 +routes: + .note: x + "https://{default}/": + .note: x + type: upstream + upstream: app:http + cache: {.note: x, enabled: false} +`, + }, + { + name: "keys starting with a dot where the platform reads them", + content: ` +applications: + app: + type: php:8.4 + crons: + .note: x +tasks: + .note: x +`, + wantErrors: `linter errors: + - applications.app.crons..note: Invalid type. Expected: object, given: string + - tasks..note: Invalid type. Expected: object, given: string`, + }, + { + name: "integer max_request_size and dynamic_compression", + content: ` +applications: + app: + type: php:8.4 + web: + locations: + /: + root: public + dynamic_compression: true + request_buffering: {enabled: true, max_request_size: 5242880} + rules: + '\.css$': {dynamic_compression: false} +`, + }, + { + name: "no applications", + content: ` +routes: + "https://{default}/": + type: redirect + to: https://example.com/ +`, + }, + { + name: "custom container profile", + content: ` +applications: + app: + type: php:8.4 + container_profile: CUSTOM + relationships: {db: "db:mysql"} +services: + db: + type: mariadb:11.4 + container_profile: CUSTOM +`, }, } diff --git a/internal/lint/merge.go b/internal/lint/merge.go index a4edcec03..f5932dd76 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -74,6 +74,9 @@ func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources source for _, entry := range mappingEntries(section) { nameNode, valueNode := entry.key, entry.value name := nameNode.Value + if strings.HasPrefix(name, ".") && key != keyTasks { + continue + } if prev, exists := sources[key+"."+name]; exists { return "", nil, &sourceError{file: file, line: nameNode.Line, msg: fmt.Sprintf( "duplicate key '%s' in section '%s' (already defined in %s)", name, key, prev.file)} diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go index 06aacce7e..b56f119f7 100644 --- a/internal/lint/merge_test.go +++ b/internal/lint/merge_test.go @@ -109,6 +109,22 @@ func TestMergeConfigFiles_DuplicateKey(t *testing.T) { require.Contains(t, err.Error(), "b.yml:2: duplicate key 'foo' in section 'applications' (already defined in a.yaml)") } +func TestMergeConfigFiles_DotPrefixedNames(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte(`applications: + .base: &base {type: go} + foo: *base`)}, + "b.yml": &fstest.MapFile{Data: []byte(`applications: + .base: {type: node} +routes: + .note: x`)}, + } + merged, sources, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) + require.NoError(t, err) + assert.Equal(t, "applications:\n foo:\n type: go\n", merged) + assert.Len(t, sources, 1) +} + func TestGetMergedConfigFiles_Success(t *testing.T) { fsys := fstest.MapFS{ ".upsun/a.yaml": &fstest.MapFile{Data: []byte(`applications: diff --git a/internal/lint/schema/schema_test.go b/internal/lint/schema/schema_test.go index 7e3bfb62f..98a2b837f 100644 --- a/internal/lint/schema/schema_test.go +++ b/internal/lint/schema/schema_test.go @@ -64,3 +64,32 @@ func TestSchemaDefaults(t *testing.T) { } walk(root, "") } + +// TestSchemaKeywords checks that no keyword is beside a $ref, and that mappings +// with fixed keys allow keys starting with ".", which the platform ignores (e.g. +// YAML anchors), except in the stack, which the platform checks itself. +func TestSchemaKeywords(t *testing.T) { + var root map[string]any + require.NoError(t, json.Unmarshal(configSchema, &root)) + + var walk func(node any, path string) + walk = func(node any, path string) { + switch n := node.(type) { + case map[string]any: + if _, ok := n["$ref"]; ok { + assert.Len(t, n, 1, "%s: draft-07 ignores keywords beside $ref", path) + } + if n["additionalProperties"] == false && path != ".definitions.stack" { + assert.Equal(t, map[string]any{"^\\.": map[string]any{}}, n["patternProperties"], path) + } + for k, v := range n { + walk(v, path+"."+k) + } + case []any: + for i, v := range n { + walk(v, fmt.Sprintf("%s.%d", path, i)) + } + } + } + walk(root, "") +} diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index a0fbf5908..eeaa1642a 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -20,7 +20,10 @@ "type": "object", "properties": { "root": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The root of the application relative to the repository root", "description": " \nDefaults to the root project directory. \nUseful for multi-app setups: \nhttps://docs.upsun.com/anchors/app/multiple/" }, @@ -30,7 +33,10 @@ "type": "object", "properties": { "command": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Operations that can be applied to the source code", "description": "More information: \nhttps://docs.upsun.com/anchors/app/source-operations/" } @@ -38,18 +44,31 @@ "required": [ "command" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Operations that can be applied to the source code", "default": {} } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "Information on the app’s source code and operations that can be run on it", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/source/operations/" }, "resources": { - "$ref": "#/definitions/deprecated/resources" + "anyOf": [ + { + "$ref": "#/definitions/deprecated/resources" + }, + { + "type": "null" + } + ] }, "relationships": { "$ref": "#/definitions/relationships" @@ -63,7 +82,10 @@ "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/", "properties": { "variables": { - "type": "object", + "type": [ + "object", + "null" + ], "additionalProperties": { "type": "object", "additionalProperties": {} @@ -72,20 +94,40 @@ "description": "Overrides the application's value for this container." }, "timezone": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The timezone of the application", "description": "Overrides the application's value for this container." }, "mounts": { - "$ref": "#/definitions/mounts", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/mounts" + }, + { + "type": "null" + } + ] }, "relationships": { - "$ref": "#/definitions/relationships", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/relationships" + }, + { + "type": "null" + } + ] }, "access": { - "type": "object", + "type": [ + "object", + "null" + ], "additionalProperties": { "type": "string" }, @@ -93,12 +135,26 @@ "description": "Overrides the application's value for this container." }, "size": { - "$ref": "#/definitions/deprecated/size", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/deprecated/size" + }, + { + "type": "null" + } + ] }, "resources": { - "$ref": "#/definitions/deprecated/resources", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/deprecated/resources" + }, + { + "type": "null" + } + ] }, "locations": { "type": "object", @@ -106,7 +162,10 @@ "type": "object", "properties": { "root": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The folder from which to serve static assets for this location relative to the application root", "description": "The directory to serve static assets for this location relative to the app’s root directory. Must be an actual directory inside the root directory. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/source/root/", "default": "public" @@ -123,20 +182,27 @@ "passthru": { "type": [ "string", - "boolean" + "boolean", + "null" ], "title": "Whether to forward disallowed and missing resources from this location to the app", "description": "Whether to forward disallowed and missing resources from this location to the application. On PHP, set to the PHP front controller script, as a URL fragment. Otherwise set to `true`/`false`.", "default": false }, "scripts": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether to allow scripts to run", "description": "Doesn't apply to paths specified in `passthru`. Meaningful only on PHP containers.", "default": true }, "index": { - "type": "array", + "type": [ + "array", + "null" + ], "items": { "type": "string" }, @@ -165,7 +231,8 @@ "expires": { "type": [ "integer", - "string" + "string", + "null" ], "title": "Amount of time to cache static assets", "description": "How long static assets are cached. The default means no caching. Setting it to a value enables the `Cache-Control` and `Expires` headers. Times can be suffixed with `ms` = milliseconds, `s` = seconds, `m` = minutes, `h` = hours, `d` = days, `w` = weeks, `M` = months/30d, or `y` = years/365d. If a `Cache-Control` appears on the `headers` configuration, `expires`, if set, will be ignored. Thus, make sure to set the `Cache-Control`’s `max-age` value when specifying a the header.", @@ -174,20 +241,27 @@ "passthru": { "type": [ "string", - "boolean" + "boolean", + "null" ], "title": "Whether to forward disallowed and missing resources from this location to the app", "description": "On PHP, set to the PHP front controller script, as a URL fragment. Otherwise set to `true`/`false`.", "default": true }, "scripts": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether to allow scripts to run", "description": "Doesn't apply to paths specified in `passthru`. Meaningful only on PHP containers.", "default": true }, "allow": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether to allow serving files which don’t match a rule", "default": true }, @@ -198,9 +272,20 @@ }, "title": "A set of header fields set to the HTTP response", "description": "Any additional headers to apply to static assets, mapping header names to values. Responses from the app aren’t affected. \nSee how to set custom headers on static content: \nhttps://docs.upsun.com/anchors/app/web/custom-headers/" + }, + "dynamic_compression": { + "type": [ + "boolean", + "null" + ], + "title": "Whether to compress dynamic responses", + "description": "Compresses dynamic responses (gzip/brotli) at the container web server. Disabled by default; only enable when the application framework mitigates BREACH (e.g. CSRF-token masking)." } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Specific overrides for specific locations", "description": "Contains a rules dictionary. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/web/locations/rules/", @@ -215,18 +300,36 @@ "default": true }, "max_request_size": { - "type": "string", + "type": [ + "string", + "integer", + "null" + ], "title": "The maximum size request that can be buffered", - "description": "Supports K, M, and G suffixes. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/request-buffering/", + "description": "A size in bytes, or with a K, M or G suffix. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/request-buffering/", "default": "250m" } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "Configuration for supporting request buffering.", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/request-buffering/" + }, + "dynamic_compression": { + "type": [ + "boolean", + "null" + ], + "title": "Whether to compress dynamic responses", + "description": "Compresses dynamic responses (gzip/brotli) at the container web server. Disabled by default; only enable when the application framework mitigates BREACH (e.g. CSRF-token masking)." } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "The specification of the web locations served by this application", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/", @@ -236,22 +339,34 @@ "type": "object", "properties": { "pre_start": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Command run just prior to `start`", "description": "Which can be useful when you need to run per-instance actions." }, "start": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The command used to start the application", "description": "It will be restarted if it terminates. Do not use on PHP unless using a custom persistent process like React PHP or FrankenPHP. \nSee note: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/start/" }, "post_start": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "A command executed after the application is started", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/" } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "The command to launch your app", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/" }, @@ -265,11 +380,17 @@ "default": "tcp" }, "protocol": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Protocol" } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "Configuration on how the web server communicates with the application", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/upstream/" }, @@ -322,27 +443,62 @@ "deprecationMessage": "Deprecated" }, "authorizations": { - "$ref": "#/definitions/authorizations", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/authorizations" + }, + { + "type": "null" + } + ] }, "egress": { - "$ref": "#/definitions/egress", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/container_egress" + }, + { + "type": "null" + } + ] }, "container_profile": { - "$ref": "#/definitions/container_profile", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "allOf": [ + { + "$ref": "#/definitions/container_profile" + } + ] }, "additional_hosts": { - "$ref": "#/definitions/additional_hosts", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/additional_hosts" + }, + { + "type": "null" + } + ] }, "operations": { - "$ref": "#/definitions/operations", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/operations" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "default": { "locations": {} } @@ -353,7 +509,10 @@ "type": "object", "properties": { "variables": { - "type": "object", + "type": [ + "object", + "null" + ], "additionalProperties": { "type": "object", "additionalProperties": {} @@ -362,12 +521,18 @@ "description": "Overrides the application's value for this container." }, "timezone": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The timezone of the application", "description": "Overrides the application's value for this container." }, "mounts": { - "type": "object", + "type": [ + "object", + "null" + ], "additionalProperties": { "type": "object", "properties": { @@ -390,7 +555,10 @@ "description": "Path relative to the root directory of the volume that's being mounted from. \nWARNING: Changing the name of your mount affects the source_path when it’s undefined. See how to ensure continuity and maintain access to your files \nhttps://docs.upsun.com/anchors/app/reference/mounts/change-name/" }, "service": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The name of the service that the volume will be mounted from", "description": "Must be a service in `services.yaml` of type `network-storage`." } @@ -398,17 +566,30 @@ "required": [ "source" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Filesystem mounts of this application", "description": "Overrides the application's value for this container." }, "relationships": { - "$ref": "#/definitions/relationships", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/relationships" + }, + { + "type": "null" + } + ] }, "access": { - "type": "object", + "type": [ + "object", + "null" + ], "additionalProperties": { "type": "string" }, @@ -416,22 +597,43 @@ "description": "Overrides the application's value for this container." }, "size": { - "$ref": "#/definitions/deprecated/size", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/deprecated/size" + }, + { + "type": "null" + } + ] }, "resources": { - "$ref": "#/definitions/deprecated/resources", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/deprecated/resources" + }, + { + "type": "null" + } + ] }, "container_profile": { - "$ref": "#/definitions/container_profile", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "allOf": [ + { + "$ref": "#/definitions/container_profile" + } + ] }, "commands": { "type": "object", "properties": { "pre_start": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The command used to run before starting the application", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" }, @@ -441,7 +643,10 @@ "description": "It will be restarted if it terminates. Do not use on PHP unless using a custom persistent process like React PHP. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" }, "post_start": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "A command executed after the application is started", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" } @@ -450,30 +655,64 @@ "start" ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "The commands to manage the worker", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" }, "authorizations": { - "$ref": "#/definitions/authorizations", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/authorizations" + }, + { + "type": "null" + } + ] }, "egress": { - "$ref": "#/definitions/worker_egress", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/container_egress" + }, + { + "type": "null" + } + ] }, "additional_hosts": { - "$ref": "#/definitions/additional_hosts", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/additional_hosts" + }, + { + "type": "null" + } + ] }, "operations": { - "$ref": "#/definitions/operations", - "description": "Overrides the application's value for this container." + "description": "Overrides the application's value for this container.", + "anyOf": [ + { + "$ref": "#/definitions/operations" + }, + { + "type": "null" + } + ] } }, "required": [ "commands" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Alternate copies of the application to run as background processes", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", @@ -504,11 +743,17 @@ "type": "object", "properties": { "flavor": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "The pre-set build tasks to use for this application" } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "The build configuration of the application", "description": "It contains a build dictionary. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/build/" }, @@ -525,22 +770,34 @@ "type": "object", "properties": { "build": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Hook executed after the build process", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/" }, "deploy": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Hook executed after the deployment of new code", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/" }, "post_deploy": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Hook executed after an environment is fully deployed", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/" } }, "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "What commands run at different stages in the `build`, `deploy` and `post_deploy` process", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/", "default": {} @@ -564,7 +821,10 @@ "description": "By default, the command is run in Dash (till you specify something else): \nhttps://en.wikipedia.org/wiki/Almquist_shell" }, "stop": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The command used to stop the cron job", "description": "The command that’s issued to give the cron command a chance to shutdown gracefully, such as to finish an active item in a list of tasks. Issued when a cron task is interrupted by a user through the CLI or Console. If not specified, a `SIGTERM` signal is sent to the process." } @@ -573,11 +833,17 @@ "start" ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "A definition of what commands to run when starting and stopping the cron job", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/crons/commands/" }, "shutdown_timeout": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "The timeout in seconds after which the cron job will be forcefully killed", "description": "When a cron is canceled, this represents the number of seconds after which a `SIGKILL` signal is sent to the process to force terminate it. The default is `10` seconds.", "default": 10 @@ -598,7 +864,10 @@ "required": [ "spec" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Scheduled cron tasks executed by this application", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/crons/", @@ -610,57 +879,87 @@ "description": "Customizations to your PHP or Lisp runtime. \nContains a runtime dictionary: \nhttps://docs.upsun.com/anchors/app/reference/runtime/", "properties": { "extensions": { - "type": "array", + "type": [ + "array", + "null" + ], "title": "PHP extensions to enable", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", "default": [] }, "disabled_extensions": { - "type": "array", + "type": [ + "array", + "null" + ], "title": "PHP extensions to disable", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", "default": [] }, "request_terminate_timeout": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "PHP timeout to terminate requests", "description": "The timeout in seconds for serving a single request after which the PHP-FPM worker process is killed." }, "sizing_hints": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "A sizing hints definition", "description": "The assumptions for setting the number of workers in your PHP-FPM runtime. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/runtime/sizing-hints/", "properties": { "request_memory": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "The average memory consumed per request in MB", "description": "Minimum to 10", "default": 45, "minimum": 10 }, "reserved_memory": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "The amount of memory reserved in MB", "description": "Minimum to 70", "default": 70, "minimum": 70 } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "xdebug": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "An Xdebug definition", "description": "The setting to turn on Xdebug. \nMore information: \nhttps://docs.upsun.com/anchors/languages/php/xdebug/", "properties": { "idekey": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Your Xdebug key" } } }, "quicklisp": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "Distributions for QuickLisp to use", "description": "More information: \nhttps://docs.upsun.com/anchors/languages/lisp/", "deprecationMessage": "Lisp image no longer exists, please see \nhttps://devcenter.upsun.com/posts/deploying-with-lisp/ \n for more information" @@ -674,7 +973,10 @@ "$ref": "#/definitions/additional_hosts" }, "timezone": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The timezone of the application", "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified. \nSee also: \nhttps://docs.upsun.com/anchors/app/timezone/" }, @@ -699,6 +1001,9 @@ "enabled" ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "description": "Configuration for pre-flight checks", "default": { "enabled": true, @@ -712,7 +1017,14 @@ "$ref": "#/definitions/authorizations" }, "egress": { - "$ref": "#/definitions/egress" + "anyOf": [ + { + "$ref": "#/definitions/egress" + }, + { + "type": "null" + } + ] }, "image": { "$ref": "#/definitions/image" @@ -732,6 +1044,9 @@ } ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "x-order": [ "type", "stack", @@ -754,6 +1069,9 @@ "preflight", "operations" ] + }, + "patternProperties": { + "^\\.": {} } }, "routes": { @@ -771,10 +1089,24 @@ "description": "More information: \nhttps://docs.upsun.com/anchors/routes/identifiers/", "properties": { "primary": { - "$ref": "#/definitions/routes/primary" + "anyOf": [ + { + "$ref": "#/definitions/routes/primary" + }, + { + "type": "null" + } + ] }, "id": { - "$ref": "#/definitions/routes/id" + "anyOf": [ + { + "$ref": "#/definitions/routes/id" + }, + { + "type": "null" + } + ] }, "attributes": { "$ref": "#/definitions/routes/attributes" @@ -796,16 +1128,33 @@ "type", "to" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, { "type": "object", "properties": { "primary": { - "$ref": "#/definitions/routes/primary" + "anyOf": [ + { + "$ref": "#/definitions/routes/primary" + }, + { + "type": "null" + } + ] }, "id": { - "$ref": "#/definitions/routes/id" + "anyOf": [ + { + "$ref": "#/definitions/routes/id" + }, + { + "type": "null" + } + ] }, "attributes": { "$ref": "#/definitions/routes/attributes" @@ -833,9 +1182,15 @@ "type", "upstream" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } ] + }, + "patternProperties": { + "^\\.": {} } }, "services": { @@ -872,7 +1227,14 @@ "$ref": "#/definitions/relationships" }, "resources": { - "$ref": "#/definitions/deprecated/resources" + "anyOf": [ + { + "$ref": "#/definitions/deprecated/resources" + }, + { + "type": "null" + } + ] }, "container_profile": { "$ref": "#/definitions/container_profile" @@ -881,7 +1243,13 @@ "required": [ "type" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } + }, + "patternProperties": { + "^\\.": {} } }, "tasks": { @@ -923,7 +1291,14 @@ "default": {} }, "egress": { - "$ref": "#/definitions/egress" + "anyOf": [ + { + "$ref": "#/definitions/egress" + }, + { + "type": "null" + } + ] }, "hooks": { "type": "object", @@ -943,7 +1318,10 @@ "description": "Run before the task's run command." } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "image": { "$ref": "#/definitions/image" @@ -955,7 +1333,14 @@ "$ref": "#/definitions/relationships" }, "resources": { - "$ref": "#/definitions/deprecated/resources" + "anyOf": [ + { + "$ref": "#/definitions/deprecated/resources" + }, + { + "type": "null" + } + ] }, "run": { "type": "object", @@ -975,7 +1360,10 @@ "required": [ "command" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "runtime": { "type": "object", @@ -983,57 +1371,87 @@ "description": "Customizations to your PHP or Lisp runtime. \nContains a runtime dictionary: \nhttps://docs.upsun.com/anchors/app/reference/runtime/", "properties": { "extensions": { - "type": "array", + "type": [ + "array", + "null" + ], "title": "PHP extensions to enable", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", "default": [] }, "disabled_extensions": { - "type": "array", + "type": [ + "array", + "null" + ], "title": "PHP extensions to disable", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", "default": [] }, "request_terminate_timeout": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "PHP timeout to terminate requests", "description": "The timeout in seconds for serving a single request after which the PHP-FPM worker process is killed." }, "sizing_hints": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "A sizing hints definition", "description": "The assumptions for setting the number of workers in your PHP-FPM runtime. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/runtime/sizing-hints/", "properties": { "request_memory": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "The average memory consumed per request in MB", "description": "Minimum to 10", "default": 45, "minimum": 10 }, "reserved_memory": { - "type": "integer", + "type": [ + "integer", + "null" + ], "title": "The amount of memory reserved in MB", "description": "Minimum to 70", "default": 70, "minimum": 70 } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "xdebug": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "An Xdebug definition", "description": "The setting to turn on Xdebug. \nMore information: \nhttps://docs.upsun.com/anchors/languages/php/xdebug/", "properties": { "idekey": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "Your Xdebug key" } } }, "quicklisp": { - "type": "object", + "type": [ + "object", + "null" + ], "title": "Distributions for QuickLisp to use", "description": "More information: \nhttps://docs.upsun.com/anchors/languages/lisp/", "deprecationMessage": "Lisp image no longer exists, please see \nhttps://devcenter.upsun.com/posts/deploying-with-lisp/ \n for more information" @@ -1048,13 +1466,19 @@ "description": "The subdirectory containing the task's code. Defaults to `/`." } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "stack": { "$ref": "#/definitions/stack" }, "timezone": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The timezone of the application", "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified. \nSee also: \nhttps://docs.upsun.com/anchors/app/timezone/" }, @@ -1074,13 +1498,13 @@ "default": {} } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } } }, - "required": [ - "applications" - ], "additionalProperties": false, "definitions": { "container_profile": { @@ -1089,20 +1513,12 @@ "null" ], "title": "The container profile for this application/service in production", - "description": "Leave blank to allow it to be set dynamically. \nMore information: \nhttps://docs.upsun.com/anchors/resources/manage/configuration/profiles/adjust/", - "anyOf": [ - { - "type": "string", - "enum": [ - "HIGH_CPU", - "BALANCED", - "HIGH_MEMORY", - "HIGHER_MEMORY" - ] - }, - { - "type": "null" - } + "description": "A profile defined in the project. Leave blank to allow it to be set dynamically. \nMore information: \nhttps://docs.upsun.com/anchors/resources/manage/configuration/profiles/adjust/", + "examples": [ + "BALANCED", + "HIGH_CPU", + "HIGH_MEMORY", + "HIGHER_MEMORY" ] }, "routes": { @@ -1173,12 +1589,18 @@ "description": "A relative URL - '/destination', \nor absolute URL - 'https://example.com/'. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/" }, "prefix": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Prefix of the redirect path", "description": "Specifies whether both the path and all its children or just the path itself should be redirected. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/affix/" }, "append_suffix": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Suffix of the redirect path", "description": "Determines if the suffix is carried over with the redirect. More information. \nhttps://docs.upsun.com/anchors/routes/redirects/affix/" }, @@ -1191,7 +1613,8 @@ "expires": { "type": [ "integer", - "string" + "string", + "null" ], "title": "The amount of time, in seconds, to cache the redirects", "description": "The duration the redirect is cached for. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/caching/manage/" @@ -1200,14 +1623,20 @@ "required": [ "to" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } } }, "required": [ "paths" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "tls": { "type": "object", @@ -1220,24 +1649,36 @@ "description": "https://docs.upsun.com/anchors/routes/https/tls/", "properties": { "enabled": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether strict transport security is enabled or not", "description": "If set to true, HSTS is enabled for 1 year. \nIf set to false, other properties are ignored." }, "include_subdomains": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether the strict transport security policy should include all subdomains", "description": "More information: \nhttps://docs.upsun.com/anchors/routes/https/tls/hsts/", "default": false }, "preload": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "title": "Whether the strict transport security policy should be preloaded in browsers", "description": "To add your website to the HSTS preload list: \nhttps://hstspreload.org/. \nThanks to this list, most browsers are informed that your site requires HSTS before an HSTS header response is even issued.\n", "default": false } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "min_version": { "type": [ @@ -1278,7 +1719,10 @@ "default": [] } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "to": { "type": "string", @@ -1323,6 +1767,9 @@ "enabled" ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "Cache configuration", "default": { "default_ttl": 0, @@ -1349,6 +1796,9 @@ "enabled" ], "additionalProperties": false, + "patternProperties": { + "^\\.": {} + }, "title": "Server-Side Include configuration.", "description": "More information: \nhttps://docs.upsun.com/anchors/routes/configuration/", "default": { @@ -1378,7 +1828,10 @@ "type": "string" }, "endpoint": { - "type": "string" + "type": [ + "string", + "null" + ] } } } @@ -1411,7 +1864,10 @@ "description": "WARNING: Changing the name of your mount affects the source_path when it’s undefined. See how to ensure continuity and maintain access to your files \nhttps://docs.upsun.com/anchors/app/reference/mounts/change-name/" }, "service": { - "type": "string", + "type": [ + "string", + "null" + ], "title": "The name of the service that the volume will be mounted from", "description": " Must be a service in `services.yaml` of type `network-storage`. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/mounts/" } @@ -1419,7 +1875,10 @@ "required": [ "source" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Filesystem mounts of this application", "description": "Directories that are writable even after the app is built. Allocated disk for mounts is defined with a separate resource configuration call using `upsun resources:set`. \nContains a dictionary of mounts: \nhttps://docs.upsun.com/anchors/app/reference/mounts/" @@ -1448,7 +1907,9 @@ } }, "additionalProperties": false, - "nullable": true, + "patternProperties": { + "^\\.": {} + }, "title": "Resources", "deprecationMessage": "Deprecated" } @@ -1485,7 +1946,10 @@ "type", "action" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } }, "egress": { @@ -1494,13 +1958,30 @@ "description": "Routes outbound traffic through the egress proxy, allowing only the listed domains, per phase (`build` or `runtime`).", "properties": { "build": { - "$ref": "#/definitions/egress_phase" + "anyOf": [ + { + "$ref": "#/definitions/egress_phase" + }, + { + "type": "null" + } + ] }, "runtime": { - "$ref": "#/definitions/egress_phase" + "anyOf": [ + { + "$ref": "#/definitions/egress_phase" + }, + { + "type": "null" + } + ] } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "image": { "type": "object", @@ -1511,10 +1992,16 @@ "type": "string" }, "buildfile": { - "type": "string" + "type": [ + "string", + "null" + ] } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "stack": { "type": [ @@ -1606,7 +2093,10 @@ "description": "It will be restarted if it terminates. Do not use on PHP unless using a custom persistent process like React PHP." }, "stop": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "The command used to stop the operation." } }, @@ -1625,7 +2115,10 @@ ] }, "timeout": { - "type": "integer", + "type": [ + "integer", + "null" + ], "minimum": 1, "maximum": 86400, "description": "The maximum duration in seconds." @@ -1634,7 +2127,10 @@ "required": [ "commands" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, "title": "Runtime operations that can be executed in the application container", "description": "More information: \nhttps://docs.upsun.com/anchors/app/runtime-operations/" @@ -1653,7 +2149,10 @@ "minLength": 1 }, "port": { - "type": "integer", + "type": [ + "integer", + "null" + ], "minimum": 1, "maximum": 65535 } @@ -1661,22 +2160,38 @@ "required": [ "host" ], - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } }, - "worker_egress": { + "container_egress": { "type": "object", "title": "Egress proxy allowlists", "description": "Routes outbound traffic through the egress proxy at runtime, allowing only the listed domains. Build-phase egress is set on the application.", "properties": { "runtime": { - "$ref": "#/definitions/egress_phase" + "anyOf": [ + { + "$ref": "#/definitions/egress_phase" + }, + { + "type": "null" + } + ] } }, - "additionalProperties": false + "additionalProperties": false, + "patternProperties": { + "^\\.": {} + } } }, "patternProperties": { diff --git a/internal/lint/web.go b/internal/lint/web.go index b81f46d7b..99c27e440 100644 --- a/internal/lint/web.go +++ b/internal/lint/web.go @@ -38,11 +38,6 @@ func CheckWebConfig(cfg *Config) *Result { for appName := range cfg.Applications { app := cfg.Applications[appName] - // The platform accepts this override but builds with the application's egress. - if app.Web.Egress.Build != nil { - result.AddWarning("applications."+appName+".web.egress.build", - "build-phase egress has no effect on web: set it on the application") - } for locName, loc := range app.Web.Locations { // Validate location key format. locKeyPath := "applications." + appName + ".web.locations" diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index 5eada4d67..a561f4aeb 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -29,7 +29,11 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re result.AddError(pathPrefix, err.Error()) return result } - for _, e := range schemaResult.Errors() { + errs := schemaResult.Errors() + for _, e := range errs { + if e.Type() == "number_any_of" && hasDetailedError(errs, e) { + continue + } field := e.Field() if field == "(root)" { field = "" @@ -49,6 +53,21 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re return result } +// hasDetailedError reports whether another error is at or below the field of an +// anyOf error, which gojsonschema adds after the errors of the closest branch. +func hasDetailedError(errs []gojsonschema.ResultError, anyOf gojsonschema.ResultError) bool { + field := anyOf.Field() + for _, e := range errs { + if e == anyOf { + continue + } + if f := e.Field(); f == field || field == "(root)" || strings.HasPrefix(f, field+".") { + return true + } + } + return false +} + func interpretYAMLError(err error) string { msg := err.Error() if strings.Contains(msg, "unknown escape character") { From ab37e8e034df60bd18b97c306fe862f3744ed9ba Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 15:55:50 +0100 Subject: [PATCH 2/3] docs(lint): note that task fields include dot-prefixed keys Co-Authored-By: Claude Opus 5.5 --- internal/lint/config_schema.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index d30db47ca..7a37fbe16 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -101,6 +101,7 @@ type Task struct { Authorizations []Authorization `yaml:"authorizations,omitempty"` // fields lists the keys set on the task, to check that "base" is used alone. + // As on the platform, this includes keys starting with ".". fields []string } From 1b29a1f9b59147df86e9883bdc429dc1f170fd59 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 15 Oct 2026 16:05:16 +0100 Subject: [PATCH 3/3] fix(lint): omit allOf summary errors when branch errors are reported The allOf wrappers added for $ref descriptions made gojsonschema add "Must validate all the schemas (allOf)" beside the real error, e.g. for an invalid container_profile override. Also correct the comment on the order of gojsonschema's errors. Co-Authored-By: Claude Opus 5.5 --- internal/lint/linter_test.go | 12 ++++++++++++ internal/lint/yaml.go | 10 +++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index f33049c4d..7aa60b320 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -529,6 +529,18 @@ applications: wantErrors: `linter errors: - applications.app.web.egress: Additional property build is not allowed`, }, + { + name: "invalid container profile override", + content: ` +applications: + app: + type: php:8.4 + web: + container_profile: 5 +`, + wantErrors: `linter errors: + - applications.app.web.container_profile: Invalid type. Expected: [string,null], given: integer`, + }, } for _, c := range cases { diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index a561f4aeb..148fd6d25 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -31,7 +31,7 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re } errs := schemaResult.Errors() for _, e := range errs { - if e.Type() == "number_any_of" && hasDetailedError(errs, e) { + if (e.Type() == "number_any_of" || e.Type() == "number_all_of") && hasDetailedError(errs, e) { continue } field := e.Field() @@ -54,11 +54,11 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re } // hasDetailedError reports whether another error is at or below the field of an -// anyOf error, which gojsonschema adds after the errors of the closest branch. -func hasDetailedError(errs []gojsonschema.ResultError, anyOf gojsonschema.ResultError) bool { - field := anyOf.Field() +// anyOf or allOf error, as gojsonschema also reports the errors of the branches. +func hasDetailedError(errs []gojsonschema.ResultError, summary gojsonschema.ResultError) bool { + field := summary.Field() for _, e := range errs { - if e == anyOf { + if e == summary { continue } if f := e.Field(); f == field || field == "(root)" || strings.HasPrefix(f, field+".") {