From d59cd2970992549776f69460381ab6a83f74b3ee Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 16 Oct 2026 17:12:34 +0100 Subject: [PATCH 1/4] feat(lint): use the schema from meta.upsun.com as the canonical Flex schema The Flex config schema now comes from https://meta.upsun.com/schema/upsun (the upsun/meta repository), which was synced with the CLI's copy in upsun/meta#220. Schema changes are made there first. - Add `make update-schema` (internal/lint/schema/gen.go), which fetches the schema and indents it, and `make check-schema`, run in the non-blocking upstream CI job beside `check-registry` (renamed `lint-upstream`). - Replace the schema's remote $refs (runtime-versions, service-versions) with empty schemas when loading, so lint does not need the network. Types are checked against the registry by CheckTypes. A test fails if the schema gains another remote $ref. - Report the schema's "maxItems: 0" error on a stack list with the same message as checkStackList. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++-- CLAUDE.md | 2 +- Makefile | 12 ++++- internal/lint/linter_test.go | 22 +++++++++ internal/lint/schema/gen.go | 49 +++++++++++++++++++ internal/lint/schema/schema.go | 28 +++++++++-- internal/lint/schema/schema_test.go | 15 ++++-- internal/lint/schema/upsun-config-schema.json | 26 +++++++++- internal/lint/types.go | 5 +- internal/lint/yaml.go | 4 ++ 10 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 internal/lint/schema/gen.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d97746f6c..24ba78f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,10 +50,10 @@ jobs: - name: Check goreleaser config run: make goreleaser-check - # Checks that the embedded image registry matches upstream. - # Fails when upstream publishes changes; run `make update-registry` to refresh. + # Checks that the embedded image registry and Flex config schema match upstream. + # Fails when upstream publishes changes; run `make update-registry` or `make update-schema` to refresh. # Non-blocking, since upstream changes are unrelated to the pull request. - image-registry: + lint-upstream: runs-on: ubuntu-latest continue-on-error: true @@ -69,6 +69,10 @@ jobs: - name: Check the embedded image registry is up to date run: make check-registry + - name: Check the embedded Flex config schema is up to date + if: ${{ !cancelled() }} + run: make check-schema + legacy-php: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 571b42e3d..dbb2923f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: **Commands**: `commands/` - `root.go`: Root command that sets up the Cobra CLI and delegates to legacy CLI when needed - Native Go commands: init, list, version, config:install, project:convert, completion, lint, and with Go auth (below): auth:browser-login (login), auth:api-token-login, auth:logout (logout), auth:token -- `lint.go`: Native config linter, `lint` (also `validate`; registered under the namespaced name `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once +- `lint.go`: Native config linter, `lint` (also `validate`; registered under the namespaced name `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once. The Flex JSON schema and the image registry come from meta.upsun.com (`make update-schema`, `make update-registry`): schema changes are made in the upsun/meta repository first - Unrecognized commands are passed to the legacy PHP CLI **Configuration**: `internal/config/` diff --git a/Makefile b/Makefile index abb1f10a5..2e6f803b9 100644 --- a/Makefile +++ b/Makefile @@ -234,7 +234,6 @@ lint-php: composer-dev ## Run PHP linters (php-cs-fixer, PHPStan) cd legacy && ./vendor/bin/phpstan analyse # The image registry, used by lint, is transformed from https://meta.upsun.com/images by gen.go. -# The schemas in internal/lint/schema are maintained in this repository. .PHONY: update-registry update-registry: ## Refresh the embedded image registry from meta.upsun.com cd internal/lint/registry && go run gen.go @@ -244,6 +243,17 @@ update-registry: ## Refresh the embedded image registry from meta.upsun.com check-registry: update-registry git diff --exit-code -- internal/lint/registry/registry.json +# The Flex config schema, used by lint, is from https://meta.upsun.com/schema/upsun (the upsun/meta repository). +# The Fixed config schemas in internal/lint/schema are maintained in this repository. +.PHONY: update-schema +update-schema: ## Refresh the embedded Flex config schema from meta.upsun.com + cd internal/lint/schema && go run gen.go + +# Fail if the embedded Flex config schema is stale. +.PHONY: check-schema +check-schema: update-schema + git diff --exit-code -- internal/lint/schema/upsun-config-schema.json + # Installs dev dependencies, which a phar build removes. PHPStan's binary marks that they are installed. .PHONY: composer-dev composer-dev: legacy/vendor/bin/phpstan diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 7aa60b320..2522ec5f4 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -407,6 +407,28 @@ applications: `, wantErrors: `linter errors: - applications.app.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, + }, + { + name: "task stack list with another schema error", + content: ` +applications: + app: + type: php:8.4 + crons: + report: + spec: "* * * * *" + commands: {start: "true"} + timeout: 100000 +tasks: + t: + type: composable:25.11 + stack: ["php@8.4"] + run: + command: "true" +`, + wantErrors: `linter errors: + - applications.app.crons.report.timeout: Must be less than or equal to 86400 + - tasks.t.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, }, { name: "invalid images", diff --git a/internal/lint/schema/gen.go b/internal/lint/schema/gen.go new file mode 100644 index 000000000..a6965a21a --- /dev/null +++ b/internal/lint/schema/gen.go @@ -0,0 +1,49 @@ +//go:build ignore + +// Command gen fetches the Upsun configuration schema from meta.upsun.com and +// writes it, indented, to upsun-config-schema.json. +// +// Run it with: go run gen.go (or `go generate ./internal/lint/schema`). +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +const schemaURL = "https://meta.upsun.com/schema/upsun" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run() error { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(schemaURL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %s from %s", resp.Status, schemaURL) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var out bytes.Buffer + if err := json.Indent(&out, body, "", " "); err != nil { + return err + } + out.WriteByte('\n') + return os.WriteFile("upsun-config-schema.json", out.Bytes(), 0o644) +} diff --git a/internal/lint/schema/schema.go b/internal/lint/schema/schema.go index 085a14e6e..9c91d6f43 100644 --- a/internal/lint/schema/schema.go +++ b/internal/lint/schema/schema.go @@ -7,10 +7,11 @@ import ( "github.com/xeipuuv/gojsonschema" ) +//go:generate go run gen.go + var ( - // upsun-config-schema.json is the self-contained Flex schema, originally from - // platformify and now maintained here. Type/version validation is done by - // CheckTypes against the registry, not by the schema. + // upsun-config-schema.json is the Flex schema served by meta.upsun.com, and + // updated with `make update-schema`. //go:embed upsun-config-schema.json configSchema []byte parsedSchema *gojsonschema.Schema @@ -18,10 +19,29 @@ var ( parseSchemaErr error ) +// remoteRefs are the schemas the Flex schema refers to on meta.upsun.com. They +// are replaced with empty schemas, so that loading does not need the network, +// as types and versions are checked by CheckTypes against the registry. +var remoteRefs = []string{ + "https://meta.upsun.com/schema/runtime-versions", + "https://meta.upsun.com/schema/service-versions", +} + // Load loads the Upsun (Flex-style) configuration schema. func Load() (*gojsonschema.Schema, error) { parseSchemaOnce.Do(func() { - parsedSchema, parseSchemaErr = gojsonschema.NewSchema(gojsonschema.NewBytesLoader(configSchema)) + parsedSchema, parseSchemaErr = compile(gojsonschema.NewBytesLoader(configSchema)) }) return parsedSchema, parseSchemaErr } + +// compile compiles a schema, with the remote references replaced. +func compile(loader gojsonschema.JSONLoader) (*gojsonschema.Schema, error) { + sl := gojsonschema.NewSchemaLoader() + for _, u := range remoteRefs { + if err := sl.AddSchema(u, gojsonschema.NewStringLoader("{}")); err != nil { + return nil, err + } + } + return sl.Compile(loader) +} diff --git a/internal/lint/schema/schema_test.go b/internal/lint/schema/schema_test.go index 98a2b837f..8d15968f2 100644 --- a/internal/lint/schema/schema_test.go +++ b/internal/lint/schema/schema_test.go @@ -3,6 +3,7 @@ package schema import ( "encoding/json" "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -44,7 +45,7 @@ func TestSchemaDefaults(t *testing.T) { } sub["definitions"] = root["definitions"] sub["$schema"] = root["$schema"] - s, err := gojsonschema.NewSchema(gojsonschema.NewGoLoader(sub)) + s, err := compile(gojsonschema.NewGoLoader(sub)) require.NoError(t, err, path) result, err := s.Validate(gojsonschema.NewGoLoader(def)) require.NoError(t, err, path) @@ -65,9 +66,10 @@ 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. +// TestSchemaKeywords checks that no keyword is beside a $ref, that remote refs +// are replaced, 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)) @@ -76,8 +78,11 @@ func TestSchemaKeywords(t *testing.T) { walk = func(node any, path string) { switch n := node.(type) { case map[string]any: - if _, ok := n["$ref"]; ok { + if ref, ok := n["$ref"].(string); ok { assert.Len(t, n, 1, "%s: draft-07 ignores keywords beside $ref", path) + if !strings.HasPrefix(ref, "#") { + assert.Contains(t, remoteRefs, ref, "%s: remote $ref must be in remoteRefs, or it is fetched", path) + } } if n["additionalProperties"] == false && path != ".definitions.stack" { assert.Equal(t, map[string]any{"^\\.": map[string]any{}}, n["patternProperties"], path) diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index eeaa1642a..5474bff68 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -10,6 +10,14 @@ "properties": { "type": { "type": "string", + "anyOf": [ + { + "$ref": "https://meta.upsun.com/schema/runtime-versions" + }, + { + "pattern": "^(composable|docker):" + } + ], "title": "The base runtime (language) and version to use for this application", "description": "The base image to use with a specific app language. \nFormat: runtime:version. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/type/" }, @@ -1205,6 +1213,11 @@ "properties": { "type": { "type": "string", + "allOf": [ + { + "$ref": "https://meta.upsun.com/schema/service-versions" + } + ], "title": "The service type", "description": "One of the available services in the format `type:version`. \nMore information: \nhttps://docs.upsun.com/anchors/services/available/" }, @@ -1484,6 +1497,14 @@ }, "type": { "type": "string", + "anyOf": [ + { + "$ref": "https://meta.upsun.com/schema/runtime-versions" + }, + { + "pattern": "^(composable|docker):" + } + ], "title": "The runtime image and version to use for this task", "description": "The base image to use with a specific app language. \nFormat: runtime:version. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/type/" }, @@ -2010,7 +2031,7 @@ "null" ], "title": "Composable image definition", - "description": "The runtimes and packages that make up the composable image. Each is a name, a mapping, or a list of them. A list is only accepted when empty (checked by the linter).", + "description": "The runtimes and packages that make up the composable image. Each is a name, a mapping, or a list of them. A list is only accepted when empty.", "properties": { "runtimes": { "anyOf": [ @@ -2069,7 +2090,8 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "maxItems": 0 }, "additional_hosts": { "type": "object", diff --git a/internal/lint/types.go b/internal/lint/types.go index 1ab4acd4e..18ba115eb 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -85,12 +85,15 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { return result } +// errStackList is the error for a non-empty stack list. +const errStackList = "'stack' must be a mapping of 'runtimes' and 'packages', not a list" + // checkStackList reports a stack written as a non-empty list, a format the // platform no longer accepts. It returns the stack for further checks: an // empty mapping in place of a reported list, so it is not reported again. func checkStackList(path string, stack any, result *Result) any { if l, ok := stack.([]any); ok && len(l) > 0 { - result.AddError(path, "'stack' must be a mapping of 'runtimes' and 'packages', not a list") + result.AddError(path, errStackList) return map[string]any{} } return stack diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index 148fd6d25..dcf7b5fde 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -40,6 +40,10 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re } // Some descriptions repeat the field, which is already in the path. msg := strings.TrimPrefix(e.Description(), field+" ") + // The schema rejects a non-empty stack list with "maxItems: 0". + if e.Type() == "array_max_items" && (field == "stack" || strings.HasSuffix(field, ".stack")) { + msg = errStackList + } switch { case pathPrefix == "": case field == "": From 6da1987e44d69ccb35ee5c73dd6d4de59471dd12 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 16 Oct 2026 17:18:14 +0100 Subject: [PATCH 2/4] fix(lint): leave stack lists to checkStackList, so semantic checks still run A schema error stops CheckContent before the semantic checks, so reporting the schema's "maxItems: 0" stack error hid every other error until the stack was fixed. The schema error is now skipped, and checkStackList reports the stack list with the other errors, as before. Co-Authored-By: Claude Opus 5.5 --- internal/lint/linter_test.go | 18 ++++-------------- internal/lint/types.go | 5 +---- internal/lint/yaml.go | 14 ++++++++++---- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 2522ec5f4..e20f219c1 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -409,26 +409,16 @@ applications: - applications.app.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, }, { - name: "task stack list with another schema error", + name: "stack list with a semantic error", content: ` applications: - app: - type: php:8.4 - crons: - report: - spec: "* * * * *" - commands: {start: "true"} - timeout: 100000 -tasks: - t: + Foo_: type: composable:25.11 stack: ["php@8.4"] - run: - command: "true" `, wantErrors: `linter errors: - - applications.app.crons.report.timeout: Must be less than or equal to 86400 - - tasks.t.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, + - applications.Foo_.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list + - applications.Foo_: "Foo_" is not a valid application name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll }, { name: "invalid images", diff --git a/internal/lint/types.go b/internal/lint/types.go index 18ba115eb..1ab4acd4e 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -85,15 +85,12 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { return result } -// errStackList is the error for a non-empty stack list. -const errStackList = "'stack' must be a mapping of 'runtimes' and 'packages', not a list" - // checkStackList reports a stack written as a non-empty list, a format the // platform no longer accepts. It returns the stack for further checks: an // empty mapping in place of a reported list, so it is not reported again. func checkStackList(path string, stack any, result *Result) any { if l, ok := stack.([]any); ok && len(l) > 0 { - result.AddError(path, errStackList) + result.AddError(path, "'stack' must be a mapping of 'runtimes' and 'packages', not a list") return map[string]any{} } return stack diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index dcf7b5fde..20fed8f7f 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -34,16 +34,17 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re if (e.Type() == "number_any_of" || e.Type() == "number_all_of") && hasDetailedError(errs, e) { continue } + // A non-empty stack list ("maxItems: 0" in the schema) is reported by + // checkStackList, so that the semantic checks still run. + if e.Type() == "array_max_items" && isStackField(e.Field()) { + continue + } field := e.Field() if field == "(root)" { field = "" } // Some descriptions repeat the field, which is already in the path. msg := strings.TrimPrefix(e.Description(), field+" ") - // The schema rejects a non-empty stack list with "maxItems: 0". - if e.Type() == "array_max_items" && (field == "stack" || strings.HasSuffix(field, ".stack")) { - msg = errStackList - } switch { case pathPrefix == "": case field == "": @@ -57,6 +58,11 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re return result } +// isStackField reports whether a schema error's field is a stack. +func isStackField(field string) bool { + return field == "stack" || strings.HasSuffix(field, ".stack") +} + // hasDetailedError reports whether another error is at or below the field of an // anyOf or allOf error, as gojsonschema also reports the errors of the branches. func hasDetailedError(errs []gojsonschema.ResultError, summary gojsonschema.ResultError) bool { From de7ce4fb30fccaceffb0fcd7892a92df208ce070 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 16 Oct 2026 17:22:23 +0100 Subject: [PATCH 3/4] refactor(lint): report Flex stack lists with the schema's own error Use the upstream schema's "maxItems: 0" error for a non-empty stack list, rather than skipping it for checkStackList, which now only reports stack lists in Fixed config. As with any schema error, the semantic checks run once it is fixed. Co-Authored-By: Claude Opus 5.5 --- internal/lint/linter_test.go | 14 +------------- internal/lint/yaml.go | 10 ---------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index e20f219c1..706011cc6 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -406,19 +406,7 @@ applications: stack: ["php@8.4"] `, wantErrors: `linter errors: - - applications.app.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, - }, - { - name: "stack list with a semantic error", - content: ` -applications: - Foo_: - type: composable:25.11 - stack: ["php@8.4"] -`, - wantErrors: `linter errors: - - applications.Foo_.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list - - applications.Foo_: "Foo_" is not a valid application name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll + - applications.app.stack: Array must have at most 0 items`, }, { name: "invalid images", diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index 20fed8f7f..148fd6d25 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -34,11 +34,6 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re if (e.Type() == "number_any_of" || e.Type() == "number_all_of") && hasDetailedError(errs, e) { continue } - // A non-empty stack list ("maxItems: 0" in the schema) is reported by - // checkStackList, so that the semantic checks still run. - if e.Type() == "array_max_items" && isStackField(e.Field()) { - continue - } field := e.Field() if field == "(root)" { field = "" @@ -58,11 +53,6 @@ func CheckSchemaAt(data any, schema *gojsonschema.Schema, pathPrefix string) *Re return result } -// isStackField reports whether a schema error's field is a stack. -func isStackField(field string) bool { - return field == "stack" || strings.HasSuffix(field, ".stack") -} - // hasDetailedError reports whether another error is at or below the field of an // anyOf or allOf error, as gojsonschema also reports the errors of the branches. func hasDetailedError(errs []gojsonschema.ResultError, summary gojsonschema.ResultError) bool { From 94547e0a753bc2b124a5511d499b3a6b70a28929 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 16 Oct 2026 17:39:05 +0100 Subject: [PATCH 4/4] feat(lint): check Fixed config with the Flex schema and an overlay The platform validates Fixed and Flex config with the same models, so Fixed config is now checked with the upstream Flex schema, patched by fixed-overlay.json (a JSON Merge Patch), instead of three schemas maintained here, which had fallen behind (e.g. post_start, dynamic_compression, runtime extensions). The overlay adds the keys that only Fixed config accepts: `disk` on applications, web, workers and services, an application `size` and `name` (required, as on the platform), and `build.caches`. Behavior changes for Fixed config, matching Flex and the platform: - `firewall` is rejected (deprecated in favor of `egress`). - `type` is required, also with a `stack`. - A non-empty stack list gets the schema's error, so checkStackList and the stack-without-type warning are removed. The schema tests now also check the patched schema. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- Makefile | 2 +- internal/lint/fixed_test.go | 55 +- internal/lint/linter_test.go | 19 + internal/lint/schema/fixed-overlay.json | 87 ++ internal/lint/schema/fixed.go | 94 +- .../lint/schema/platformsh.application.json | 1369 ----------------- internal/lint/schema/platformsh.routes.json | 401 ----- internal/lint/schema/platformsh.services.json | 118 -- internal/lint/schema/schema_test.go | 23 +- internal/lint/types.go | 25 +- internal/lint/types_test.go | 33 - 12 files changed, 262 insertions(+), 1966 deletions(-) create mode 100644 internal/lint/schema/fixed-overlay.json delete mode 100644 internal/lint/schema/platformsh.application.json delete mode 100644 internal/lint/schema/platformsh.routes.json delete mode 100644 internal/lint/schema/platformsh.services.json diff --git a/CLAUDE.md b/CLAUDE.md index dbb2923f5..9b5b2d58a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ The CLI operates as a wrapper around a legacy PHP CLI: **Commands**: `commands/` - `root.go`: Root command that sets up the Cobra CLI and delegates to legacy CLI when needed - Native Go commands: init, list, version, config:install, project:convert, completion, lint, and with Go auth (below): auth:browser-login (login), auth:api-token-login, auth:logout (logout), auth:token -- `lint.go`: Native config linter, `lint` (also `validate`; registered under the namespaced name `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once. The Flex JSON schema and the image registry come from meta.upsun.com (`make update-schema`, `make update-registry`): schema changes are made in the upsun/meta repository first +- `lint.go`: Native config linter, `lint` (also `validate`; registered under the namespaced name `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once. The Flex JSON schema and the image registry come from meta.upsun.com (`make update-schema`, `make update-registry`): schema changes are made in the upsun/meta repository first. Fixed config is checked with the same schema, patched by `internal/lint/schema/fixed-overlay.json` (a JSON Merge Patch adding Fixed-only keys such as `disk`) - Unrecognized commands are passed to the legacy PHP CLI **Configuration**: `internal/config/` diff --git a/Makefile b/Makefile index 2e6f803b9..37176a6dd 100644 --- a/Makefile +++ b/Makefile @@ -244,7 +244,7 @@ check-registry: update-registry git diff --exit-code -- internal/lint/registry/registry.json # The Flex config schema, used by lint, is from https://meta.upsun.com/schema/upsun (the upsun/meta repository). -# The Fixed config schemas in internal/lint/schema are maintained in this repository. +# Fixed config is checked with the same schema and an overlay, internal/lint/schema/fixed-overlay.json. .PHONY: update-schema update-schema: ## Refresh the embedded Flex config schema from meta.upsun.com cd internal/lint/schema && go run gen.go diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index 1d4b1afe4..c444fc750 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -31,6 +31,57 @@ relationships: }, wantNoErr: true, }, + { + name: "Fixed-only keys", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +size: L +disk: 2048 +build: + caches: + vendor: + directory: vendor + watch: [composer.lock] +web: + disk: 1024 +workers: + queue: + disk: 512 + commands: + start: php worker.php`, + ".platform/services.yaml": `db: + type: "postgresql:16" + disk: 1024`, + }, + wantNoErr: true, + }, + { + name: "firewall", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +firewall: + outbound: []`, + ".platform/services.yaml": `db: + type: "postgresql:16" + firewall: + outbound: []`, + }, + wantErrors: []string{ + ".platform.app.yaml:1: applications.myapp: Additional property firewall is not allowed", + ".platform/services.yaml:1: services.db: Additional property firewall is not allowed", + }, + }, + { + name: "invalid size", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +size: XXL`, + }, + wantErrors: []string{"applications.myapp.size: must be one of the following"}, + }, { name: "rule overriding passthru with a boolean", files: map[string]string{ @@ -164,7 +215,7 @@ stack: []`, type: "composable:25.11" stack: ["php@8.3"]`, }, - wantErrors: []string{"applications.myapp.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list"}, + wantErrors: []string{"applications.myapp.stack: Array must have at most 0 items"}, }, { name: "composable image with stack and no type", @@ -172,7 +223,7 @@ stack: ["php@8.3"]`, ".platform.app.yaml": `name: myapp stack: {runtimes: ["php@8.3"]}`, }, - wantNoErr: true, + wantErrors: []string{"applications.myapp: type is required"}, }, { name: "invalid type and bad upstream", diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 706011cc6..82854a096 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -407,6 +407,25 @@ applications: `, wantErrors: `linter errors: - applications.app.stack: Array must have at most 0 items`, + }, + { + name: "Fixed-only keys", + content: ` +applications: + app: + type: php:8.4 + disk: 1024 + firewall: + outbound: [] +services: + db: + type: postgresql:16 + disk: 1024 +`, + wantErrors: `linter errors: + - applications.app: Additional property disk is not allowed + - applications.app: Additional property firewall is not allowed + - services.db: Additional property disk is not allowed`, }, { name: "invalid images", diff --git a/internal/lint/schema/fixed-overlay.json b/internal/lint/schema/fixed-overlay.json new file mode 100644 index 000000000..4f6bf7b9f --- /dev/null +++ b/internal/lint/schema/fixed-overlay.json @@ -0,0 +1,87 @@ +{ + "definitions": { + "fixed": { + "disk": { + "type": ["integer", "null"], + "minimum": 0, + "title": "The writable disk size to reserve on this container, in MB" + }, + "size": { + "type": "string", + "enum": ["AUTO", "XS", "S", "M", "L", "XL", "2XL", "4XL"], + "title": "The container size for this application in production", + "description": "Leave blank to allow it to be set dynamically." + }, + "caches": { + "type": "object", + "title": "The configuration of paths managed by the build cache", + "additionalProperties": { + "type": "object", + "properties": { + "directory": { + "type": ["string", "null"], + "title": "The directory, relative to the application root, that should be cached" + }, + "watch": { + "type": "array", + "items": {"type": "string"}, + "title": "The file or files whose hashed contents should be considered part of the cache key" + }, + "allow_stale": { + "type": "boolean", + "title": "If true, on a cache miss the last cache version will be used and can be updated in place", + "default": false + }, + "share_between_apps": { + "type": "boolean", + "title": "Whether multiple applications in the project should share cached directories", + "default": false + } + }, + "required": ["watch"], + "patternProperties": {"^\\.": {}}, + "additionalProperties": false + } + } + } + }, + "properties": { + "applications": { + "additionalProperties": { + "required": ["name"], + "properties": { + "name": { + "type": "string", + "title": "The name of the application, which must be unique within the project" + }, + "disk": {"$ref": "#/definitions/fixed/disk"}, + "size": {"$ref": "#/definitions/fixed/size"}, + "build": { + "properties": { + "caches": {"$ref": "#/definitions/fixed/caches"} + } + }, + "web": { + "properties": { + "disk": {"$ref": "#/definitions/fixed/disk"} + } + }, + "workers": { + "additionalProperties": { + "properties": { + "disk": {"$ref": "#/definitions/fixed/disk"} + } + } + } + } + } + }, + "services": { + "additionalProperties": { + "properties": { + "disk": {"$ref": "#/definitions/fixed/disk"} + } + } + } + } +} diff --git a/internal/lint/schema/fixed.go b/internal/lint/schema/fixed.go index 7255458a7..f11489353 100644 --- a/internal/lint/schema/fixed.go +++ b/internal/lint/schema/fixed.go @@ -2,20 +2,20 @@ package schema import ( _ "embed" + "encoding/json" + "fmt" "sync" "github.com/xeipuuv/gojsonschema" ) -// Embedded Fixed-style (legacy Platform.sh) schemas, one per file type. -var ( - //go:embed platformsh.application.json - applicationSchema []byte - //go:embed platformsh.routes.json - routesSchema []byte - //go:embed platformsh.services.json - servicesSchema []byte +// fixed-overlay.json is a JSON Merge Patch (RFC 7386), applied to the Flex +// schema to add the keys that only Fixed-style (Platform.sh) config accepts. +// +//go:embed fixed-overlay.json +var fixedOverlay []byte +var ( fixedOnce sync.Once parsedApplication *gojsonschema.Schema parsedRoutes *gojsonschema.Schema @@ -23,17 +23,83 @@ var ( fixedErr error ) +// fixedSchema returns the Flex schema with the Fixed overlay applied. +func fixedSchema() (map[string]any, error) { + var root, patch map[string]any + if err := json.Unmarshal(configSchema, &root); err != nil { + return nil, err + } + if err := json.Unmarshal(fixedOverlay, &patch); err != nil { + return nil, err + } + mergePatch(root, patch) + return root, nil +} + +// mergePatch applies a JSON Merge Patch to target, modifying it. +func mergePatch(target, patch any) any { + p, ok := patch.(map[string]any) + if !ok { + return patch + } + t, ok := target.(map[string]any) + if !ok { + t = map[string]any{} + } + for k, v := range p { + if v == nil { + delete(t, k) + } else { + t[k] = mergePatch(t[k], v) + } + } + return t +} + +// subSchema returns the schema at path in root, as a standalone schema. +func subSchema(root map[string]any, path ...string) (map[string]any, error) { + node := root + for _, k := range path { + next, ok := node[k].(map[string]any) + if !ok { + return nil, fmt.Errorf("schema has no object at %v", path) + } + node = next + } + sub := make(map[string]any, len(node)+2) + for k, v := range node { + sub[k] = v + } + sub["$schema"] = root["$schema"] + sub["definitions"] = root["definitions"] + return sub, nil +} + func loadFixed() error { fixedOnce.Do(func() { - if parsedApplication, fixedErr = gojsonschema.NewSchema( - gojsonschema.NewBytesLoader(applicationSchema)); fixedErr != nil { + root, err := fixedSchema() + if err != nil { + fixedErr = err return } - if parsedRoutes, fixedErr = gojsonschema.NewSchema( - gojsonschema.NewBytesLoader(routesSchema)); fixedErr != nil { - return + for _, s := range []struct { + parsed **gojsonschema.Schema + path []string + }{ + {&parsedApplication, []string{"properties", "applications", "additionalProperties"}}, + {&parsedServices, []string{"properties", "services"}}, + {&parsedRoutes, []string{"properties", "routes"}}, + } { + sub, err := subSchema(root, s.path...) + if err != nil { + fixedErr = err + return + } + if *s.parsed, err = compile(gojsonschema.NewGoLoader(sub)); err != nil { + fixedErr = err + return + } } - parsedServices, fixedErr = gojsonschema.NewSchema(gojsonschema.NewBytesLoader(servicesSchema)) }) return fixedErr } diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json deleted file mode 100644 index 7645d400c..000000000 --- a/internal/lint/schema/platformsh.application.json +++ /dev/null @@ -1,1369 +0,0 @@ -{ - "title": "Platform.sh application configuration file", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "resources": { - "type": "object", - "properties": { - "base_memory": { - "type": "integer", - "title": "The base memory for the container", - "default": 64 - }, - "memory_ratio": { - "type": "integer", - "title": "The amount of memory to allocate per units of CPU", - "default": 128 - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Resources", - "default": null - }, - "size": { - "type": "string", - "title": "The container size for this application in production. Leave blank to allow it to be set dynamically.", - "default": "AUTO" - }, - "disk": { - "type": "integer", - "title": "The writeable disk size to reserve on this application container.", - "default": null - }, - "access": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Access information, a mapping between access type and roles.", - "default": { - "ssh": "contributor" - } - }, - "relationships": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - }, - { - "type": "object", - "properties": { - "service": { - "type": "string" - }, - "endpoint": { - "type": "string" - } - } - } - ] - }, - "title": "The relationships of the application to defined services.", - "default": {} - }, - "mounts": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "source": { - "type": "string", - "title": "The type of mount that will provide the data.", - "enum": [ - "local", - "service", - "tmp" - ] - }, - "source_path": { - "type": "string", - "title": "The path to be mounted, relative to the root directory of the volume that's being mounted from." - }, - "service": { - "type": "string", - "title": "The name of the service that the volume will be mounted from. Must be a service in `services.yaml` of type `network-storage`." - } - }, - "required": [ - "source" - ], - "additionalProperties": false - }, - "title": "Filesystem mounts of this application. If not specified the application will have no writeable disk space.", - "default": {} - }, - "timezone": { - "type": "string", - "title": "The timezone of the application. This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified.", - "default": null - }, - "variables": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {} - }, - "title": "Variables provide environment-sensitive information to control how your application behaves. To set a Unix environment variable, specify a key of `env:`, and then each sub-item of that is a key/value pair that will be injected into the environment.", - "default": {} - }, - "firewall": { - "type": "object", - "properties": { - "outbound": { - "type": "array", - "items": { - "type": "object", - "properties": { - "protocol": { - "type": "string", - "title": "The IP protocol to apply the restriction on.", - "default": "tcp" - }, - "ips": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The IP range in CIDR notation to apply the restriction on.", - "default": [] - }, - "domains": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Domains of the restriction.", - "default": [] - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "The port to apply the restriction on.", - "default": [] - } - }, - "additionalProperties": false - }, - "title": "Outbound firewall restrictions", - "default": [] - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Firewall", - "default": null - }, - "name": { - "type": "string", - "title": "The name of the application. Must be unique within a project." - }, - "type": { - "type": "string", - "title": "The base runtime (language) and version to use for this application." - }, - "runtime": { - "type": "object", - "title": "Runtime-specific configuration.", - "default": {} - }, - "preflight": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Whether the preflight security blocks are enabled." - }, - "ignored_rules": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Specific rules to ignore during preflight security checks. See the documentation for options.", - "default": [] - } - }, - "required": [ - "enabled" - ], - "additionalProperties": false, - "title": "Configuration for pre-flight checks.", - "default": { - "enabled": true, - "ignored_rules": [] - } - }, - "dependencies": { - "type": "object", - "additionalProperties": { - "type": "object" - }, - "title": "External global dependencies of this application. They will be downloaded by the language's package manager.", - "default": {} - }, - "build": { - "type": "object", - "properties": { - "flavor": { - "type": "string", - "title": "The pre-set build tasks to use for this application.", - "default": null - }, - "caches": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "directory": { - "type": "string", - "title": "The directory, relative to the application root, that should be cached.", - "default": null - }, - "watch": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The file or files whose hashed contents should be considered part of the cache key." - }, - "allow_stale": { - "type": "boolean", - "title": "If true, on a cache miss the last cache version will be used and can be updated in place.", - "default": false - }, - "share_between_apps": { - "type": "boolean", - "title": "Whether multiple applications in the project should share cached directories.", - "default": false - } - }, - "required": [ - "watch" - ], - "additionalProperties": false - }, - "title": "The configuration of paths managed by the build cache.", - "default": {} - } - }, - "additionalProperties": false, - "title": "The build configuration of the application.", - "default": { - "flavor": null, - "caches": {} - } - }, - "source": { - "type": "object", - "properties": { - "root": { - "type": "string", - "title": "The root of the application relative to the repository root.", - "default": null - }, - "operations": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "command": { - "type": "string", - "title": "The command to use to update this application." - } - }, - "required": [ - "command" - ], - "additionalProperties": false - }, - "title": "Operations that can be applied to the source code.", - "default": {} - } - }, - "additionalProperties": false, - "title": "Configuration related to the source code of the application.", - "default": { - "operations": {}, - "root": null - } - }, - "web": { - "type": "object", - "properties": { - "firewall": { - "type": "object", - "properties": { - "outbound": { - "type": "array", - "items": { - "type": "object", - "properties": { - "protocol": { - "type": "string", - "title": "The IP protocol to apply the restriction on.", - "default": "tcp" - }, - "ips": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The IP range in CIDR notation to apply the restriction on.", - "default": [] - }, - "domains": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Domains of the restriction.", - "default": [] - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "The port to apply the restriction on.", - "default": [] - } - }, - "additionalProperties": false - }, - "title": "Outbound firewall restrictions", - "default": [] - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Firewall" - }, - "variables": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {} - }, - "title": "Variables provide environment-sensitive information to control how your application behaves. To set a Unix environment variable, specify a key of `env:`, and then each sub-item of that is a key/value pair that will be injected into the environment." - }, - "timezone": { - "type": "string", - "title": "The timezone of the application. This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified." - }, - "mounts": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "source": { - "type": "string", - "title": "The type of mount that will provide the data.", - "enum": [ - "local", - "service", - "tmp" - ] - }, - "source_path": { - "type": "string", - "title": "The path to be mounted, relative to the root directory of the volume that's being mounted from." - }, - "service": { - "type": "string", - "title": "The name of the service that the volume will be mounted from. Must be a service in `services.yaml` of type `network-storage`." - } - }, - "required": [ - "source" - ], - "additionalProperties": false - }, - "title": "Filesystem mounts of this application. If not specified the application will have no writeable disk space." - }, - "relationships": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - }, - { - "type": "object", - "properties": { - "service": { - "type": "string" - }, - "endpoint": { - "type": "string" - } - } - } - ] - }, - "title": "The relationships of the application to defined services.", - "default": {} - }, - "access": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Access information, a mapping between access type and roles." - }, - "disk": { - "type": "integer", - "title": "The writeable disk size to reserve on this application container." - }, - "size": { - "type": "string", - "title": "The container size for this application in production. Leave blank to allow it to be set dynamically." - }, - "resources": { - "type": "object", - "properties": { - "base_memory": { - "type": "integer", - "title": "The base memory for the container", - "default": 64 - }, - "memory_ratio": { - "type": "integer", - "title": "The amount of memory to allocate per units of CPU", - "default": 128 - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Resources" - }, - "locations": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "root": { - "type": "string", - "title": "The folder from which to serve static assets for this location relative to the application root.", - "default": null - }, - "expires": { - "type": [ - "integer", - "string" - ], - "title": "Amount of time to cache static assets.", - "default": -1 - }, - "passthru": { - "type": [ - "string", - "boolean" - ], - "title": "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": true - }, - "scripts": { - "type": "boolean", - "title": "Whether to execute scripts in this location (for script based runtimes).", - "default": true - }, - "index": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Files to look for to serve directories." - }, - "allow": { - "type": "boolean", - "title": "Whether to allow access to this location by default.", - "default": true - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "A set of header fields set to the HTTP response. Applies only to static files, not responses from the application.", - "default": {} - }, - "rules": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "expires": { - "type": [ - "integer", - "string" - ], - "title": "Amount of time to cache static assets." - }, - "passthru": { - "type": [ - "string", - "boolean" - ], - "title": "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`." - }, - "scripts": { - "type": "boolean", - "title": "Whether to execute scripts in this location (for script based runtimes)." - }, - "allow": { - "type": "boolean", - "title": "Whether to allow access to this location by default." - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "A set of header fields set to the HTTP response. Replaces headers set on the location block." - } - }, - "additionalProperties": false - }, - "title": "Specific overrides.", - "default": {} - }, - "request_buffering": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Enable request buffering.", - "default": true - }, - "max_request_size": { - "type": "string", - "title": "The maximum size request that can be buffered. Supports K, M, and G suffixes.", - "default": 262144000 - } - }, - "additionalProperties": false, - "title": "Configuration for supporting request buffering." - } - }, - "additionalProperties": false - }, - "title": "The specification of the web locations served by this application.", - "default": {} - }, - "commands": { - "type": "object", - "properties": { - "pre_start": { - "type": "string", - "title": "The command used to run before starting the application." - }, - "start": { - "type": "string", - "title": "The command used to start the application. It will be restarted if it terminates. Do not use on PHP unless using a custom persistent process like React PHP." - }, - "post_start": { - "type": "string", - "title": "A command executed after the application is started." - } - }, - "required": [ - "start" - ], - "additionalProperties": false, - "title": "Commands to manage the application's lifecycle." - }, - "upstream": { - "type": "object", - "properties": { - "socket_family": { - "type": "string", - "title": "If `tcp`, check the PORT environment variable on application startup. If `unix`, check SOCKET.", - "default": "tcp" - }, - "protocol": { - "type": "string", - "title": "Protocol", - "default": null - } - }, - "additionalProperties": false, - "title": "Configuration on how the web server communicates with the application." - }, - "document_root": { - "type": "string", - "title": "The document root of this application, relative to its root." - }, - "passthru": { - "type": "string", - "title": "The URL to use as a passthru if a file doesn't match the whitelist." - }, - "index_files": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Files to look for to serve directories." - }, - "whitelist": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Whitelisted entries." - }, - "blacklist": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Blacklisted entries." - }, - "expires": { - "type": [ - "integer", - "string" - ], - "title": "Amount of time to cache static assets." - }, - "move_to_root": { - "type": "boolean", - "title": "Whether to move the whole root of the app to the document root.", - "default": false - }, - "authorizations": { - "type": "array", - "title": "Workload authorizations", - "description": "Grants the container short-lived, narrowly-scoped access to the Upsun API at runtime.\nMore information:\nhttps://developer.upsun.com/docs/configure-apps/image-properties/authorizations", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "env", - "task" - ], - "description": "The type of resource to access: `task` (a task defined in the project) or `env` (the environment API)." - }, - "action": { - "type": "string", - "enum": [ - "operate", - "view" - ], - "description": "`operate` allows triggering and managing a task; `view` grants read-only access to the environment API." - }, - "resource": { - "type": "string", - "description": "The name of the task. Required when `type` is `task`." - } - }, - "required": [ - "type", - "action" - ], - "additionalProperties": false - } - }, - "egress": { - "type": "object", - "title": "Egress proxy allowlists", - "description": "Routes outbound traffic through the egress proxy, allowing only the listed domains, per phase (`build` or `runtime`).", - "properties": { - "build": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "runtime": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "container_profile": { - "type": "string", - "title": "The container profile for this container" - } - }, - "additionalProperties": false, - "title": "Configuration for accessing this application via HTTP.", - "default": { - "locations": {} - } - }, - "hooks": { - "type": "object", - "properties": { - "build": { - "type": "string", - "title": "Hook executed after the build process.", - "default": null - }, - "deploy": { - "type": "string", - "title": "Hook executed after the deployment of new code.", - "default": null - }, - "post_deploy": { - "type": "string", - "title": "Hook executed after an environment is fully deployed.", - "default": null - } - }, - "additionalProperties": false, - "title": "Scripts executed at various points in the lifecycle of the application.", - "default": {} - }, - "crons": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "spec": { - "type": "string", - "title": "The cron schedule specification." - }, - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "title": "The command used to start the cron job." - }, - "stop": { - "type": "string", - "title": "The command used to stop the cron job.", - "default": null - } - }, - "required": [ - "start" - ], - "additionalProperties": false, - "title": "The start and stop commands definition." - }, - "shutdown_timeout": { - "type": "integer", - "title": "The timeout in seconds after which the cron job will be forcefully killed.", - "default": null - }, - "cmd": { - "type": "string", - "title": "The command to execute." - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 86400, - "description": "The maximum duration in seconds." - } - }, - "required": [ - "spec" - ], - "additionalProperties": false - }, - "title": "Scheduled cron tasks executed by this application.", - "default": {} - }, - "workers": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "firewall": { - "type": "object", - "properties": { - "outbound": { - "type": "array", - "items": { - "type": "object", - "properties": { - "protocol": { - "type": "string", - "title": "The IP protocol to apply the restriction on.", - "default": "tcp" - }, - "ips": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The IP range in CIDR notation to apply the restriction on.", - "default": [] - }, - "domains": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Domains of the restriction.", - "default": [] - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "The port to apply the restriction on.", - "default": [] - } - }, - "additionalProperties": false - }, - "title": "Outbound firewall restrictions", - "default": [] - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Firewall" - }, - "variables": { - "type": "object", - "additionalProperties": { - "type": "object", - "additionalProperties": {} - }, - "title": "Variables provide environment-sensitive information to control how your application behaves. To set a Unix environment variable, specify a key of `env:`, and then each sub-item of that is a key/value pair that will be injected into the environment." - }, - "timezone": { - "type": "string", - "title": "The timezone of the application. This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified." - }, - "mounts": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "source": { - "type": "string", - "title": "The type of mount that will provide the data.", - "enum": [ - "local", - "service", - "tmp" - ] - }, - "source_path": { - "type": "string", - "title": "The path to be mounted, relative to the root directory of the volume that's being mounted from." - }, - "service": { - "type": "string", - "title": "The name of the service that the volume will be mounted from. Must be a service in `services.yaml` of type `network-storage`." - } - }, - "required": [ - "source" - ], - "additionalProperties": false - }, - "title": "Filesystem mounts of this application. If not specified the application will have no writeable disk space." - }, - "relationships": { - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "null" - }, - { - "type": "object", - "properties": { - "service": { - "type": "string" - }, - "endpoint": { - "type": "string" - } - } - } - ] - }, - "title": "The relationships of the application to defined services.", - "default": {} - }, - "access": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Access information, a mapping between access type and roles." - }, - "disk": { - "type": "integer", - "title": "The writeable disk size to reserve on this application container." - }, - "size": { - "type": "string", - "title": "The container size for this application in production. Leave blank to allow it to be set dynamically." - }, - "resources": { - "type": "object", - "properties": { - "base_memory": { - "type": "integer", - "title": "The base memory for the container", - "default": 64 - }, - "memory_ratio": { - "type": "integer", - "title": "The amount of memory to allocate per units of CPU", - "default": 128 - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Resources" - }, - "commands": { - "type": "object", - "properties": { - "pre_start": { - "type": "string", - "title": "The command used to run before starting the cron job." - }, - "start": { - "type": "string", - "title": "The command used to start the application. It will be restarted if it terminates. Do not use on PHP unless using a custom persistent process like React PHP." - } - }, - "required": [ - "start" - ], - "additionalProperties": false, - "title": "The commands to manage the worker." - }, - "authorizations": { - "type": "array", - "title": "Workload authorizations", - "description": "Grants the container short-lived, narrowly-scoped access to the Upsun API at runtime.\nMore information:\nhttps://developer.upsun.com/docs/configure-apps/image-properties/authorizations", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "env", - "task" - ], - "description": "The type of resource to access: `task` (a task defined in the project) or `env` (the environment API)." - }, - "action": { - "type": "string", - "enum": [ - "operate", - "view" - ], - "description": "`operate` allows triggering and managing a task; `view` grants read-only access to the environment API." - }, - "resource": { - "type": "string", - "description": "The name of the task. Required when `type` is `task`." - } - }, - "required": [ - "type", - "action" - ], - "additionalProperties": false - } - }, - "egress": { - "type": "object", - "title": "Egress proxy allowlists", - "description": "Routes outbound traffic through the egress proxy, allowing only the listed domains, per phase (`build` or `runtime`).", - "properties": { - "build": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "runtime": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "container_profile": { - "type": "string", - "title": "The container profile for this container" - } - }, - "required": [ - "commands" - ], - "additionalProperties": false - }, - "title": "Persistent worker containers created by this application.", - "default": {} - }, - "additional_hosts": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Resolve additional IPs to domain names." - }, - "stack": { - "type": [ - "object", - "array", - "null" - ], - "title": "Composable image definition", - "description": "The runtimes and packages that make up the composable image. Each is a name, a mapping, or a list of them. A list is only accepted when empty (checked by the linter).", - "properties": { - "runtimes": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "minProperties": 1, - "maxProperties": 1 - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "minProperties": 1, - "maxProperties": 1 - } - ] - } - } - ] - }, - "packages": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "required": [ - "package" - ] - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "required": [ - "package" - ] - } - ] - } - } - ] - } - }, - "additionalProperties": false - }, - "authorizations": { - "type": "array", - "title": "Workload authorizations", - "description": "Grants the container short-lived, narrowly-scoped access to the Upsun API at runtime.\nMore information:\nhttps://developer.upsun.com/docs/configure-apps/image-properties/authorizations", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "env", - "task" - ], - "description": "The type of resource to access: `task` (a task defined in the project) or `env` (the environment API)." - }, - "action": { - "type": "string", - "enum": [ - "operate", - "view" - ], - "description": "`operate` allows triggering and managing a task; `view` grants read-only access to the environment API." - }, - "resource": { - "type": "string", - "description": "The name of the task. Required when `type` is `task`." - } - }, - "required": [ - "type", - "action" - ], - "additionalProperties": false - } - }, - "operations": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "commands": { - "type": "object", - "properties": { - "start": { - "type": "string", - "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." - }, - "stop": { - "type": "string", - "description": "The command used to stop the operation." - } - }, - "required": [ - "start" - ] - }, - "role": { - "type": "string", - "title": "which users can trigger it according to their user role", - "description": "More information: \nhttps://docs.upsun.com/anchors/app/runtime-operations/define/", - "enum": [ - "viewer", - "contributor", - "admin" - ] - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 86400, - "description": "The maximum duration in seconds." - } - }, - "required": [ - "commands" - ], - "additionalProperties": false - }, - "title": "Runtime operations that can be executed in the application container", - "description": "More information: \nhttps://docs.upsun.com/anchors/app/runtime-operations/", - "default": {} - }, - "egress": { - "type": "object", - "title": "Egress proxy allowlists", - "description": "Routes outbound traffic through the egress proxy, allowing only the listed domains, per phase (`build` or `runtime`).", - "properties": { - "build": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "runtime": { - "type": "object", - "properties": { - "allowed_domains": { - "type": "array", - "description": "The domains that outbound traffic may reach.", - "items": { - "type": "object", - "properties": { - "host": { - "type": "string", - "minLength": 1 - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - } - }, - "required": [ - "host" - ], - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "image": { - "type": "object", - "title": "OCI image configuration", - "description": "For a `docker:` type: either `name` (an image reference) or `buildfile`.", - "properties": { - "name": { - "type": "string" - }, - "buildfile": { - "type": "string" - } - }, - "additionalProperties": false - }, - "container_profile": { - "type": "string", - "title": "The container profile for this container" - } - }, - "anyOf": [ - { - "required": [ - "name", - "type" - ] - }, - { - "required": [ - "name", - "stack" - ] - } - ], - "additionalProperties": false -} diff --git a/internal/lint/schema/platformsh.routes.json b/internal/lint/schema/platformsh.routes.json deleted file mode 100644 index 52f92c631..000000000 --- a/internal/lint/schema/platformsh.routes.json +++ /dev/null @@ -1,401 +0,0 @@ -{ - "title": "Platform.sh routes configuration file", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "additionalProperties": { - "oneOf": [ - { - "type": "object", - "properties": { - "primary": { - "type": "boolean", - "title": "This route is the primary route of the environment", - "default": null - }, - "id": { - "type": "string", - "title": "Route Identifier", - "default": null - }, - "attributes": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Arbitrary attributes attached to this resource", - "default": {} - }, - "type": { - "type": "string", - "title": "Route type." - }, - "redirects": { - "type": "object", - "properties": { - "expires": { - "type": ["integer", "string"], - "title": "The amount of time, in seconds, to cache the redirects.", - "default": -1 - }, - "paths": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "regexp": { - "type": "boolean", - "title": "Whether the path is a regular expression.", - "default": false - }, - "to": { - "type": "string", - "title": "The URL to redirect to." - }, - "prefix": { - "type": "boolean", - "title": "Whether to redirect all the paths that start with the path.", - "default": null - }, - "append_suffix": { - "type": "boolean", - "title": "Whether to append the incoming suffix to the redirected URL.", - "default": null - }, - "code": { - "type": "integer", - "title": "The redirect code to use.", - "default": 302 - }, - "expires": { - "type": ["integer", "string"], - "title": "The amount of time, in seconds, to cache the redirects.", - "default": null - } - }, - "required": [ - "to" - ], - "additionalProperties": false - }, - "title": "The paths to redirect" - } - }, - "required": [ - "paths" - ], - "additionalProperties": false, - "title": "The configuration of the redirects.", - "default": {} - }, - "tls": { - "type": "object", - "properties": { - "strict_transport_security": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Whether strict transport security is enabled or not.", - "default": null - }, - "include_subdomains": { - "type": "boolean", - "title": "Whether the strict transport security policy should include all subdomains.", - "default": null - }, - "preload": { - "type": "boolean", - "title": "Whether the strict transport security policy should be preloaded in browsers.", - "default": null - } - }, - "additionalProperties": false, - "title": "Strict-Transport-Security options.", - "default": { - "preload": null, - "include_subdomains": null, - "enabled": null - } - }, - "min_version": { - "type": "string", - "enum": [ - "TLSv1.1", - "TLSv1.0", - "TLSv1.3", - "TLSv1.2" - ], - "title": "The minimum TLS version to support.", - "default": null - }, - "client_authentication": { - "type": "string", - "title": "The type of client authentication to request.", - "default": null - }, - "client_certificate_authorities": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Certificate authorities to validate the client certificate against. If not specified, a default set of trusted CAs will be used.", - "default": [] - } - }, - "additionalProperties": false, - "title": "TLS settings for the route.", - "default": { - "client_authentication": null, - "min_version": null, - "client_certificate_authorities": [], - "strict_transport_security": { - "preload": null, - "include_subdomains": null, - "enabled": null - } - } - }, - "to": { - "type": "string", - "title": "Redirect destination" - } - }, - "required": [ - "type", - "to" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "primary": { - "type": "boolean", - "title": "This route is the primary route of the environment", - "default": null - }, - "id": { - "type": "string", - "title": "Route Identifier", - "default": null - }, - "attributes": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Arbitrary attributes attached to this resource", - "default": {} - }, - "type": { - "type": "string", - "title": "Route type." - }, - "redirects": { - "type": "object", - "properties": { - "expires": { - "type": ["integer", "string"], - "title": "The amount of time, in seconds, to cache the redirects.", - "default": -1 - }, - "paths": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "regexp": { - "type": "boolean", - "title": "Whether the path is a regular expression.", - "default": false - }, - "to": { - "type": "string", - "title": "The URL to redirect to." - }, - "prefix": { - "type": "boolean", - "title": "Whether to redirect all the paths that start with the path.", - "default": null - }, - "append_suffix": { - "type": "boolean", - "title": "Whether to append the incoming suffix to the redirected URL.", - "default": null - }, - "code": { - "type": "integer", - "title": "The redirect code to use.", - "default": 302 - }, - "expires": { - "type": ["integer", "string"], - "title": "The amount of time, in seconds, to cache the redirects.", - "default": null - } - }, - "required": [ - "to" - ], - "additionalProperties": false - }, - "title": "The paths to redirect" - } - }, - "required": [ - "paths" - ], - "additionalProperties": false, - "title": "The configuration of the redirects.", - "default": {} - }, - "tls": { - "type": "object", - "properties": { - "strict_transport_security": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Whether strict transport security is enabled or not.", - "default": null - }, - "include_subdomains": { - "type": "boolean", - "title": "Whether the strict transport security policy should include all subdomains.", - "default": null - }, - "preload": { - "type": "boolean", - "title": "Whether the strict transport security policy should be preloaded in browsers.", - "default": null - } - }, - "additionalProperties": false, - "title": "Strict-Transport-Security options.", - "default": { - "preload": null, - "include_subdomains": null, - "enabled": null - } - }, - "min_version": { - "type": "string", - "enum": [ - "TLSv1.1", - "TLSv1.0", - "TLSv1.3", - "TLSv1.2" - ], - "title": "The minimum TLS version to support.", - "default": null - }, - "client_authentication": { - "type": "string", - "title": "The type of client authentication to request.", - "default": null - }, - "client_certificate_authorities": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Certificate authorities to validate the client certificate against. If not specified, a default set of trusted CAs will be used.", - "default": [] - } - }, - "additionalProperties": false, - "title": "TLS settings for the route.", - "default": { - "client_authentication": null, - "min_version": null, - "client_certificate_authorities": [], - "strict_transport_security": { - "preload": null, - "include_subdomains": null, - "enabled": null - } - } - }, - "cache": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Whether the cache is enabled." - }, - "default_ttl": { - "type": "integer", - "title": "The TTL to apply when the response doesn't specify one. Only applies to static files.", - "default": 0 - }, - "cookies": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The cookies to take into account for the cache key.", - "default": [ - "*" - ] - }, - "headers": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The headers to take into account for the cache key.", - "default": [ - "Accept", - "Accept-Language" - ] - } - }, - "required": [ - "enabled" - ], - "additionalProperties": false, - "title": "Cache configuration.", - "default": { - "default_ttl": 0, - "cookies": [ - "*" - ], - "enabled": true, - "headers": [ - "Accept", - "Accept-Language" - ] - } - }, - "ssi": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "title": "Whether SSI include is enabled." - } - }, - "required": [ - "enabled" - ], - "additionalProperties": false, - "title": "Server-Side Include configuration.", - "default": { - "enabled": false - } - }, - "upstream": { - "type": "string", - "title": "The upstream to use for this route." - } - }, - "required": [ - "type", - "upstream" - ], - "additionalProperties": false - } - ] - } -} \ No newline at end of file diff --git a/internal/lint/schema/platformsh.services.json b/internal/lint/schema/platformsh.services.json deleted file mode 100644 index 3a20fe18b..000000000 --- a/internal/lint/schema/platformsh.services.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "title": "Platform.sh services configuration file", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "title": "The service type." - }, - "size": { - "type": "string", - "title": "The service size.", - "default": "AUTO" - }, - "disk": { - "type": "integer", - "title": "The size of the disk.", - "default": null - }, - "access": { - "type": "object", - "title": "The configuration of the service.", - "default": {} - }, - "configuration": { - "type": "object", - "title": "The configuration of the service.", - "default": {} - }, - "relationships": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "The relationships of the service to other services.", - "default": {} - }, - "firewall": { - "type": "object", - "properties": { - "outbound": { - "type": "array", - "items": { - "type": "object", - "properties": { - "protocol": { - "type": "string", - "title": "The IP protocol to apply the restriction on.", - "default": "tcp" - }, - "ips": { - "type": "array", - "items": { - "type": "string" - }, - "title": "The IP range in CIDR notation to apply the restriction on.", - "default": [] - }, - "domains": { - "type": "array", - "items": { - "type": "string" - }, - "title": "Domains of the restriction.", - "default": [] - }, - "ports": { - "type": "array", - "items": { - "type": "integer" - }, - "title": "The port to apply the restriction on.", - "default": [] - } - }, - "additionalProperties": false - }, - "title": "Outbound firewall restrictions", - "default": [] - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Firewall", - "default": null - }, - "resources": { - "type": "object", - "properties": { - "base_memory": { - "type": "integer", - "title": "The base memory for the container", - "default": 64 - }, - "memory_ratio": { - "type": "integer", - "title": "The amount of memory to allocate per units of CPU", - "default": 128 - } - }, - "additionalProperties": false, - "nullable": true, - "title": "Resources", - "default": null - }, - "container_profile": { - "type": "string", - "title": "The container profile for this container" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } -} diff --git a/internal/lint/schema/schema_test.go b/internal/lint/schema/schema_test.go index 8d15968f2..0cb1bc168 100644 --- a/internal/lint/schema/schema_test.go +++ b/internal/lint/schema/schema_test.go @@ -31,9 +31,12 @@ applications: // TestSchemaDefaults checks that each default is valid, as editors insert defaults on completion. func TestSchemaDefaults(t *testing.T) { - var root map[string]any - require.NoError(t, json.Unmarshal(configSchema, &root)) + for name, root := range schemaRoots(t) { + t.Run(name, func(t *testing.T) { checkDefaults(t, root) }) + } +} +func checkDefaults(t *testing.T, root map[string]any) { var walk func(node any, path string) walk = func(node any, path string) { switch n := node.(type) { @@ -71,9 +74,12 @@ func TestSchemaDefaults(t *testing.T) { // 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)) + for name, root := range schemaRoots(t) { + t.Run(name, func(t *testing.T) { checkKeywords(t, root) }) + } +} +func checkKeywords(t *testing.T, root map[string]any) { var walk func(node any, path string) walk = func(node any, path string) { switch n := node.(type) { @@ -98,3 +104,12 @@ func TestSchemaKeywords(t *testing.T) { } walk(root, "") } + +// schemaRoots returns the Flex schema, and the Flex schema with the Fixed overlay. +func schemaRoots(t *testing.T) map[string]map[string]any { + var flex map[string]any + require.NoError(t, json.Unmarshal(configSchema, &flex)) + fixed, err := fixedSchema() + require.NoError(t, err) + return map[string]map[string]any{"flex": flex, "fixed": fixed} +} diff --git a/internal/lint/types.go b/internal/lint/types.go index 1ab4acd4e..023ec0f36 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -51,23 +51,13 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { for appName := range cfg.Applications { app := cfg.Applications[appName] - stack := checkStackList("applications."+appName+".stack", app.Stack, result) - if app.Type == "" && !isStackEmpty(app.Stack) { - // For backwards compatibility, we allow 'stack' to be specified without 'type'. - if !isStackEmpty(stack) { - result.AddWarning("applications."+appName, - "'type' should be specified (as a composable image) when using 'stack'") - } - continue - } - checkRuntime("applications."+appName, app.Type, stack, app.Image) + checkRuntime("applications."+appName, app.Type, app.Stack, app.Image) } for taskName := range cfg.Tasks { // A task using a built-in definition ('base') has no type of its own. task := cfg.Tasks[taskName] - stack := checkStackList("tasks."+taskName+".stack", task.Stack, result) if task.Base == "" { - checkRuntime("tasks."+taskName, task.Type, stack, task.Image) + checkRuntime("tasks."+taskName, task.Type, task.Stack, task.Image) } } for appName := range cfg.Applications { @@ -85,17 +75,6 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { return result } -// checkStackList reports a stack written as a non-empty list, a format the -// platform no longer accepts. It returns the stack for further checks: an -// empty mapping in place of a reported list, so it is not reported again. -func checkStackList(path string, stack any, result *Result) any { - if l, ok := stack.([]any); ok && len(l) > 0 { - result.AddError(path, "'stack' must be a mapping of 'runtimes' and 'packages', not a list") - return map[string]any{} - } - return stack -} - // checkType validates an image type and version. It returns a warning for a // retired version, or an error if the type or version is not allowed. func checkType(t string, reg registry.Registry, runtime bool) (string, error) { diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index f2ce65472..0c0b69506 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -78,17 +78,6 @@ services: type: nodejs:22`, expectErrorMessage: "linter errors:\n - services.myservice.type: type 'nodejs' is a runtime type, not a service type", //nolint:lll }, - { - name: "composable_without_type", - content: ` -applications: - foo: - stack: - runtimes: [bun@1] - packages: [ffmpeg]`, - expectErrorMessage: `linter warnings: - - applications.foo: 'type' should be specified (as a composable image) when using 'stack'`, - }, { name: "composable_without_stack", content: fmt.Sprintf(` @@ -128,28 +117,6 @@ tasks: - tasks.bad.type: type cannot be empty linter warnings: - tasks.bad.stack: 'stack' is only used with a composable image type`, - }, - { - name: "stack_list", - content: ` -applications: - foo: - type: composable:25.11 - stack: [php@8.4] - classic: - type: php:8.4 - stack: [php@8.4] - untyped: - stack: [php@8.4] -tasks: - job: - base: performance-agent - stack: [php@8.4]`, - expectErrorMessage: `linter errors: - - applications.classic.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list - - applications.foo.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list - - applications.untyped.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list - - tasks.job.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, }, { name: "stack_with_non_composable_type",