Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
55 changes: 53 additions & 2 deletions internal/lint/fixed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
pjcdawkins marked this conversation as resolved.
".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{
Expand Down Expand Up @@ -164,15 +215,15 @@ 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",
files: map[string]string{
".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",
Expand Down
21 changes: 20 additions & 1 deletion internal/lint/linter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions internal/lint/schema/fixed-overlay.json
Original file line number Diff line number Diff line change
@@ -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"}
}
}
}
}
}
94 changes: 80 additions & 14 deletions internal/lint/schema/fixed.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,104 @@ 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
parsedServices *gojsonschema.Schema
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
}
Expand Down
Loading
Loading