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..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 +- `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 abb1f10a5..37176a6dd 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). +# 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 + +# 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/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 7aa60b320..82854a096 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -406,7 +406,26 @@ applications: stack: ["php@8.4"] `, wantErrors: `linter errors: - - applications.app.stack: 'stack' must be a mapping of 'runtimes' and 'packages', not a list`, + - 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/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/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.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..0cb1bc168 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" @@ -30,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) { @@ -44,7 +48,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,19 +69,26 @@ 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)) + 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) { 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) @@ -93,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/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..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",