From 8951f2d8e55fe00ff729e6903bdc61b874d79258 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:08:48 +0100 Subject: [PATCH 01/36] feat(lint): port pure config linter from ai-api Add internal/lint with the multi-error config linter ported from the ai-api repository (internal/linter, internal/schema, internal/registry, plus the .upsun config merge helpers). The linter validates merged Flex-style config against the embedded JSON schema and runs semantic checks (relationships, names, types, scripts, web, dependencies, routes), collecting all errors and warnings rather than stopping at the first. Changes from the source: - Inline the composable-image stable channel constant to drop the nix dependency. - Drop the AI-only file_modifications schema patch. - Use github.com/dlclark/regexp2/v2. Promote gojsonschema to a direct dependency and add mvdan.cc/sh/v3. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 5 +- go.sum | 11 +- internal/lint/config_schema.go | 85 ++ internal/lint/dependencies.go | 42 + internal/lint/dependencies_test.go | 147 ++ internal/lint/linter.go | 50 + internal/lint/linter_test.go | 108 ++ internal/lint/merge.go | 87 ++ internal/lint/merge_test.go | 77 + internal/lint/names.go | 72 + internal/lint/names_test.go | 152 ++ internal/lint/registry/model.go | 133 ++ internal/lint/registry/model_test.go | 174 +++ internal/lint/registry/registry.go | 98 ++ internal/lint/registry/registry.json | 1359 +++++++++++++++++ internal/lint/registry/registry_test.go | 19 + internal/lint/relationships.go | 89 ++ internal/lint/relationships_test.go | 120 ++ internal/lint/result.go | 111 ++ internal/lint/routes.go | 84 + internal/lint/routes_test.go | 234 +++ internal/lint/schema/schema.go | 24 + internal/lint/schema/schema_test.go | 27 + internal/lint/schema/upsun-config-schema.json | 1285 ++++++++++++++++ internal/lint/scripts.go | 48 + internal/lint/scripts_test.go | 179 +++ internal/lint/testdata/registry.json | 1319 ++++++++++++++++ internal/lint/types.go | 95 ++ internal/lint/types_test.go | 118 ++ internal/lint/web.go | 90 ++ internal/lint/web_test.go | 479 ++++++ internal/lint/yaml.go | 41 + internal/lint/yaml_test.go | 98 ++ 33 files changed, 7055 insertions(+), 5 deletions(-) create mode 100644 internal/lint/config_schema.go create mode 100644 internal/lint/dependencies.go create mode 100644 internal/lint/dependencies_test.go create mode 100644 internal/lint/linter.go create mode 100644 internal/lint/linter_test.go create mode 100644 internal/lint/merge.go create mode 100644 internal/lint/merge_test.go create mode 100644 internal/lint/names.go create mode 100644 internal/lint/names_test.go create mode 100644 internal/lint/registry/model.go create mode 100644 internal/lint/registry/model_test.go create mode 100644 internal/lint/registry/registry.go create mode 100644 internal/lint/registry/registry.json create mode 100644 internal/lint/registry/registry_test.go create mode 100644 internal/lint/relationships.go create mode 100644 internal/lint/relationships_test.go create mode 100644 internal/lint/result.go create mode 100644 internal/lint/routes.go create mode 100644 internal/lint/routes_test.go create mode 100644 internal/lint/schema/schema.go create mode 100644 internal/lint/schema/schema_test.go create mode 100644 internal/lint/schema/upsun-config-schema.json create mode 100644 internal/lint/scripts.go create mode 100644 internal/lint/scripts_test.go create mode 100644 internal/lint/testdata/registry.json create mode 100644 internal/lint/types.go create mode 100644 internal/lint/types_test.go create mode 100644 internal/lint/web.go create mode 100644 internal/lint/web_test.go create mode 100644 internal/lint/yaml.go create mode 100644 internal/lint/yaml_test.go diff --git a/go.mod b/go.mod index 992dc6047..fee9b0b7b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/briandowns/spinner v1.23.2 + github.com/dlclark/regexp2/v2 v2.2.1 github.com/fatih/color v1.19.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-playground/validator/v10 v10.30.4 @@ -21,12 +22,14 @@ require ( github.com/upsun/lib-sun v0.3.16 github.com/upsun/whatsun v0.2.1 github.com/wk8/go-ordered-map/v2 v2.1.8 + github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/crypto v0.57.0 golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.23.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.14.1 ) require ( @@ -57,7 +60,6 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/semgroup v1.3.0 // indirect @@ -123,7 +125,6 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zricethezav/gitleaks/v8 v8.30.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index f4e76c99a..be3f6f4ab 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,9 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -121,6 +122,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= @@ -226,8 +229,8 @@ github.com/platformsh/platformify v0.5.0/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8I github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -378,3 +381,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go new file mode 100644 index 000000000..ee816faf6 --- /dev/null +++ b/internal/lint/config_schema.go @@ -0,0 +1,85 @@ +package lint + +import ( + "fmt" + "reflect" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Applications map[string]struct { + Type string `yaml:"type"` + Stack any `yaml:"stack,omitempty"` // The stack can be an object, string, or array. + + Hooks struct { + Build string `yaml:"build,omitempty"` + Deploy string `yaml:"deploy,omitempty"` + PostDeploy string `yaml:"post_deploy,omitempty"` + } `yaml:"hooks,omitempty"` + + Web struct { + Commands struct { + Start string `yaml:"start,omitempty"` + PostStart string `yaml:"post_start,omitempty"` + } `yaml:"commands,omitempty"` + + Locations map[string]struct { + Root string `yaml:"root,omitempty"` + Rules map[string]any `yaml:"rules,omitempty"` + } `yaml:"locations,omitempty"` + } `yaml:"web,omitempty"` + + Relationships map[string]any `yaml:"relationships,omitempty"` + + Crons map[string]struct { + Commands struct { + Start string `yaml:"start,omitempty"` + Stop string `yaml:"stop,omitempty"` + } `yaml:"commands,omitempty"` + } `yaml:"crons,omitempty"` + + Workers map[string]struct { + Type string `yaml:"type,omitempty"` + } `yaml:"workers,omitempty"` + + Dependencies map[string]map[string]any `yaml:"dependencies,omitempty"` + } `yaml:"applications"` + + Services map[string]struct { + Type string `yaml:"type,omitempty"` + } `yaml:"services,omitempty"` + + Routes map[string]struct { + Type string `yaml:"type,omitempty"` + Upstream string `yaml:"upstream,omitempty"` + To string `yaml:"to,omitempty"` + } `yaml:"routes,omitempty"` +} + +func DecodeConfig(content string) (*Config, error) { + var c Config + if err := yaml.Unmarshal([]byte(content), &c); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + return &c, nil +} + +// isStackEmpty checks if the stack field is empty, handling all possible types. +func isStackEmpty(stack any) bool { + if stack == nil { + return true + } + + v := reflect.ValueOf(stack) + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Slice, reflect.Array, reflect.Map: + return v.Len() == 0 + case reflect.Pointer, reflect.Interface: + return v.IsNil() + default: + return v.IsZero() + } +} diff --git a/internal/lint/dependencies.go b/internal/lint/dependencies.go new file mode 100644 index 000000000..b5f4822ff --- /dev/null +++ b/internal/lint/dependencies.go @@ -0,0 +1,42 @@ +package lint + +import "fmt" + +// CheckDependencies validates the application dependencies section. +func CheckDependencies(cfg *Config) *Result { + result := &Result{} + + validTypes := map[string]bool{ + "nodejs": true, + "php": true, + "python": true, + "python3": true, + "ruby": true, + } + + for appName, app := range cfg.Applications { + for depType, packages := range app.Dependencies { + // Lint dependency type + if !validTypes[depType] { + path := "applications." + appName + ".dependencies." + depType + msg := fmt.Sprintf("invalid dependency type '%s'; must be one of: nodejs, php, python3, ruby", depType) + result.AddError(path, msg) + continue + } + + // Lint package names and versions are not empty + for pkgName, version := range packages { + if pkgName == "" { + path := "applications." + appName + ".dependencies." + depType + result.AddError(path, "package name cannot be empty") + } + if version == "" { + path := "applications." + appName + ".dependencies." + depType + "." + pkgName + result.AddError(path, "package version cannot be empty") + } + } + } + } + + return result +} diff --git a/internal/lint/dependencies_test.go b/internal/lint/dependencies_test.go new file mode 100644 index 000000000..fbdd5127e --- /dev/null +++ b/internal/lint/dependencies_test.go @@ -0,0 +1,147 @@ +package lint + +import ( + "sort" + "testing" +) + +func TestCheckDependencies(t *testing.T) { + tests := []struct { + name string + yaml string + wantErrs []string + }{ + { + name: "valid dependencies", + yaml: ` +applications: + app: + type: nodejs:22 + dependencies: + nodejs: + sharp: '*' + lodash: '^4.17.21' + php: + guzzlehttp/guzzle: '^7.0' + python3: + requests: '2.28.1' + ruby: + nokogiri: '1.13.8' +`, + }, + { + name: "invalid dependency type", + yaml: ` +applications: + app: + type: nodejs:22 + dependencies: + golang: + gin: 'v1.9.1' +`, + wantErrs: []string{ + "applications.app.dependencies.golang: invalid dependency type 'golang'; must be one of: nodejs, php, python3, ruby", //nolint:lll + }, + }, + { + name: "multiple invalid dependency types", + yaml: ` +applications: + app: + type: nodejs:22 + dependencies: + golang: + gin: 'v1.9.1' + java: + spring: '2.7.0' +`, + wantErrs: []string{ + "applications.app.dependencies.golang: invalid dependency type 'golang'; must be one of: nodejs, php, python3, ruby", //nolint:lll + "applications.app.dependencies.java: invalid dependency type 'java'; must be one of: nodejs, php, python3, ruby", + }, + }, + { + name: "empty package version", + yaml: ` +applications: + app: + type: nodejs:22 + dependencies: + nodejs: + sharp: '' +`, + wantErrs: []string{ + "applications.app.dependencies.nodejs.sharp: package version cannot be empty", + }, + }, + { + name: "no dependencies section", + yaml: ` +applications: + app: + type: nodejs:22 +`, + }, + { + name: "php custom repositories", + yaml: ` +applications: + app: + type: php:8.3 + # Example from: https://docs.upsun.com/languages/php.html#alternative-repositories + dependencies: + php: + require: + "platformsh/client": "2.x-dev" + repositories: + - type: vcs + url: "git@github.com:platformsh/platformsh-client-php.git" +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := DecodeConfig(tt.yaml) + if err != nil { + t.Fatalf("failed to decode YAML: %v", err) + } + + result := CheckDependencies(cfg) + + if len(tt.wantErrs) == 0 { + if result.HasErrors() { + t.Errorf("expected no errors, got: %v", result.Errors) + } + return + } + + if !result.HasErrors() { + t.Errorf("expected errors, got none") + return + } + + if len(result.Errors) != len(tt.wantErrs) { + t.Errorf("expected %d errors, got %d: %v", len(tt.wantErrs), len(result.Errors), result.Errors) + return + } + + // Convert errors to strings and sort both expected and got for comparison + var gotErrs []string + for _, err := range result.Errors { + gotErrs = append(gotErrs, err.Path+": "+err.Message) + } + sort.Strings(gotErrs) + + wantErrsSorted := make([]string, len(tt.wantErrs)) + copy(wantErrsSorted, tt.wantErrs) + sort.Strings(wantErrsSorted) + + for i, wantErr := range wantErrsSorted { + if gotErrs[i] != wantErr { + t.Errorf("error %d: expected %q, got %q", i, wantErr, gotErrs[i]) + } + } + }) + } +} diff --git a/internal/lint/linter.go b/internal/lint/linter.go new file mode 100644 index 000000000..991803b7e --- /dev/null +++ b/internal/lint/linter.go @@ -0,0 +1,50 @@ +package lint + +import ( + "context" + "errors" + "fmt" + + "github.com/upsun/cli/internal/lint/registry" + "github.com/upsun/cli/internal/lint/schema" +) + +var ErrEmptyContent = errors.New("empty content") + +// Lint checks generated configuration and returns a Result. +func Lint(_ context.Context, content string) (*Result, error) { + if len(content) == 0 { + return nil, ErrEmptyContent + } + + reg, err := registry.Parsed() + if err != nil { + return nil, fmt.Errorf("failed to load registry: %w", err) + } + + yamlSchema, err := schema.Load() + if err != nil { + return nil, fmt.Errorf("failed to load schema: %w", err) + } + + // Check YAML validity and schema compliance. + if result := CheckYAMLSchema(content, yamlSchema); result.HasErrors() { + return result, nil + } + + cfg, err := DecodeConfig(content) + if err != nil { + return nil, err + } + + // Run all other validation checks. + return Combine( + CheckRelationships(cfg), + CheckNames(cfg), + CheckTypes(cfg, reg), + CheckScripts(cfg), + CheckWebConfig(cfg), + CheckDependencies(cfg), + CheckRoutes(cfg), + ), nil +} diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go new file mode 100644 index 000000000..5d9a62b43 --- /dev/null +++ b/internal/lint/linter_test.go @@ -0,0 +1,108 @@ +package lint + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +//nolint:lll +func TestLint(t *testing.T) { + tests := []struct { + name string + content string + wantErr bool + wantErrMsg string + }{ + { + name: "combine errors", + content: ` +applications: + Foo_: + type: invalid:1.0 + relationships: + missing_service: + web: + commands: + start: "echo started" +services: {} +`, + wantErr: true, + wantErrMsg: `linter errors: + - applications.Foo_.relationships.missing_service: relationship 'missing_service' in application 'Foo_' does not match any service (or app) (did you forget to define services?) + - applications.Foo_.type: type not found: 'invalid'; it must be one of: composable, dotnet, elixir, golang, java, nodejs, php, python, ruby, rust (check the Registry for supported types, or make an application using a composable image) + - applications.Foo_: "Foo_" is not a valid application name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll + }, + { + name: "all ok", + content: ` +applications: + foo: + type: golang:1.24 + relationships: + database: + web: + commands: + start: "go run main.go" +services: + database: + type: mariadb:11.4 +`, + }, + { + name: "service missing type", + content: ` +applications: + foo: + type: golang:1.24 + relationships: + database: + web: + commands: + start: "go run main.go" +services: + database: {} +`, + wantErr: true, + wantErrMsg: "linter errors:\n - services.database: type is required", + }, + { + name: "invalid worker name", + content: ` +applications: + foo: + type: golang:1.24 + relationships: + database: + web: + commands: + start: "go run main.go" + workers: + _badworker: + commands: + start: echo ok +services: + database: + type: mariadb:11.4 +`, + wantErr: true, + wantErrMsg: `linter errors: + - applications.foo.workers._badworker: "_badworker" is not a valid worker name, it should start and end with alphanumeric characters.`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := Lint(context.Background(), tc.content) + assert.NoError(t, err) + + if tc.wantErr { + assert.True(t, result.HasErrors(), "expected errors but got none") + assert.Equal(t, tc.wantErrMsg, result.Error()) + } else { + assert.False(t, result.HasErrors(), "expected no errors but got: %s", result.Error()) + } + }) + } +} diff --git a/internal/lint/merge.go b/internal/lint/merge.go new file mode 100644 index 000000000..f52f1cc23 --- /dev/null +++ b/internal/lint/merge.go @@ -0,0 +1,87 @@ +package lint + +import ( + "fmt" + "io/fs" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// findUpsunConfigFiles returns all .upsun/*.yaml and .upsun/*.yml files in the given directory. +func findUpsunConfigFiles(fsys fs.FS, path string) ([]string, error) { + // Find both .yaml and .yml files + patterns := []string{ + filepath.Join(path, ".upsun", "*.yaml"), + filepath.Join(path, ".upsun", "*.yml"), + } + + var allMatches []string + for _, pattern := range patterns { + matches, err := fs.Glob(fsys, pattern) + if err != nil { + return nil, fmt.Errorf("could not glob .upsun directory: %w", err) + } + allMatches = append(allMatches, matches...) + } + + if len(allMatches) == 0 { + return nil, fmt.Errorf("no configuration files found matching %s or %s", patterns[0], patterns[1]) + } + return allMatches, nil +} + +// mergeConfigFiles merges the given YAML files, combining top-level 'applications', 'routes', and 'services' maps. +// If a key is duplicated across files, it returns an error. Returns the merged YAML as a string. +func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { + merged := map[string]map[string]any{ + "applications": {}, + "routes": {}, + "services": {}, + } + for _, file := range files { + b, err := fs.ReadFile(fsys, file) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", file, err) + } + var doc map[string]any + if err := yaml.Unmarshal(b, &doc); err != nil { + return "", fmt.Errorf("failed to parse YAML in %s: %w", file, err) + } + for _, key := range []string{"applications", "routes", "services"} { + if section, ok := doc[key]; ok && section != nil { + sectionMap, ok := section.(map[string]any) + if !ok { + return "", fmt.Errorf("%s in %s is not a map", key, file) + } + for k, v := range sectionMap { + if _, exists := merged[key][k]; exists { + return "", fmt.Errorf("duplicate key '%s' in section '%s' found in file %s", k, key, file) + } + merged[key][k] = v + } + } + } + } + out := map[string]any{} + for _, key := range []string{"applications", "routes", "services"} { + if len(merged[key]) > 0 { + out[key] = merged[key] + } + } + buf, err := yaml.Marshal(out) + if err != nil { + return "", fmt.Errorf("failed to marshal merged YAML: %w", err) + } + return string(buf), nil +} + +// getMergedConfigFiles merges all .upsun/*.yaml files in the given directory. +// It is a convenience wrapper for findUpsunConfigFiles + mergeConfigFiles. +func getMergedConfigFiles(fsys fs.FS, path string) (string, error) { + files, err := findUpsunConfigFiles(fsys, path) + if err != nil { + return "", err + } + return mergeConfigFiles(fsys, files) +} diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go new file mode 100644 index 000000000..ed0e8604d --- /dev/null +++ b/internal/lint/merge_test.go @@ -0,0 +1,77 @@ +package lint + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" +) + +func TestFindUpsunConfigFiles(t *testing.T) { + fsys := fstest.MapFS{ + ".upsun/a.yaml": &fstest.MapFile{Data: []byte("{}")}, + ".upsun/b.yaml": &fstest.MapFile{Data: []byte("{}")}, + ".upsun/c.yml": &fstest.MapFile{Data: []byte("{}")}, + ".upsun/notyaml.txt": &fstest.MapFile{Data: []byte("not yaml")}, + } + files, err := findUpsunConfigFiles(fsys, ".") + require.NoError(t, err) + require.ElementsMatch(t, []string{".upsun/a.yaml", ".upsun/b.yaml", ".upsun/c.yml"}, files) +} + +func TestMergeConfigFiles_Success(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte(`applications: + foo: {type: go} +routes: + /: {type: upstream}`)}, + "b.yml": &fstest.MapFile{Data: []byte(`services: + db: {type: mariadb}`)}, + } + merged, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) + require.NoError(t, err) + require.Contains(t, merged, "foo") + require.Contains(t, merged, "db") + require.Contains(t, merged, "/:") +} + +func TestMergeConfigFiles_DuplicateKey(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte(`applications: + foo: {type: go}`)}, + "b.yml": &fstest.MapFile{Data: []byte(`applications: + foo: {type: node}`)}, + } + _, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate key 'foo'") +} + +func TestGetMergedConfigFiles_Success(t *testing.T) { + fsys := fstest.MapFS{ + ".upsun/a.yaml": &fstest.MapFile{Data: []byte(`applications: + foo: {type: go}`)}, + ".upsun/b.yml": &fstest.MapFile{Data: []byte(`services: + db: {type: mariadb}`)}, + } + merged, err := getMergedConfigFiles(fsys, ".") + require.NoError(t, err) + require.Contains(t, merged, "foo") + require.Contains(t, merged, "db") +} + +func TestGetMergedConfigFiles_NoUpsunDir(t *testing.T) { + fsys := fstest.MapFS{} + _, err := getMergedConfigFiles(fsys, ".") + require.Error(t, err) + require.Contains(t, err.Error(), ".upsun") +} + +func TestGetMergedConfigFiles_NoYamlFiles(t *testing.T) { + fsys := fstest.MapFS{ + ".upsun/notyaml.txt": &fstest.MapFile{Data: []byte("not yaml")}, + } + _, err := getMergedConfigFiles(fsys, ".") + require.Error(t, err) + require.Contains(t, err.Error(), "no configuration files found") +} diff --git a/internal/lint/names.go b/internal/lint/names.go new file mode 100644 index 000000000..6b0008615 --- /dev/null +++ b/internal/lint/names.go @@ -0,0 +1,72 @@ +package lint + +import ( + "fmt" + "regexp" + "strings" +) + +const maxServiceNameLength = 32 + +var ( + isValidServiceNameChars = regexp.MustCompile(`^[a-z0-9_\-]+$`).MatchString + startsEndsAlphanumeric = regexp.MustCompile(`^[a-z0-9].*[a-z0-9]$`).MatchString + isAlphanumeric = regexp.MustCompile(`^[a-z0-9]+$`).MatchString +) + +// CheckNames checks that application, service and worker names are correct. +func CheckNames(cfg *Config) *Result { + result := &Result{} + + for name, app := range cfg.Applications { + if err := validateServiceName(name, "application"); err != "" { + result.AddError("applications."+name, err) + } + for workerName := range app.Workers { + if err := validateServiceName(workerName, "worker"); err != "" { + result.AddError("applications."+name+".workers."+workerName, err) + } + } + } + for name := range cfg.Services { + if err := validateServiceName(name, "service"); err != "" { + result.AddError("services."+name, err) + } + } + + return result +} + +// validateServiceName checks that a service name is correct and returns an error message if not. +func validateServiceName(value, nameType string) string { + if value == "router" { + return fmt.Sprintf("%q is a reserved name.", value) + } + + if len(value) > maxServiceNameLength { + return fmt.Sprintf("%q is not a valid %s name, it should be shorter than %d characters.", + value, nameType, maxServiceNameLength) + } + + if strings.Contains(value, "--") { + return fmt.Sprintf("%q is not a valid %s name, it should not contain double dashes.", value, nameType) + } + + if !isValidServiceNameChars(value) { + return fmt.Sprintf("%q is not a valid %s name, it can only contain lowercase alphanumeric characters, dashes, or underscores.", //nolint:lll + value, nameType) + } + + if len(value) == 1 { + // Single character names are valid if they're alphanumeric + if !isAlphanumeric(value) { + return fmt.Sprintf("%q is not a valid %s name, it should start and end with alphanumeric characters.", + value, nameType) + } + } else if !startsEndsAlphanumeric(value) { + return fmt.Sprintf("%q is not a valid %s name, it should start and end with alphanumeric characters.", + value, nameType) + } + + return "" +} diff --git a/internal/lint/names_test.go b/internal/lint/names_test.go new file mode 100644 index 000000000..db927273a --- /dev/null +++ b/internal/lint/names_test.go @@ -0,0 +1,152 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/lint" +) + +func TestCheckNames(t *testing.T) { + cases := []struct { + name string + content string + expectErrorValues []string + }{ + { + name: "valid_names", + content: ` +applications: + foo: + relationships: + database: + app-with-dash: + relationships: + database: + app_with_underscore: + relationships: + database: + a: + workers: + worker-1: + commands: + start: echo ok + worker_2: + commands: + start: echo ok +services: + database: + type: mariadb:11.4 + cache-service: + type: valkey:8.0 + service_with_underscore: + type: redis:7.2`, + }, + { + name: "reserved_name_router", + content: ` +services: + router: + type: mariadb:11.4`, + expectErrorValues: []string{`services.router: "router" is a reserved name.`}, + }, + { + name: "invalid_characters", + content: ` +applications: + fooBar: + relationships: + database: + foo.bar: + relationships: + database: +services: + database: + type: mariadb:11.4`, + expectErrorValues: []string{ + `applications.fooBar: "fooBar" is not a valid application name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll + `applications.foo.bar: "foo.bar" is not a valid application name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll + }, + }, + { + name: "invalid_start_end", + content: ` +applications: + _baz: + relationships: + database: + foo-: + relationships: + database: + -bar: + relationships: + database: + workers: + _badworker: + commands: + start: echo ok +services: + database: + type: mariadb:11.4`, + expectErrorValues: []string{ + `applications._baz: "_baz" is not a valid application name, it should start and end with alphanumeric characters.`, + `applications.foo-: "foo-" is not a valid application name, it should start and end with alphanumeric characters.`, + `applications.-bar: "-bar" is not a valid application name, it should start and end with alphanumeric characters.`, + `applications.-bar.workers._badworker: "_badworker" is not a valid worker name, it should start and end with alphanumeric characters.`, //nolint:lll + }, + }, + { + name: "double_dash", + content: ` +applications: + foo--bar: + relationships: + database: +services: + database: + type: mariadb:11.4 + cache--service: + type: valkey:8.0`, + expectErrorValues: []string{ + `applications.foo--bar: "foo--bar" is not a valid application name, it should not contain double dashes.`, + `services.cache--service: "cache--service" is not a valid service name, it should not contain double dashes.`, + }, + }, + { + name: "too_long", + content: ` +applications: + averylongapplicationnamethatexceedsthirtytwocharacters: + relationships: + database: +services: + database: + type: mariadb:11.4 + averylongservicenamethatexceedsthirtytwocharacters: + type: valkey:8.0`, + expectErrorValues: []string{ + `applications.averylongapplicationnamethatexceedsthirtytwocharacters: "averylongapplicationnamethatexceedsthirtytwocharacters" is not a valid application name, it should be shorter than 32 characters.`, //nolint:lll + `services.averylongservicenamethatexceedsthirtytwocharacters: "averylongservicenamethatexceedsthirtytwocharacters" is not a valid service name, it should be shorter than 32 characters.`, //nolint:lll + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + if err != nil { + assert.FailNow(t, "decodeConfig failed", err) + } + result := lint.CheckNames(cfg) + if len(c.expectErrorValues) > 0 { + assert.True(t, result.HasErrors()) + for _, v := range c.expectErrorValues { + assert.ErrorContains(t, result, v) + } + } else { + assert.False(t, result.HasErrors()) + } + }) + } +} diff --git a/internal/lint/registry/model.go b/internal/lint/registry/model.go new file mode 100644 index 000000000..4f5f4b7e2 --- /dev/null +++ b/internal/lint/registry/model.go @@ -0,0 +1,133 @@ +package registry + +import ( + "encoding/json" + "slices" +) + +type Registry map[string]Image + +func (r Registry) AllTypes(runtime bool) []string { + types := make([]string, 0, len(r)) + for k, v := range r { + if v.IsRuntime == runtime { + types = append(types, k) + } + } + slices.Sort(types) + return types +} + +// Image describes a single service/runtime container image. +type Image struct { + Name string `json:"name" yaml:"name,omitempty"` + Type string `json:"type" yaml:"type"` + IsRuntime bool `json:"runtime" yaml:"is_runtime"` + Versions VersionInfo `json:"versions,omitzero" yaml:"versions,omitempty"` + + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Configuration string `json:"configuration,omitempty" yaml:"configuration,omitempty"` + Docs Docs `json:"docs,omitzero" yaml:"docs,omitempty"` +} + +// Docs contains documentation for the image. +type Docs struct { + RelationshipName *string `json:"relationship_name,omitempty" yaml:"relationship_name,omitempty"` + ServiceName *string `json:"service_name,omitempty" yaml:"service_name,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Web Web `json:"web,omitzero" yaml:"web,omitempty"` + Hooks Hooks `json:"hooks,omitzero" yaml:"hooks,omitempty"` + Build BuildConfig `json:"build,omitzero" yaml:"build,omitempty"` + Dependencies map[string]Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` +} + +// Web describes web server configuration for runtime images. +type Web struct { + Commands struct { + Start string `json:"start,omitempty" yaml:"start,omitempty"` + } `json:"commands,omitzero" yaml:"commands,omitempty"` + Locations map[string]Location `json:"locations,omitempty" yaml:"locations,omitempty"` + Upstream Upstream `json:"upstream,omitzero" yaml:"upstream,omitempty"` +} + +// Upstream config for protocols/sockets. +type Upstream struct { + SocketFamily string `json:"socket_family,omitempty" yaml:"socket_family,omitempty"` + Protocol string `json:"protocol,omitempty" yaml:"protocol,omitempty"` +} + +// Location defines routing rules inside the Web configuration. +type Location struct { + Root string `json:"root,omitempty" yaml:"root,omitempty"` + Allow bool `json:"allow,omitempty" yaml:"allow,omitempty"` + Passthru any `json:"passthru,omitempty" yaml:"passthru,omitempty"` // String or bool + Expires any `json:"expires,omitempty" yaml:"expires,omitempty"` // String or int +} + +// Hooks define build and deploy lifecycle commands. +type Hooks struct { + Build any `json:"build,omitempty" yaml:"build,omitempty"` // String or list? + Deploy any `json:"deploy,omitempty" yaml:"deploy,omitempty"` +} + +// BuildConfig holds custom build settings. +type BuildConfig struct { + Flavor string `json:"flavor,omitempty" yaml:"flavor,omitempty"` +} + +// Dependency version map under Docs. +type Dependency map[string]string + +// VersionInfo lists deprecated, supported, and optional legacy versions. +type VersionInfo struct { + Supported []string `json:"supported"` + + Deprecated []string `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Legacy []string `json:"legacy,omitempty" yaml:"legacy,omitempty"` +} + +// UnmarshalJSON handles both string and object formats for versions field. +// Some registry entries have versions as a string (e.g. "25.05") instead of an object. +func (v *VersionInfo) UnmarshalJSON(data []byte) error { + // Try unmarshaling as a string first. + var str string + if err := json.Unmarshal(data, &str); err == nil { + // It's a string, treat it as a single supported version. + v.Supported = []string{str} + return nil + } + + // Try unmarshaling as the normal object structure. + type versionInfoAlias VersionInfo + var obj versionInfoAlias + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *v = VersionInfo(obj) + return nil +} + +// LatestVersion returns the most recent supported version. +// Returns empty string if no supported versions exist. +func (v VersionInfo) LatestVersion() string { + if len(v.Supported) == 0 { + return "" + } + return v.Supported[0] +} + +// ForTemplates returns a template-friendly representation of the registry +// with latest_version as a direct field for easier Jinja template access. +func (r Registry) ForTemplates() map[string]map[string]any { + result := make(map[string]map[string]any, len(r)) + for key, img := range r { + result[key] = map[string]any{ + "name": img.Name, + "type": img.Type, + "is_runtime": img.IsRuntime, + "latest_version": img.Versions.LatestVersion(), + "versions": img.Versions, + } + } + return result +} diff --git a/internal/lint/registry/model_test.go b/internal/lint/registry/model_test.go new file mode 100644 index 000000000..e6b1f8047 --- /dev/null +++ b/internal/lint/registry/model_test.go @@ -0,0 +1,174 @@ +package registry_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/lint/registry" +) + +func TestVersionInfo_UnmarshalJSON(t *testing.T) { + cases := []struct { + name string + json string + expected registry.VersionInfo + }{ + { + name: "object_with_supported_versions", + json: `{"supported": ["8.4", "8.3"], "deprecated": ["8.0"]}`, + expected: registry.VersionInfo{ + Supported: []string{"8.4", "8.3"}, + Deprecated: []string{"8.0"}, + }, + }, + { + name: "string_version", + json: fmt.Sprintf(`%q`, registry.ChannelStable), + expected: registry.VersionInfo{ + Supported: []string{registry.ChannelStable}, + }, + }, + { + name: "empty_object", + json: `{}`, + expected: registry.VersionInfo{ + Supported: nil, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var v registry.VersionInfo + err := json.Unmarshal([]byte(c.json), &v) + require.NoError(t, err) + assert.Equal(t, c.expected, v) + }) + } +} + +func TestVersionInfo_LatestVersion(t *testing.T) { + cases := []struct { + name string + versions registry.VersionInfo + expected string + }{ + { + name: "returns_first_supported_version", + versions: registry.VersionInfo{ + Supported: []string{"8.4", "8.3", "8.2"}, + }, + expected: "8.4", + }, + { + name: "returns_single_version", + versions: registry.VersionInfo{ + Supported: []string{"22"}, + }, + expected: "22", + }, + { + name: "returns_empty_when_no_versions", + versions: registry.VersionInfo{ + Supported: []string{}, + }, + expected: "", + }, + { + name: "returns_empty_when_nil", + versions: registry.VersionInfo{ + Supported: nil, + }, + expected: "", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := c.versions.LatestVersion() + assert.Equal(t, c.expected, result) + }) + } +} + +func TestVersionInfo_LatestVersion_WithRealRegistry(t *testing.T) { + reg, err := registry.Parsed() + assert.NoError(t, err) + assert.NotEmpty(t, reg) + + // Test with real registry data. + if img, ok := reg["php"]; ok { + latest := img.Versions.LatestVersion() + assert.NotEmpty(t, latest, "PHP should have a latest version") + assert.Contains(t, img.Versions.Supported, latest, "Latest version should be in supported list") + if len(img.Versions.Supported) > 0 { + assert.Equal(t, img.Versions.Supported[0], latest, "Latest version should be first in supported list") + } + } + + if img, ok := reg["postgresql"]; ok { + latest := img.Versions.LatestVersion() + assert.NotEmpty(t, latest, "PostgreSQL should have a latest version") + } +} + +func TestRegistry_ForTemplates(t *testing.T) { + reg := registry.Registry{ + "php": registry.Image{ + Name: "PHP", + Type: "php", + IsRuntime: true, + Versions: registry.VersionInfo{ + Supported: []string{"8.4", "8.3"}, + }, + }, + "mariadb": registry.Image{ + Name: "MariaDB", + Type: "mariadb", + IsRuntime: false, + Versions: registry.VersionInfo{ + Supported: []string{"11.4", "11.0"}, + }, + }, + } + + result := reg.ForTemplates() + + assert.Len(t, result, 2) + + phpData, ok := result["php"] + assert.True(t, ok) + assert.Equal(t, "PHP", phpData["name"]) + assert.Equal(t, "php", phpData["type"]) + assert.Equal(t, true, phpData["is_runtime"]) + assert.Equal(t, "8.4", phpData["latest_version"]) + + mariaData, ok := result["mariadb"] + assert.True(t, ok) + assert.Equal(t, "MariaDB", mariaData["name"]) + assert.Equal(t, "mariadb", mariaData["type"]) + assert.Equal(t, false, mariaData["is_runtime"]) + assert.Equal(t, "11.4", mariaData["latest_version"]) +} + +func TestRegistry_ForTemplates_WithRealRegistry(t *testing.T) { + reg, err := registry.Parsed() + assert.NoError(t, err) + + result := reg.ForTemplates() + + assert.Equal(t, len(reg), len(result), "should have same number of entries") + + for key, img := range reg { + data, ok := result[key] + assert.True(t, ok, "should have entry for %s", key) + assert.Equal(t, img.Name, data["name"]) + assert.Equal(t, img.Type, data["type"]) + assert.Equal(t, img.IsRuntime, data["is_runtime"]) + assert.Equal(t, img.Versions.LatestVersion(), data["latest_version"]) + } +} diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go new file mode 100644 index 000000000..9b2dfbc7a --- /dev/null +++ b/internal/lint/registry/registry.go @@ -0,0 +1,98 @@ +package registry + +import ( + _ "embed" + "encoding/json" + "sync" +) + +// ChannelStable is the current stable NixOS channel, used for composable images. +const ChannelStable = "25.11" + +//go:embed registry.json +var Data []byte + +var parsedRegistry Registry +var parsedOnce sync.Once + +func Parse(b []byte) (reg Registry, err error) { + err = json.Unmarshal(b, ®) + if err == nil { + clean(reg) + } + return +} + +func Parsed() (Registry, error) { + var err error + parsedOnce.Do(func() { + parsedRegistry, err = Parse(Data) + }) + return parsedRegistry, err +} + +// clean reduces irrelevant information in a registry, for the purposes of this project. +func clean(reg Registry) { + for k, img := range reg { + // Remove deprecated version info. + img.Versions.Deprecated = nil + // Remove descriptions. + img.Description = "" + reg[k] = img + } + + // Lisp no longer has its own runtime image. + // TODO remove this when it's removed upstream + delete(reg, "lisp") + + // Add missing images. + if _, ok := reg["clickhouse"]; !ok { + reg["clickhouse"] = Image{ + Name: "ClickHouse", + Type: "clickhouse", + Versions: VersionInfo{Supported: []string{"25.3", "24.3", "23.8"}}, + } + } + if _, ok := reg["gotenberg"]; !ok { + reg["gotenberg"] = Image{ + Name: "Gotenberg", + Type: "gotenberg", + Versions: VersionInfo{Supported: []string{"8"}}, + } + } + if _, ok := reg["composable"]; !ok { + reg["composable"] = Image{ + Name: "Composable image", + Type: "composable", + Versions: VersionInfo{Supported: []string{ChannelStable}}, + IsRuntime: true, + } + } + if _, ok := reg["redis-persistent"]; !ok { + // Treat "redis-persistent" as a copy of "redis". + if redis, ok := reg["redis"]; ok { + reg["redis-persistent"] = Image{ + Name: "Redis (persistent)", + Type: "redis-persistent", + Versions: redis.Versions, + } + redis.Name = "Redis (ephemeral)" + reg["redis"] = redis + } + } + if _, ok := reg["valkey"]; !ok { + reg["valkey"] = Image{ + Name: "Valkey (ephemeral)", + Type: "valkey", + Versions: VersionInfo{Supported: []string{"8.0"}}, + } + reg["valkey-persistent"] = Image{ + Name: "Valkey (persistent)", + Type: "valkey-persistent", + Versions: VersionInfo{Supported: []string{"8.0"}}, + } + } + + // Treat "mysql" as an alias of "mariadb". + reg["mysql"] = reg["mariadb"] +} diff --git a/internal/lint/registry/registry.json b/internal/lint/registry/registry.json new file mode 100644 index 000000000..54766858d --- /dev/null +++ b/internal/lint/registry/registry.json @@ -0,0 +1,1359 @@ +{ + "chrome-headless": { + "description": "", + "disk": false, + "docs": { + "relationship_name": "chrome-headless", + "service_name": "chrome-headless", + "url": "/add-services/headless-chrome.html" + }, + "endpoint": "http", + "min_disk_size": null, + "name": "Headless Chrome", + "repo_name": "chrome-headless", + "runtime": false, + "type": "chrome-headless", + "versions": { + "deprecated": [ + "73", + "80", + "81", + "83", + "84", + "86" + ], + "supported": [ + "120", + "113", + "95", + "91" + ], + "legacy": [ + + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "95" + ] + } + }, + "dotnet": { + "description": "ASP.NET 5 application container.", + "repo_name": "dotnet", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/dotnet.html", + "web": { + "commands": { + "start": "dotnet application.dll" + }, + "locations": { + "/": { + "root": "wwwroot", + "allow": true, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "set -e", + "dotnet publish --output \"$PLATFORM_OUTPUT_DIR\" -p:UseRazorBuildServer=false -p:UseSharedCompilation=false" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "C#/.Net Core", + "runtime": true, + "type": "dotnet", + "versions": { + "deprecated": [ + "5.0", + "3.1", + "2.2", + "2.1", + "2.0" + ], + "supported": [ + "7.0", + "6.0", + "8.0" + ], + "legacy": [ + "3.1", + "2.2", + "2.1", + "2.0" + ] + } + }, + "elasticsearch": { + "description": "A manufacture service for Elasticsearch", + "disk": true, + "docs": { + "relationship_name": "elasticsearch", + "service_name": "elasticsearch", + "url": "/add-services/elasticsearch.html" + }, + "endpoint": "elasticsearch", + "min_disk_size": 256, + "name": "Elasticsearch", + "repo_name": "elasticsearch", + "runtime": false, + "type": "elasticsearch", + "versions": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5", + "5.4", + "5.2", + "2.4", + "1.7", + "1.4" + ], + "supported": [ + "8.5", + "7.17" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "7.2", + "7.5", + "7.6", + "7.7", + "7.9", + "7.10", + "7.17", + "8.5", + "8.8" + ], + "deprecated": [ + "6.8", + "6.5", + "5.6", + "5.2", + "2.4", + "1.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5" + ], + "supported": [] + } + }, + "elixir": { + "description": "", + "repo_name": "elixir", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/elixir.html", + "web": { + "commands": { + "start": "mix run --no-halt" + }, + "locations": { + "/": { + "allow": false, + "root": "web", + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "mix local.hex --force", + "mix local.rebar --force", + "mix do deps.get --only prod, deps.compile, compile" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Elixir", + "runtime": true, + "type": "elixir", + "versions": { + "deprecated": [ + "1.13", + "1.12", + "1.11", + "1.10", + "1.9" + ], + "supported": [ + "1.18", + "1.15", + "1.14" + ], + "legacy": [ + "1.10", + "1.9" + ] + } + }, + "golang": { + "description": "", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/go.html", + "web": { + "upstream": { + "socket_family": "tcp", + "protocol": "http" + }, + "commands": { + "start": "./bin/app" + }, + "locations": { + "/": { + "allow": false, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "go build -o bin/app" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Go", + "repo_name": "golang", + "runtime": true, + "type": "golang", + "versions": { + "deprecated": [ + "1.19", + "1.18", + "1.17", + "1.16", + "1.15", + "1.14", + "1.13", + "1.12", + "1.11", + "1.10", + "1.9", + "1.8" + ], + "supported": [ + "1.25", + "1.24", + "1.23", + "1.22", + "1.21", + "1.20" + ] + } + }, + "influxdb": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "influxdb", + "service_name": "influxdb", + "url": "/add-services/influxdb.html" + }, + "endpoint": "influxdb", + "min_disk_size": null, + "name": "InfluxDB", + "repo_name": "influxdb", + "runtime": false, + "type": "influxdb", + "versions": { + "deprecated": [ + "2.2", + "1.8", + "1.7", + "1.3", + "1.2" + ], + "supported": [ + "2.7", + "2.3" + ] + } + }, + "java": { + "description": "", + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/java.html", + "web": { + "commands": { + "start": "java -jar target/application.jar --server.port=$PORT" + } + }, + "hooks": { + "build": [ + "mvn clean install" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Java", + "repo_name": "java", + "runtime": true, + "type": "java", + "versions": { + "deprecated": [ + "14", + "13", + "12" + ], + "supported": [ + "21", + "19", + "18", + "17", + "11", + "8" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "17", + "11", + "8" + ], + "deprecated": [ + "15", + "13", + "7" + ] + } + }, + "kafka": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "kafka", + "service_name": "kafka", + "url": "/add-services/kafka.html" + }, + "endpoint": "kafka", + "min_disk_size": 512, + "name": "Kafka", + "repo_name": "kafka", + "runtime": false, + "type": "kafka", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ], + "supported": [ + "3.7", + "3.6", + "3.4", + "3.2" + ], + "legacy": [ + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ] + } + }, + "mariadb": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "mariadb", + "service_name": "mariadb", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mariadb", + "versions": { + "deprecated": [ + "11.2", + "11.0", + "10.5", + "10.4", + "10.3", + "10.2", + "10.1", + "10.0", + "5.5" + ], + "supported": [ + "11.8", + "11.4", + "10.11", + "10.6" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "11.4 Galera", + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera" + ], + "deprecated": [ + "11.2 Galera", + "10.2 Galera", + "10.1 Galera", + "10.0 Galera", + "10.3 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "mysql": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "mysql", + "service_name": "mysql", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mysql", + "versions": { + "deprecated": [ + "11.2", + "10.4", + "10.2", + "10.1", + "10.0", + "5.5" + ], + "supported": [ + "11.0", + "10.11", + "10.6", + "10.5", + "10.4", + "10.3" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "11.4 Galera", + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.5 Galera" + ], + "deprecated": [ + "11.2 Galera", + "10.4 Galera", + "10.3 Galera", + "10.2 Galera", + "10.1 Galera", + "10.0 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "memcached": { + "description": "Memcached service.", + "repo_name": "memcached", + "disk": false, + "docs": { + "relationship_name": "memcached", + "service_name": "memcached", + "url": "/add-services/memcached.html" + }, + "endpoint": "memcached", + "min_disk_size": null, + "name": "Memcached", + "runtime": false, + "type": "memcached", + "versions": { + "deprecated": [], + "supported": [ + "1.6", + "1.5", + "1.4" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "1.5", + "1.4" + ] + } + }, + "mongodb": { + "description": "Experimental MongoDB support on Upsun Fixed", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodb", + "service_name": "mongodb", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb", + "versions": { + "deprecated": [ + "4.0.3", + "3.6", + "3.4", + "3.2", + "3.0" + ], + "supported": [] + } + }, + "mongodb-enterprise": { + "description": "Support for the enterprise edition of MongoDB", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodb-enterprise", + "service_name": "mongodb-enterprise", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb-enterprise", + "premium": true, + "versions": { + "supported": [ + "7.0", + "6.0", + "5.0", + "4.4" + ], + "deprecated": [ + "4.2", + "4.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "7.0", + "6.0", + "5.0", + "4.4" + ], + "deprecated": [ + "4.2", + "4.0" + ] + } + }, + "network-storage": { + "description": "", + "repo_name": "network-storage", + "disk": true, + "docs": { + "relationship_name": "null", + "service_name": "network-storage", + "url": "/add-services/network-storage.html" + }, + "endpoint": "something", + "min_disk_size": null, + "name": "Network Storage", + "runtime": false, + "type": "network-storage", + "versions": { + "deprecated": [ + "2.0" + ], + "supported": [ + "1.0" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2.0" + ] + } + }, + "nodejs": { + "description": "NodeJS service for Upsun Fixed", + "repo_name": "nodejs", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/nodejs.html", + "web": { + "commands": { + "start": "node index.js" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "JavaScript/Node.js", + "runtime": true, + "type": "nodejs", + "versions": { + "deprecated": [ + "18", + "16", + "14", + "12", + "10", + "8", + "6", + "4.8", + "4.7", + "0.12" + ], + "supported": [ + "24", + "22", + "20" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "22", + "20", + "19" + ], + "deprecated": [ + "18", + "16", + "14", + "12", + "10", + "9.8" + ] + } + }, + "opensearch": { + "description": "A manufacture service for OpenSearch", + "disk": true, + "docs": { + "relationship_name": "opensearch", + "service_name": "opensearch", + "url": "/add-services/opensearch.html" + }, + "endpoint": "opensearch", + "min_disk_size": 256, + "name": "OpenSearch", + "repo_name": "opensearch", + "runtime": false, + "type": "opensearch", + "versions": { + "deprecated": [ + "1.3", + "1.2", + "1.1" + ], + "supported": [ + "3", + "2" + ], + "legacy": [ + "1.1" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [], + "supported": [ + "1.2", + "1.99", + "2.12", + "2.14", + "2.5", + "2.18", + "2.19", + "2.99" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2" + ] + } + }, + "oracle-mysql": { + "description": "Images using MySQL from Oracle instead of MariaDB still providing mysql endpoints", + "repo_name": "oracle-mysql", + "disk": true, + "docs": { + "relationship_name": "oracle-mysql", + "service_name": "oracle-mysql", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "Oracle MySQL", + "runtime": false, + "type": "oracle-mysql", + "versions": { + "deprecated": [], + "supported": [ + "8.0", + "5.7" + ] + } + }, + "php": { + "description": "PHP service for Upsun Fixed.", + "repo_name": "php", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/php.html", + "web": { + "locations": { + "/": { + "root": "web", + "passthru": "/index.php" + } + } + }, + "hooks": { + "build": [ + "|", + "set -e" + ], + "deploy": [ + "|", + "set -e" + ] + }, + "build": { + "flavor": "composer" + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "PHP", + "runtime": true, + "type": "php", + "versions-dedicated-gen-2": { + "supported": [ + "8.5", + "8.4", + "8.3", + "8.2", + "8.1", + "8.0" + ], + "deprecated": [ + "7.4", + "7.3", + "7.2", + "7.1", + "7.0" + ] + }, + "versions": { + "deprecated": [ + "8.0", + "7.4", + "7.3", + "7.2", + "7.1", + "7.0", + "5.6", + "5.5", + "5.4" + ], + "supported": [ + "8.5", + "8.4", + "8.3", + "8.2", + "8.1" + ] + } + }, + "postgresql": { + "description": "PostgreSQL service for Upsun Fixed.", + "repo_name": "postgresql", + "disk": true, + "docs": { + "relationship_name": "postgresql", + "service_name": "postgresql", + "url": "/add-services/postgresql.html" + }, + "endpoint": "postgresql", + "min_disk_size": null, + "name": "PostgreSQL", + "runtime": false, + "type": "postgresql", + "versions": { + "deprecated": [ + "11", + "10", + "9.6", + "9.5", + "9.4", + "9.3" + ], + "supported": [ + "18", + "17", + "16", + "15", + "14", + "13", + "12" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [ + "11*", + "9.6*", + "9.5", + "9.4", + "9.3" + ], + "supported": [] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "11", + "10" + ], + "supported": [ + "17", + "16", + "15", + "14", + "13", + "12" + ] + } + }, + "python": { + "description": "", + "repo_name": "python", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/python.html", + "web": { + "commands": { + "start": "python server.py" + } + }, + "hooks": { + "build": [ + "|", + "pipenv install --system --deploy" + ] + }, + "dependencies": { + "python3": { + "pipenv": "2018.10.13" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Python", + "runtime": true, + "type": "python", + "versions": { + "deprecated": [ + "3.7", + "3.6", + "3.5", + "2.7*" + ], + "supported": [ + "3.13", + "3.12", + "3.11", + "3.10", + "3.9", + "3.8" + ] + } + }, + "rabbitmq": { + "description": "A manufacture-based container for RabbitMQ", + "repo_name": "rabbitmq", + "disk": true, + "docs": { + "relationship_name": "rabbitmq", + "service_name": "rabbitmq", + "url": "/add-services/rabbitmq.html" + }, + "endpoint": "rabbitmq", + "min_disk_size": 512, + "name": "RabbitMQ", + "runtime": false, + "type": "rabbitmq", + "versions": { + "deprecated": [ + "3.11", + "3.10", + "3.9", + "3.8", + "3.7", + "3.6", + "3.5" + ], + "supported": [ + "4.1", + "4.0", + "3.13", + "3.12" + ], + "legacy": [ + "3.8", + "3.7", + "3.6", + "3.5" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "4.1", + "4.0", + "3.13", + "3.12" + ], + "deprecated": [ + "3.11", + "3.10", + "3.9", + "3.8", + "3.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "3.11", + "3.10", + "3.9" + ], + "supported": [ + "3.13", + "3.12" + ] + } + }, + "redis": { + "description": "A manufacture-based Redis container ", + "repo_name": "redis", + "disk": false, + "docs": { + "relationship_name": "redis", + "service_name": "redis", + "url": "/add-services/redis.html" + }, + "endpoint": "redis", + "min_disk_size": null, + "name": "Redis", + "runtime": false, + "type": "redis", + "versions": { + "deprecated": [ + "7.0", + "6.2", + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "8.0", + "7.2" + ], + "legacy": [ + "6.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.0", + "7.4", + "7.2" + ], + "deprecated": [ + "7.0", + "6.2", + "6.0", + "5.0", + "3.2" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "7.0", + "6.2", + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "8.0", + "7.2" + ] + } + }, + "ruby": { + "description": "", + "repo_name": "ruby", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/ruby.html", + "web": { + "upstream": { + "socket_family": "unix" + }, + "commands": { + "start": "unicorn -l $SOCKET -E production config.ru" + }, + "locations": { + "/": { + "root": "public", + "passthru": true, + "expires": "1h", + "allow": true + } + } + }, + "hooks": { + "build": [ + "|", + "bundle install --without development test" + ], + "deploy": [ + "|", + "RACK_ENV=production bundle exec rake db:migrate" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Ruby", + "runtime": true, + "type": "ruby", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ], + "supported": [ + "3.4", + "3.3", + "3.2", + "3.1", + "3.0" + ], + "legacy": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ], + "supported": [ + "3.3", + "3.2", + "3.1", + "3.0" + ], + "legacy": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ] + } + }, + "rust": { + "description": "", + "repo_name": "rust", + "disk": true, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/rust.html", + "web": { + "commands": { + "start": "./target/debug/hello" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Rust", + "runtime": true, + "type": "rust", + "versions": { + "deprecated": [], + "supported": [ + "1" + ] + } + }, + "solr": { + "description": "", + "repo_name": "solr", + "disk": true, + "docs": { + "relationship_name": "solr", + "service_name": "solr", + "url": "/add-services/solr.html" + }, + "endpoint": "solr", + "min_disk_size": 256, + "name": "Solr", + "runtime": false, + "type": "solr", + "versions": { + "deprecated": [ + "8.11", + "8.6", + "8.4", + "8.0", + "7.7", + "7.6", + "6.6", + "6.3", + "4.10", + "3.6" + ], + "supported": [ + "9.9", + "9.6", + "9.4", + "9.2", + "9.1" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "9.9", + "9.7", + "9.6", + "9.4", + "9.2", + "9.1", + "9.0" + ], + "deprecated": [ + "8.11", + "8.6", + "8.0", + "7.7", + "6.6", + "6.3", + "4.10" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "9.6", + "9.4", + "9.2", + "9.1" + + ], + "deprecated": [ + "8.11" + ] + } + }, + "valkey": { + "description": "", + "repo_name": "valkey", + "disk": false, + "docs": { + "relationship_name": "valkey", + "service_name": "valkey", + "url": "/add-services/valkey.html" + }, + "endpoint": "http+stats", + "min_disk_size": null, + "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", + "service_relationships": "application: 'app:http'", + "name": "Valkey", + "runtime": false, + "type": "valkey", + "versions": { + "supported": [ + "8.1", + "8.0" + ], + "deprecated": [] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.1", + "8.0" + ], + "deprecated": [] + } + }, + "varnish": { + "description": "", + "repo_name": "varnish", + "disk": false, + "docs": { + "relationship_name": "varnish", + "service_name": "varnish", + "url": "/add-services/varnish.html" + }, + "endpoint": "http+stats", + "min_disk_size": null, + "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", + "service_relationships": "application: 'app:http'", + "name": "Varnish", + "runtime": false, + "type": "varnish", + "versions": { + "deprecated": [ + "5.1", + "5.2", + "6.3", + "6.4", + "7.1" + ], + "supported": [ + "7.6", + "7.3", + "7.2", + "6.0" + ] + } + }, + "vault-kms": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "vault-kms", + "service_name": "vault-kms", + "url": "/add-services/vault.html" + }, + "endpoint": "manage_keys", + "min_disk_size": 512, + "configuration": " configuration:\n endpoints:\n :\n - policy: \n key: \n type: ", + "name": "Vault KMS", + "repo_name": "vault-kms", + "runtime": false, + "type": "vault-kms", + "versions": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ], + "legacy": [ + "1.6" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "1.6" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ] + } + } +} diff --git a/internal/lint/registry/registry_test.go b/internal/lint/registry/registry_test.go new file mode 100644 index 000000000..d7cc6799f --- /dev/null +++ b/internal/lint/registry/registry_test.go @@ -0,0 +1,19 @@ +package registry_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/lint/registry" +) + +// Basic test to ensure that the registry can be parsed. +func TestRegistry(t *testing.T) { + reg, err := registry.Parsed() + assert.NoError(t, err) + assert.NotEmpty(t, reg) + assert.Contains(t, reg, "golang") + assert.Contains(t, reg["golang"].Versions.Supported, "1.24") + assert.Contains(t, reg, "redis-persistent") +} diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go new file mode 100644 index 000000000..fa41c363e --- /dev/null +++ b/internal/lint/relationships.go @@ -0,0 +1,89 @@ +package lint + +import ( + "fmt" + "strings" +) + +// CheckRelationships checks that application relationships correspond to existing services. +func CheckRelationships(cfg *Config) *Result { + result := &Result{} + + var serviceNames = make(map[string]string) + addService := func(name, source string) string { + if prevSource, exists := serviceNames[name]; exists { + return fmt.Sprintf("duplicate name found: '%s' in '%s' (previous in '%s')", name, source, prevSource) + } + serviceNames[name] = source + return "" + } + for appName, app := range cfg.Applications { + if msg := addService(appName, "applications"); msg != "" { + result.AddError("applications."+appName, msg) + } + for name := range app.Workers { + if msg := addService(name, "applications."+appName+".workers."+name); msg != "" { + result.AddError("applications."+appName+".workers."+name, msg) + } + } + } + for name := range cfg.Services { + if msg := addService(name, "services"); msg != "" { + result.AddError("services."+name, msg) + } + } + + var linkedServices = make(map[string]struct{}) + for appName, appConfig := range cfg.Applications { + for relName, value := range appConfig.Relationships { + // By default, the relationship links to the service with the same name. + var relationshipService = relName + var explicit bool + + // The service name can also be specified explicitly, via a map or a string. + // TODO validate the endpoint + switch details := value.(type) { + case map[string]any: + if s, ok := details["service"].(string); ok { + relationshipService = s + explicit = true + } + case string: + relationshipService = strings.SplitN(details, ":", 2)[0] + explicit = true + } + + if _, exists := serviceNames[relationshipService]; exists { + linkedServices[relationshipService] = struct{}{} + } else { + var msg string + if explicit { + msg = fmt.Sprintf( + "relationship '%s' in application '%s' points to a service (or app) named '%s' which is not found", + relName, + appName, + relationshipService, + ) + } else { + msg = fmt.Sprintf( + "relationship '%s' in application '%s' does not match any service (or app)", + relName, + appName, + ) + } + if len(cfg.Services) == 0 { + msg += " (did you forget to define services?)" + } + result.AddError("applications."+appName+".relationships."+relName, msg) + } + } + } + + for name := range cfg.Services { + if _, linked := linkedServices[name]; !linked { + result.AddError("services."+name, fmt.Sprintf("no application has a relationship to service '%s'", name)) + } + } + + return result +} diff --git a/internal/lint/relationships_test.go b/internal/lint/relationships_test.go new file mode 100644 index 000000000..9439ca1e0 --- /dev/null +++ b/internal/lint/relationships_test.go @@ -0,0 +1,120 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/lint" +) + +func TestCheckRelationships(t *testing.T) { + cases := []struct { + name string + content string + expectErrorMessage string + }{ + { + name: "correct_single_short", + // N.B. YAML requires spaces for indents, not tabs. + content: ` +applications: + foo: + relationships: + database: +services: + database: + type: mariadb:11.4`, + }, + { + name: "incorrect_single_short", + // N.B. YAML requires spaces for indents, not tabs. + content: ` +applications: + foo: + relationships: + database:`, + expectErrorMessage: "linter errors:\n - applications.foo.relationships.database: relationship 'database' in application 'foo' " + //nolint:lll + "does not match any service (or app) (did you forget to define services?)", + }, + { + name: "incorrect_single_explicit", + content: ` +applications: + foo: + relationships: + database: + service: mydb`, + expectErrorMessage: "linter errors:\n - applications.foo.relationships.database: relationship 'database' in application 'foo' " + //nolint:lll + "points to a service (or app) named 'mydb' which is not found (did you forget to define services?)", + }, + { + name: "incorrect_duplicate_service_names", + // N.B. YAML requires spaces for indents, not tabs. + content: ` +applications: + foo: {} +services: + foo: {}`, + expectErrorMessage: "linter errors:" + + "\n - services.foo: duplicate name found: 'foo' in 'services' (previous in 'applications')" + + "\n - services.foo: no application has a relationship to service 'foo'", + }, + { + name: "correct_multiapp", + content: ` +applications: + foo: + relationships: + database: + cache: + service: kv + bar: + relationships: + database: + foo: +services: + database: + type: mariadb:11.4 + kv: + type: valkey:8.0`, + }, + { + name: "incorrect_multiapp", + content: ` +applications: + foo: + relationships: + database: + cache: + service: kv + bar: + relationships: + postgres: +services: + database: + type: mariadb:11.4 + kv: + type: valkey:8.0`, + expectErrorMessage: "linter errors:\n - applications.bar.relationships.postgres: relationship 'postgres' in application 'bar' " + //nolint:lll + "does not match any service (or app)", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + if err != nil { + assert.Equal(t, c.expectErrorMessage, err.Error()) + return + } + result := lint.CheckRelationships(cfg) + if c.expectErrorMessage != "" { + assert.True(t, result.HasErrors() || result.HasWarnings()) + assert.Equal(t, c.expectErrorMessage, result.Error()) + } else { + assert.False(t, result.HasErrors()) + } + }) + } +} diff --git a/internal/lint/result.go b/internal/lint/result.go new file mode 100644 index 000000000..4a784ccb2 --- /dev/null +++ b/internal/lint/result.go @@ -0,0 +1,111 @@ +package lint + +import ( + "fmt" + "sort" + "strings" +) + +// Result contains the results of linting, including both errors and warnings. +type Result struct { + Errors []Issue + Warnings []Issue +} + +// Issue represents a single linting problem with its location in the content. +type Issue struct { + Path string // e.g., "applications.foo.type", "services.database" + Message string +} + +// AddError adds an error to the linter result. +func (r *Result) AddError(path, message string) { + r.Errors = append(r.Errors, Issue{ + Path: path, + Message: message, + }) +} + +// AddWarning adds a warning to the linter result. +func (r *Result) AddWarning(path, message string) { + r.Warnings = append(r.Warnings, Issue{ + Path: path, + Message: message, + }) +} + +// HasErrors returns true if the validation result contains any errors. +func (r *Result) HasErrors() bool { + return len(r.Errors) > 0 +} + +// HasWarnings returns true if the validation result contains any warnings. +func (r *Result) HasWarnings() bool { + return len(r.Warnings) > 0 +} + +// formatIssues formats a list of validation issues with a given prefix. +func formatIssues(issues []Issue, prefix string) []string { + if len(issues) == 0 { + return nil + } + + var messages []string + for _, issue := range issues { + if issue.Path != "" { + messages = append(messages, fmt.Sprintf(" - %s: %s", issue.Path, issue.Message)) + } else { + messages = append(messages, fmt.Sprintf(" - %s", issue.Message)) + } + } + + // Sort the issue messages for consistent ordering. + sort.Strings(messages) + + // Add prefix at the beginning. + return append([]string{prefix}, messages...) +} + +// formatResult formats all lint issues with appropriate capitalization. +func (r *Result) formatResult(capitalize bool) string { + var messages []string + + errorPrefix := "linter errors:" + warningPrefix := "linter warnings:" + if capitalize { + errorPrefix = "Linter errors:" + warningPrefix = "Linter warnings:" + } + + if errorMessages := formatIssues(r.Errors, errorPrefix); errorMessages != nil { + messages = append(messages, errorMessages...) + } + + if warningMessages := formatIssues(r.Warnings, warningPrefix); warningMessages != nil { + messages = append(messages, warningMessages...) + } + + return strings.Join(messages, "\n") +} + +// Error returns a formatted string representation of all validation issues. +func (r *Result) Error() string { + return r.formatResult(false) +} + +// String returns a formatted string representation of all validation issues. +func (r *Result) String() string { + return r.formatResult(true) +} + +// Combine combines a list of validation results. +func Combine(results ...*Result) *Result { + result := &Result{} + for _, r := range results { + if r != nil { + result.Errors = append(result.Errors, r.Errors...) + result.Warnings = append(result.Warnings, r.Warnings...) + } + } + return result +} diff --git a/internal/lint/routes.go b/internal/lint/routes.go new file mode 100644 index 000000000..c2f112bb1 --- /dev/null +++ b/internal/lint/routes.go @@ -0,0 +1,84 @@ +package lint + +import ( + "fmt" + "strings" +) + +// CheckRoutes validates that upstream routes link to existing applications or services. +func CheckRoutes(cfg *Config) *Result { + result := &Result{} + + // Check if at least 1 route is defined when multiple applications exist + if len(cfg.Applications) > 1 && len(cfg.Routes) == 0 { + result.AddError("routes", "at least 1 route must be defined when multiple applications are defined") + } + + // Build a set of valid upstream targets (app names and service names) + validTargets := make(map[string]bool) + + // Add applications as valid targets + for appName := range cfg.Applications { + validTargets[appName] = true + } + + // Add services as valid targets + for serviceName := range cfg.Services { + validTargets[serviceName] = true + } + + // Check each upstream route + for routeURL, route := range cfg.Routes { + if route.Type != "upstream" { + continue + } + if route.Upstream == "" { + result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), + "upstream property is required for routes with type 'upstream'") + continue + } + + // Parse the upstream value (format: "appname:protocol" or "servicename:protocol") + upstream := route.Upstream + parts := strings.SplitN(upstream, ":", 2) + if len(parts) != 2 { + result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), + fmt.Sprintf("upstream '%s' must be in format 'name:protocol'", upstream)) + continue + } + + targetName := parts[0] + protocol := parts[1] + + // Check if the target exists + if !validTargets[targetName] { + // Build a helpful error message listing available targets + var availableTargets []string + for target := range validTargets { + availableTargets = append(availableTargets, target) + } + + if len(availableTargets) == 0 { + result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), + fmt.Sprintf("upstream target '%s' does not exist (no applications or services defined)", targetName)) + } else { + result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), + fmt.Sprintf("upstream target '%s' does not exist, available targets: %s", + targetName, strings.Join(availableTargets, ", "))) + } + } else { + // Target exists, validate the protocol is appropriate + if _, isApp := cfg.Applications[targetName]; isApp { + // For applications, protocol must be 'http' + if protocol != "http" { + result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), + fmt.Sprintf("protocol '%s' is not valid for application '%s'; must be 'http'", protocol, targetName)) + } + } + // For services, protocol validation is complex and not implemented yet + // No validation performed for service protocols + } + } + + return result +} diff --git a/internal/lint/routes_test.go b/internal/lint/routes_test.go new file mode 100644 index 000000000..ccfc38d77 --- /dev/null +++ b/internal/lint/routes_test.go @@ -0,0 +1,234 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/lint" +) + +func TestCheckRoutes(t *testing.T) { + cases := []struct { + name string + content string + expectErrorMessage string + }{ + { + name: "valid_upstream_to_app", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: myapp:http`, + }, + { + name: "valid_upstream_to_service", + content: ` +services: + api: + type: mariadb:11.4 +routes: + "https://{default}/api": + type: upstream + upstream: api:http`, + }, + { + name: "valid_multiple_routes", + content: ` +applications: + frontend: + type: nodejs:22 + backend: + type: php:8.4 +services: + database: + type: postgresql:15 +routes: + "https://{default}/": + type: upstream + upstream: frontend:http + "https://{default}/api": + type: upstream + upstream: backend:http + "https://{default}/db": + type: upstream + upstream: database:http`, + }, + { + name: "valid_redirect_route", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: myapp:http + "https://www.{default}/": + type: redirect + to: "https://{default}/"`, + }, + { + name: "missing_upstream_property", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: upstream property is required for routes with type 'upstream'", //nolint:lll + }, + { + name: "invalid_upstream_format", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: myapp_without_protocol`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: upstream 'myapp_without_protocol' must be in format 'name:protocol'", //nolint:lll + }, + { + name: "nonexistent_upstream_target", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: nonexistent:http`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: upstream target 'nonexistent' does not exist, available targets: myapp", //nolint:lll + }, + { + name: "no_apps_or_services", + content: ` +routes: + "https://{default}/": + type: upstream + upstream: myapp:http`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: upstream target 'myapp' does not exist (no applications or services defined)", //nolint:lll + }, + { + name: "multiple_invalid_routes", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: nonexistent1:http + "https://{default}/api": + type: upstream + upstream: nonexistent2:http`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: upstream target 'nonexistent1' does not exist, available targets: myapp\n - routes[\"https://{default}/api\"].upstream: upstream target 'nonexistent2' does not exist, available targets: myapp", //nolint:lll + }, + { + name: "invalid_protocol_for_app", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: myapp:fastcgi`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: protocol 'fastcgi' is not valid for application 'myapp'; must be 'http'", //nolint:lll + }, + { + name: "any_protocol_valid_for_service", + content: ` +services: + database: + type: postgresql:15 +routes: + "https://{default}/": + type: upstream + upstream: database:custom_protocol`, + }, + + { + name: "custom_protocol_for_app_error", + content: ` +applications: + myapp: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: myapp:custom_protocol`, + expectErrorMessage: "linter errors:\n - routes[\"https://{default}/\"].upstream: protocol 'custom_protocol' is not valid for application 'myapp'; must be 'http'", //nolint:lll + }, + { + name: "complex_route_urls", + content: ` +applications: + app1: + type: nodejs:22 + app2: + type: php:8.4 +routes: + "https://site1.{default}/": + type: upstream + upstream: app1:http + "https://site2.{default}/admin": + type: upstream + upstream: app2:http`, + }, + { + name: "single_app_no_routes_allowed", + content: ` +applications: + myapp: + type: php:8.4`, + }, + { + name: "multiple_apps_no_routes_error", + content: ` +applications: + app1: + type: nodejs:22 + app2: + type: php:8.4`, + expectErrorMessage: "linter errors:\n - routes: at least 1 route must be defined when multiple applications are defined", //nolint:lll + }, + { + name: "multiple_apps_with_routes_valid", + content: ` +applications: + app1: + type: nodejs:22 + app2: + type: php:8.4 +routes: + "https://{default}/": + type: upstream + upstream: app1:http`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + if err != nil { + assert.FailNow(t, "DecodeConfig failed", err) + } + result := lint.CheckRoutes(cfg) + if c.expectErrorMessage != "" { + assert.True(t, result.HasErrors() || result.HasWarnings()) + assert.Equal(t, c.expectErrorMessage, result.Error()) + } else { + assert.False(t, result.HasErrors()) + assert.False(t, result.HasWarnings()) + } + }) + } +} diff --git a/internal/lint/schema/schema.go b/internal/lint/schema/schema.go new file mode 100644 index 000000000..d6d52bdd1 --- /dev/null +++ b/internal/lint/schema/schema.go @@ -0,0 +1,24 @@ +package schema + +import ( + _ "embed" + "sync" + + "github.com/xeipuuv/gojsonschema" +) + +var ( + //go:embed upsun-config-schema.json + configSchema []byte + parsedSchema *gojsonschema.Schema + parseSchemaOnce sync.Once + parseSchemaErr error +) + +// Load loads the Upsun (Flex-style) configuration schema. +func Load() (*gojsonschema.Schema, error) { + parseSchemaOnce.Do(func() { + parsedSchema, parseSchemaErr = gojsonschema.NewSchema(gojsonschema.NewBytesLoader(configSchema)) + }) + return parsedSchema, parseSchemaErr +} diff --git a/internal/lint/schema/schema_test.go b/internal/lint/schema/schema_test.go new file mode 100644 index 000000000..f715878d3 --- /dev/null +++ b/internal/lint/schema/schema_test.go @@ -0,0 +1,27 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" +) + +func TestLoadValidatesConfig(t *testing.T) { + s, err := Load() + require.NoError(t, err, "Failed to load schema") + + testYAML := ` +applications: + myapp: + type: nodejs:22 +` + var data = make(map[string]any) + require.NoError(t, yaml.Unmarshal([]byte(testYAML), &data)) + + result, err := s.Validate(gojsonschema.NewGoLoader(data)) + require.NoError(t, err) + assert.True(t, result.Valid(), "Schema should accept a basic config: %v", result.Errors()) +} diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json new file mode 100644 index 000000000..11e536d78 --- /dev/null +++ b/internal/lint/schema/upsun-config-schema.json @@ -0,0 +1,1285 @@ +{ + "title": "Upsun configuration file", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "applications": { + "description": "For more information, see https://docs.upsun.com/anchors/app/reference/root-keys/", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "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/" + }, + "stack": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + } + ], + "title": "Composable Image definition", + "description": "A list of packages from the Upsun collection of supported runtimes and/or from NixPkgs. \nMore information: \nhttps://docs.upsun.com/anchors/app/composable/" + }, + "source": { + "type": "object", + "properties": { + "root": { + "type": "string", + "title": "The root of the application relative to the repository root", + "description": " \nDefaults to the root project directory. \nUseful for multi-app setups: \nhttps://docs.upsun.com/anchors/app/multiple/", + "default": null + }, + "operations": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "command": { + "type": "string", + "title": "Operations that can be applied to the source code", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/source-operations/" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "title": "Operations that can be applied to the source code", + "default": {} + } + }, + "additionalProperties": false, + "title": "Information on the app’s source code and operations that can be run on it", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/source/operations/", + "default": { + "operations": {}, + "root": null + } + }, + "resources": {"$ref": "#/definitions/deprecated/resources"}, + "relationships": {"$ref": "#/definitions/relationships"}, + "mounts": {"$ref": "#/definitions/mounts"}, + "web": { + "type": "object", + "title": "How the web application is served", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/", + "properties": { + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + }, + "title": "Variables provide environment-sensitive information to control how your application behaves", + "description": "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", + "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified." + }, + "mounts": {"$ref": "#/definitions/mounts"}, + "relationships": {"$ref": "#/definitions/relationships"}, + "access": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Access information, a mapping between access type and roles", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/access/" + }, + "size": {"$ref": "#/definitions/deprecated/size"}, + "resources": {"$ref": "#/definitions/deprecated/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", + "description": "The directory to serve static assets for this location relative to the app’s root directory. Must be an actual directory inside the root directory. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/source/root/", + "default": "public" + }, + "expires": { + "type": ["integer", "string"], + "title": "Amount of time to cache static assets", + "description": "How long static assets are cached. The default means no caching. Setting it to a value enables the `Cache-Control` and `Expires` headers. Times can be suffixed with `ms` = milliseconds, `s` = seconds, `m` = minutes, `h` = hours, `d` = days, `w` = weeks, `M` = months/30d, or `y` = years/365d. If a `Cache-Control` appears on the `headers` configuration, `expires`, if set, will be ignored. Thus, make sure to set the `Cache-Control`’s `max-age` value when specifying a the header.", + "default": -1 + }, + "passthru": { + "type": [ + "string", + "boolean" + ], + "title": "Whether to forward disallowed and missing resources from this location to the app", + "description": "Whether to forward disallowed and missing resources from this location to the application. On PHP, set to the PHP front controller script, as a URL fragment. Otherwise set to `true`/`false`.", + "default": false + }, + "scripts": { + "type": "boolean", + "title": "Whether to allow scripts to run", + "description": "Doesn't apply to paths specified in `passthru`. Meaningful only on PHP containers.", + "default": true + }, + "index": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Files to look for to serve directories", + "description": "Files to consider when serving a request for a directory. When set, requires access to the files through the `allow` or `rules` keys." + }, + "allow": { + "type": "boolean", + "title": "Whether to allow serving files which don’t match a rule", + "default": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "A set of header fields set to the HTTP response", + "description": "Any additional headers to apply to static assets, mapping header names to values. Responses from the app aren’t affected. \nSee how to set custom headers on static content: \nhttps://docs.upsun.com/anchors/app/web/custom-headers/", + "default": {} + }, + "rules": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "expires": { + "type": ["integer", "string"], + "title": "Amount of time to cache static assets", + "description": "How long static assets are cached. The default means no caching. Setting it to a value enables the `Cache-Control` and `Expires` headers. Times can be suffixed with `ms` = milliseconds, `s` = seconds, `m` = minutes, `h` = hours, `d` = days, `w` = weeks, `M` = months/30d, or `y` = years/365d. If a `Cache-Control` appears on the `headers` configuration, `expires`, if set, will be ignored. Thus, make sure to set the `Cache-Control`’s `max-age` value when specifying a the header.", + "default": -1 + }, + "passthru": { + "type": [ + "string", + "boolean" + ], + "title": "Whether to forward disallowed and missing resources from this location to the app", + "description": "On PHP, set to the PHP front controller script, as a URL fragment. Otherwise set to `true`/`false`.", + "default": true + }, + "scripts": { + "type": "boolean", + "title": "Whether to allow scripts to run", + "description": "Doesn't apply to paths specified in `passthru`. Meaningful only on PHP containers.", + "default": true + }, + "allow": { + "type": "boolean", + "title": "Whether to allow serving files which don’t match a rule", + "default": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "A set of header fields set to the HTTP response", + "description": "Any additional headers to apply to static assets, mapping header names to values. Responses from the app aren’t affected. \nSee how to set custom headers on static content: \nhttps://docs.upsun.com/anchors/app/web/custom-headers/" + } + }, + "additionalProperties": false + }, + "title": "Specific overrides for specific locations", + "description": "Contains a rules dictionary. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/web/locations/rules/", + "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", + "description": "Supports K, M, and G suffixes. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/request-buffering/", + "default": 262144000 + } + }, + "additionalProperties": false, + "title": "Configuration for supporting request buffering.", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/request-buffering/" + } + }, + "additionalProperties": false + }, + "title": "The specification of the web locations served by this application", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/", + "default": {} + }, + "commands": { + "type": "object", + "properties": { + "pre_start": { + "type": "string", + "title": "Command run just prior to `start`", + "description": "Which can be useful when you need to run per-instance actions." + }, + "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 or FrankenPHP. \nSee note: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/start/" + }, + "post_start": { + "type": "string", + "title": "A command executed after the application is started", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/" + } + }, + "additionalProperties": false, + "title": "The command to launch your app", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/commands/" + }, + "upstream": { + "type": "object", + "properties": { + "socket_family": { + "type": "string", + "title": "Whether your app listens on a Unix or TCP socket", + "description": "If `tcp`, check the PORT environment variable on application startup. If `unix`, check SOCKET. \nMore Information: \nhttps://docs.upsun.com/anchors/app/reference/web/upstream/", + "default": "tcp" + }, + "protocol": { + "type": "string", + "title": "Protocol", + "default": null + } + }, + "additionalProperties": false, + "title": "Configuration on how the web server communicates with the application", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/web/upstream/" + }, + "document_root": { + "type": "string", + "title": "The document root of this application, relative to its root", + "deprecationMessage": "Deprecated" + }, + "passthru": { + "type": "string", + "title": "The URL to use as a passthru if a file doesn't match the whitelist", + "deprecationMessage": "Deprecated" + }, + "index_files": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Files to look for to serve directories", + "deprecationMessage": "Deprecated" + }, + "whitelist": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Whitelisted entries", + "deprecationMessage": "Deprecated" + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Blacklisted entries", + "deprecationMessage": "Deprecated" + }, + "expires": { + "type": ["integer", "string"], + "title": "Amount of time to cache static assets", + "deprecationMessage": "Deprecated" + }, + "move_to_root": { + "type": "boolean", + "title": "Whether to move the whole root of the app to the document root", + "default": false, + "deprecationMessage": "Deprecated" + } + }, + "additionalProperties": false, + "default": { + "locations": {} + } + }, + "workers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "firewall": {"$ref": "#/definitions/firewall"}, + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + }, + "title": "Variables to control the environment", + "description": "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. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/variables/", + "default": {} + }, + "timezone": { + "type": "string", + "title": "The timezone of the application", + "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified. \nSee also: \nhttps://docs.upsun.com/anchors/app/timezone/", + "default": null + }, + "mounts": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "type": "string", + "title": "The type of mount that will provide the data", + "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", + "enum": [ + "instance", + "service", + "storage", + "temporary", + "tmp" + ] + }, + "source_path": { + "type": "string", + "title": "The path to be mounted", + "description": "Path relative to the root directory of the volume that's being mounted from. \nWARNING: Changing the name of your mount affects the source_path when it’s undefined. See how to ensure continuity and maintain access to your files \nhttps://docs.upsun.com/anchors/app/reference/mounts/change-name/" + }, + "service": { + "type": "string", + "title": "The name of the service that the volume will be mounted from", + "description": "Must be a service in `services.yaml` of type `network-storage`." + } + }, + "required": [ + "source" + ], + "additionalProperties": false + }, + "title": "Filesystem mounts of this application", + "description": "Directories that are writable even after the app is built. Allocated disk for mounts is defined with a separate resource configuration call using `upsun resources:set`. \nContains a dictionary of mounts: \nhttps://docs.upsun.com/anchors/app/reference/mounts/", + "default": {} + }, + "relationships": {"$ref": "#/definitions/relationships"}, + "access": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Access control for roles accessing app environments", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/access/", + "default": { + "ssh": "contributor" + } + }, + "size": {"$ref": "#/definitions/deprecated/size"}, + "resources": {"$ref": "#/definitions/deprecated/resources"}, + "container_profile": {"$ref": "#/definitions/container_profile"}, + "commands": { + "type": "object", + "properties": { + "pre_start": { + "type": "string", + "title": "The command used to run before starting the application", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" + }, + "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. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" + }, + "post_start": { + "type": "string", + "title": "A command executed after the application is started", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" + } + }, + "required": [ + "start" + ], + "additionalProperties": false, + "title": "The commands to manage the worker", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" + } + }, + "required": [ + "commands" + ], + "additionalProperties": false + }, + "title": "Alternate copies of the application to run as background processes", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", + "default": {} + }, + "access": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Access control for roles accessing app environments", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/access/", + "default": { + "ssh": "contributor" + } + }, + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + }, + "title": "Variables to control the environment", + "description": "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. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/variables/", + "default": {} + }, + "firewall": {"$ref": "#/definitions/firewall"}, + "build": { + "type": "object", + "properties": { + "flavor": { + "type": "string", + "description": "The pre-set build tasks to use for this application", + "default": null + } + }, + "additionalProperties": false, + "title": "The build configuration of the application", + "description": "It contains a build dictionary. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/build/", + "default": { + "flavor": null + } + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "title": "External global dependencies of this application", + "description": "What global dependencies to install before the build `hook` is run. They will be downloaded by the language's package manager. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/dependencies/", + "default": {} + }, + "hooks": { + "type": "object", + "properties": { + "build": { + "type": "string", + "title": "Hook executed after the build process", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/", + "default": null + }, + "deploy": { + "type": "string", + "title": "Hook executed after the deployment of new code", + "description": "More information: \nhttps://docs.upsun.com\t\n/anchors/app/reference/hooks/", + "default": null + }, + "post_deploy": { + "type": "string", + "title": "Hook executed after an environment is fully deployed", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/", + "default": null + } + }, + "additionalProperties": false, + "title": "What commands run at different stages in the `build`, `deploy` and `post_deploy` process", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/hooks/", + "default": {} + }, + "crons": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "title": "The cron schedule specification", + "description": "The cron specification. To prevent competition for resources that might hurt performance, use `H` in definitions to indicate an unspecified but invariant time. For example, instead of using `0 * * * *` to indicate the cron job runs at the start of every hour, you can use `H * * * *` to indicate it runs every hour, but not necessarily at the start. This prevents multiple cron jobs from trying to start at the same time. \nMore information: \nhttps://en.wikipedia.org/wiki/Cron#Cron_expression" + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "title": "The command used to start the cron job", + "description": "By default, the command is run in Dash (till you specify something else): \nhttps://en.wikipedia.org/wiki/Almquist_shell" + }, + "stop": { + "type": "string", + "title": "The command used to stop the cron job", + "description": "The command that’s issued to give the cron command a chance to shutdown gracefully, such as to finish an active item in a list of tasks. Issued when a cron task is interrupted by a user through the CLI or Console. If not specified, a `SIGTERM` signal is sent to the process.", + "default": null + } + }, + "required": [ + "start" + ], + "additionalProperties": false, + "title": "A definition of what commands to run when starting and stopping the cron job", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/crons/commands/" + }, + "shutdown_timeout": { + "type": "integer", + "title": "The timeout in seconds after which the cron job will be forcefully killed", + "description": "When a cron is canceled, this represents the number of seconds after which a `SIGKILL` signal is sent to the process to force terminate it. The default is `10` seconds.", + "default": 10 + }, + "timeout": { + "type": "integer", + "title": "The cron timeout", + "description": "The maximum amount of time a cron can run before it’s terminated. Defaults to the maximum allowed value of `86400` seconds (24 hours).", + "maximum": 86400 + }, + "cmd": { + "type": "string", + "title": "The command to execute", + "deprecationMessage": "Deprecated, please use `commands.start` instead." + } + }, + "required": [ + "spec" + ], + "additionalProperties": false + }, + "title": "Scheduled cron tasks executed by this application", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/crons/", + "default": {} + }, + "runtime": { + "type": "object", + "title": "Runtime-specific configuration", + "description": "Customizations to your PHP or Lisp runtime. \nContains a runtime dictionary: \nhttps://docs.upsun.com/anchors/app/reference/runtime/", + "properties": { + "extensions": { + "type": "array", + "title": "PHP extensions to enable", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", + "default": [] + }, + "disabled_extensions": { + "type": "array", + "title": "PHP extensions to disable", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", + "default": [] + }, + "request_terminate_timeout": { + "type": "integer", + "title": "PHP timeout to terminate requests", + "description": "The timeout in seconds for serving a single request after which the PHP-FPM worker process is killed." + }, + "sizing_hints": { + "type": "object", + "title": "A sizing hints definition", + "description": "The assumptions for setting the number of workers in your PHP-FPM runtime. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/runtime/sizing-hints/", + "properties": { + "request_memory": { + "type": "integer", + "title": "The average memory consumed per request in MB", + "description": "Minimum to 10", + "default": 45, + "minimum": 10 + }, + "reserved_memory": { + "type": "integer", + "title": "The amount of memory reserved in MB", + "description": "Minimum to 70", + "default": 70, + "minimum": 70 + } + }, + "additionalProperties": false + }, + "xdebug": { + "type": "object", + "title": "An Xdebug definition", + "description": "The setting to turn on Xdebug. \nMore information: \nhttps://docs.upsun.com/anchors/languages/php/xdebug/", + "properties": { + "idekey": { + "type": "string", + "title": "Your Xdebug key" + } + } + }, + "quicklisp": { + "type": "object", + "title": "Distributions for QuickLisp to use", + "description": "More information: \nhttps://docs.upsun.com/anchors/languages/lisp/", + "deprecationMessage": "Lisp image no longer exists, please see \nhttps://devcenter.upsun.com/posts/deploying-with-lisp/ \n for more information" + } + } + }, + "container_profile": {"$ref": "#/definitions/container_profile"}, + "additional_hosts": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Maps of hostnames to IP addresses", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/additional-hosts/" + }, + "timezone": { + "type": "string", + "title": "The timezone of the application", + "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified. \nSee also: \nhttps://docs.upsun.com/anchors/app/timezone/", + "default": null + }, + "preflight": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "title": "Whether the preflight security blocks are enabled", + "description": "Must be a boolean" + }, + "ignored_rules": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Specific rules to ignore during preflight security checks", + "default": [] + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false, + "description": "Configuration for pre-flight checks", + "default": { + "enabled": true, + "ignored_rules": [] + } + }, + "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." + } + }, + "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" + ] + } + }, + "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": {} + } + }, + "anyOf": [ + { + "required": [ + "type" + ] + }, + { + "required": [ + "stack" + ] + } + ], + "additionalProperties": false, + "x-order": [ + "type", + "stack", + "source", + "resources", + "relationships", + "mounts", + "web", + "workers", + "access", + "variables", + "firewall", + "build", + "dependencies", + "hooks", + "crons", + "runtime", + "container_profile", + "additional_hosts", + "timezone", + "preflight", + "operations" + ] + } + }, + "routes": { + "title": "The routes of the project", + "description": "Each route describes how an incoming URL is going to be processed by Upsun. \nMore information: \nhttps://docs.upsun.com/anchors/routes/", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "title": "Route identifier", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/identifiers/", + "properties": { + "primary": {"$ref": "#/definitions/routes/primary"}, + "id": {"$ref": "#/definitions/routes/id"}, + "attributes": {"$ref": "#/definitions/routes/attributes"}, + "type": {"$ref": "#/definitions/routes/route_type"}, + "redirects": {"$ref": "#/definitions/routes/redirects"}, + "tls": {"$ref": "#/definitions/routes/tls"}, + "to": {"$ref": "#/definitions/routes/to"} + }, + "required": [ + "type", + "to" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "primary": {"$ref": "#/definitions/routes/primary"}, + "id": {"$ref": "#/definitions/routes/id"}, + "attributes": {"$ref": "#/definitions/routes/attributes"}, + "type": {"$ref": "#/definitions/routes/route_type"}, + "redirects": {"$ref": "#/definitions/routes/redirects"}, + "tls": {"$ref": "#/definitions/routes/tls"}, + "cache": {"$ref": "#/definitions/routes/cache"}, + "ssi": {"$ref": "#/definitions/routes/ssi"}, + "upstream": {"$ref": "#/definitions/routes/upstream"} + }, + "required": [ + "type", + "upstream" + ], + "additionalProperties": false + } + ] + } + }, + "services": { + "title": "The services of the project", + "description": "Each service listed will be deployed to power your Upsun project. \nMore information: \nhttps://docs.upsun.com/anchors/services/ \nFull list of available services: \nhttps://docs.upsun.com/anchors/services/available/", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "The service type", + "description": "One of the available services in the format `type:version`. \nMore information: \nhttps://docs.upsun.com/anchors/services/available/", + "default": {} + }, + "size": {"$ref": "#/definitions/deprecated/size"}, + "access": { + "type": "object", + "title": "The configuration of the service", + "default": {}, + "deprecationMessage": "Deprecated" + }, + "configuration": { + "type": "object", + "title": "The configuration of the service", + "description": "Some services have additional specific configuration options that can be defined here, such as specific endpoints. \nSee the given service page for more details: \nhttps://docs.upsun.com/anchors/services/available/.", + "default": {} + }, + "relationships": {"$ref": "#/definitions/relationships"}, + "firewall": {"$ref": "#/definitions/firewall"}, + "resources": {"$ref": "#/definitions/deprecated/resources"}, + "container_profile": {"$ref": "#/definitions/container_profile"} + }, + "required": [ + "type" + ], + "additionalProperties": false + } + } + }, + "required": [ + "applications" + ], + "additionalProperties": true, + "definitions": { + "container_profile": { + "type": [ + "string", + "null" + ], + "title": "The container profile for this application/service in production", + "description": "Leave blank to allow it to be set dynamically. \nMore information: \nhttps://docs.upsun.com/anchors/resources/manage/configuration/profiles/adjust/", + "default": "", + "anyOf": [ + { + "type": "string", + "enum": [ + "HIGH_CPU", + "BALANCED", + "HIGH_MEMORY", + "HIGHER_MEMORY" + ] + }, + { + "type": "null" + } + ] + }, + "routes": { + "primary": { + "type": "boolean", + "title": "Whether the route is the primary route for the project", + "description": "If true, this route is the primary route of the environment", + "default": true + }, + "id": { + "type": "string", + "title": "Route Identifier", + "description": "A unique identifier for the route. See route identifiers: \nhttps://docs.upsun.com/anchors/routes/identifiers/", + "default": null + }, + "attributes": { + "type": "object", + "title": "Attributes of the route", + "additionalProperties": { + "type": "string" + }, + "description": "Arbitrary attributes attached to this resource: \nhttps://docs.upsun.com/anchors/routes/attributes/", + "default": {} + }, + "route_type": { + "type": [ + "string", + "null" + ], + "title": "Route type", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/configuration/", + "enum": [ + "proxy", + "redirect", + "upstream" + ], + "default": "upstream" + }, + "redirects": { + "type": "object", + "title": "The configuration of the redirects", + "description": "Defines redirects for partial routes. For definition and options, see the redirect rules: \nhttps://docs.upsun.com/anchors/routes/redirects/", + "properties": { + "expires": { + "type": ["integer", "string"], + "title": "The duration the redirect is cached", + "description": "Examples of valid values include 3600s, 1d, 2w, 3m. \nTo disable caching for all your redirects, set expires to 0. You can also disable caching on a specific redirect: \nhttps://docs.upsun.com/anchors/routes/redirects/caching/disable/", + "default": -1 + }, + "paths": { + "type": "object", + "title": "The paths to redirect", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/", + "additionalProperties": { + "type": "object", + "properties": { + "regexp": { + "type": "boolean", + "title": "Whether the path is a regular expression", + "description": "Specifies whether the path key should be interpreted as a PCRE regular expression. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/", + "default": false + }, + "to": { + "type": "string", + "title": "The URL to redirect to", + "description": "A relative URL - '/destination', \nor absolute URL - 'https://example.com/'. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/" + }, + "prefix": { + "type": "boolean", + "title": "Prefix of the redirect path", + "description": "Specifies whether both the path and all its children or just the path itself should be redirected. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/affix/", + "default": null + }, + "append_suffix": { + "type": "boolean", + "title": "Suffix of the redirect path", + "description": "Determines if the suffix is carried over with the redirect. More information. \nhttps://docs.upsun.com/anchors/routes/redirects/affix/", + "default": null + }, + "code": { + "type": "integer", + "title": "The redirect HTTP status code to use", + "description": "Valid status codes are 301, 302, 307, and 308. \nDefaults to 302 for Partial redirects: \nhttps://docs.upsun.com/anchors/routes/redirects/affix/ \nand 301 for Whole-route redirects: \nhttps://docs.upsun.com/anchors/routes/redirects/whole/ \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/http-status-code/", + "default": 302 + }, + "expires": { + "type": ["integer", "string"], + "title": "The amount of time, in seconds, to cache the redirects", + "description": "The duration the redirect is cached for. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/caching/manage/", + "default": null + } + }, + "required": [ + "to" + ], + "additionalProperties": false + } + } + }, + "required": [ + "paths" + ], + "additionalProperties": false, + "default": {} + }, + "tls": { + "type": "object", + "title": "TLS settings for the route", + "description": "The absolute URL or other route to which the given route should be redirected with an HTTP 301 status code. \nhttps://docs.upsun.com/anchors/routes/https/tls/", + "properties": { + "strict_transport_security": { + "type": "object", + "title": "Strict-Transport-Security options", + "description": "https://docs.upsun.com/anchors/routes/https/tls/", + "properties": { + "enabled": { + "type": "boolean", + "title": "Whether strict transport security is enabled or not", + "description": "If set to true, HSTS is enabled for 1 year. \nIf set to false, other properties are ignored.", + "default": null + }, + "include_subdomains": { + "type": "boolean", + "title": "Whether the strict transport security policy should include all subdomains", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/https/tls/hsts/", + "default": false + }, + "preload": { + "type": "boolean", + "title": "Whether the strict transport security policy should be preloaded in browsers", + "description": "To add your website to the HSTS preload list: \nhttps://hstspreload.org/. \nThanks to this list, most browsers are informed that your site requires HSTS before an HSTS header response is even issued.\n", + "default": false + } + }, + "additionalProperties": false, + "default": { + "preload": null, + "include_subdomains": false, + "enabled": false + } + }, + "min_version": { + "type": ["string", "null"], + "enum": [ + "TLSv1.0", + "TLSv1.1", + "TLSv1.2", + "TLSv1.3", + null + ], + "title": "The minimum TLS version to support.", + "description": "Note that TLS versions older than 1.2 are deprecated and are rejected by default.", + "default": null + }, + "client_authentication": { + "type": ["string", "null"], + "enum": ["request", "require", null], + "description": "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", + "description": "If not specified, a default set of trusted CAs will be used. \nMore Information: \nhttps://docs.upsun.com/anchors/routes/https/tls/mtls/", + "default": [] + } + }, + "additionalProperties": false, + "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", + "description": "The absolute URL or other route to which the given route should be redirected with an HTTP 301 status code. \nA relative URL - '/destination', or absolute URL - 'https://example.com/' \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/partial/" + }, + "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", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/server-side-includes/" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false, + "title": "Server-Side Include configuration.", + "description": "More information: \nhttps://docs.upsun.com/anchors/routes/configuration/", + "default": { + "enabled": false + } + }, + "upstream": { + "type": "string", + "title": "The upstream to use for this route", + "description": "The name of the app to be served (as defined in your app configuration) followed by :http. Example: app:http \nMore information: \nhttps://docs.upsun.com/anchors/routes/configuration/" + } + }, + "relationships": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + }, + { + "type": "object", + "properties": { + "service": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + } + } + ] + }, + "title": "The relationships of the application/service to other services", + "description": "Contains a dictionary of relationships: \nhttps://docs.upsun.com/anchors/app/reference/relationships/", + "default": {} + }, + "firewall": { + "type": "object", + "properties": { + "outbound": { + "type": "array", + "items": { + "type": "object", + "properties": { + "protocol": { + "type": "string", + "title": "The IP protocol to apply the restriction on", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", + "default": "tcp" + }, + "ips": { + "type": "array", + "items": { + "type": "string" + }, + "title": "The IP range in CIDR notation to apply the restriction on", + "description": "See a CIDR format converter: \nhttps://www.ipaddressguide.com/cidr \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", + "default": [] + }, + "domains": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Domains of the restriction", + "description": "Fully qualified domain names to specify specific destinations by hostname \nhttps://en.wikipedia.org/wiki/Fully_qualified_domain_name \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", + "default": [] + }, + "ports": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "The port to apply the restriction on", + "description": "Ports from 1 to 65535 that are allowed. If any ports are specified, all unspecified ports are blocked. If no ports are specified, all ports are allowed. Port 25, the SMTP port for sending email, is always blocked.", + "default": [] + } + }, + "additionalProperties": false + }, + "title": "Outbound firewall restrictions", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/", + "default": [] + } + }, + "additionalProperties": false, + "nullable": true, + "title": "Outbound firewall rules for the application", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/firewall/", + "default": null + }, + "mounts": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "source": { + "type": "string", + "title": "The type of mount that will provide the data", + "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", + "enum": [ + "instance", + "service", + "storage", + "temporary", + "tmp" + ] + }, + "source_path": { + "type": "string", + "title": "The path to be mounted, relative to the root directory of the volume that's being mounted from", + "description": "WARNING: Changing the name of your mount affects the source_path when it’s undefined. See how to ensure continuity and maintain access to your files \nhttps://docs.upsun.com/anchors/app/reference/mounts/change-name/" + }, + "service": { + "type": "string", + "title": "The name of the service that the volume will be mounted from", + "description":" Must be a service in `services.yaml` of type `network-storage`. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/mounts/" + } + }, + "required": [ + "source" + ], + "additionalProperties": false + }, + "title": "Filesystem mounts of this application", + "description": "Directories that are writable even after the app is built. Allocated disk for mounts is defined with a separate resource configuration call using `upsun resources:set`. \nContains a dictionary of mounts: \nhttps://docs.upsun.com/anchors/app/reference/mounts/", + "default": {} + }, + "deprecated": { + "size": { + "type": "string", + "title": "The container size for this application in production", + "description": "Leave blank to allow it to be set dynamically.", + "deprecationMessage": "Deprecated" + }, + "resources": { + "type": "object", + "properties": { + "base_memory": { + "type": "integer", + "title": "The base memory for the container", + "default": 64, + "deprecationMessage": "Deprecated" + }, + "memory_ratio": { + "type": "integer", + "title": "The amount of memory to allocate per units of CPU", + "default": 128, + "deprecationMessage": "Deprecated" + } + }, + "additionalProperties": false, + "nullable": true, + "title": "Resources", + "deprecationMessage": "Deprecated" + } + } + } +} diff --git a/internal/lint/scripts.go b/internal/lint/scripts.go new file mode 100644 index 000000000..93a0a9e79 --- /dev/null +++ b/internal/lint/scripts.go @@ -0,0 +1,48 @@ +package lint + +import ( + "fmt" + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +func CheckScripts(cfg *Config) *Result { + result := &Result{} + + var scripts = make(map[string]string) + for appName, app := range cfg.Applications { + keyPrefix := "applications." + appName + "." + + // Warn if the start command is not set for non-PHP applications. + // Skip composable applications for now as they could potentially also use a PHP-FPM default. // TODO check this + if app.Web.Commands.Start == "" && !strings.HasPrefix(app.Type, "php:") && + !strings.HasPrefix(app.Type, "composable:") { + result.AddWarning(keyPrefix+"web.commands.start", "a start command is needed for non-PHP applications") + } + + // Group all scripts for shell syntax checking. + scripts[keyPrefix+"hooks.build"] = app.Hooks.Build + scripts[keyPrefix+"hooks.deploy"] = app.Hooks.Deploy + scripts[keyPrefix+"hooks.post_deploy"] = app.Hooks.PostDeploy + scripts[keyPrefix+"web.commands.start"] = app.Web.Commands.Start + scripts[keyPrefix+"web.commands.post_start"] = app.Web.Commands.PostStart + for cronName, cron := range app.Crons { + cronPrefix := keyPrefix + "crons." + cronName + "." + scripts[cronPrefix+"start"] = cron.Commands.Start + scripts[cronPrefix+"stop"] = cron.Commands.Stop + } + } + + for k, v := range scripts { + if v == "" { + continue + } + r := strings.NewReader(v) + if _, err := syntax.NewParser(syntax.Variant(syntax.LangPOSIX)).Parse(r, ""); err != nil { + result.AddError(k, fmt.Sprintf("invalid syntax: %s", err)) + } + } + + return result +} diff --git a/internal/lint/scripts_test.go b/internal/lint/scripts_test.go new file mode 100644 index 000000000..c7b8ba964 --- /dev/null +++ b/internal/lint/scripts_test.go @@ -0,0 +1,179 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/internal/lint" +) + +func TestCheckScripts(t *testing.T) { + tests := []struct { + name string + yamlContent string + expectErrorMessage string + expectWarningMessage string + }{ + { + name: "all valid scripts", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + hooks: + build: "echo 'Building app1'" + deploy: "cp -r ./build /var/www" + post_deploy: "chmod -R 755 /var/www" + web: + commands: + start: "node server.js" + post_start: "echo 'Server started'" +`, + }, + { + name: "invalid build script", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + hooks: + build: "echo 'Missing quote" + deploy: "cp -r ./build /var/www" + web: + commands: + start: "node server.js" +`, + expectErrorMessage: "linter errors:\n - applications.app1.hooks.build: invalid syntax: 1:6: " + + "reached EOF without closing quote `'`", + }, + { + name: "invalid deploy script with unmatched parenthesis", + yamlContent: ` +applications: + app1: + type: "php:8.2" + hooks: + build: "echo 'Building app1'" + deploy: "if (true; then echo 'deployed'; fi" +`, + expectErrorMessage: "linter errors:\n - applications.app1.hooks.deploy: invalid syntax: 1:11: " + + "`then` can only be used in an `if`", + }, + { + name: "invalid start command with pipe error", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + web: + commands: + start: "node server.js | | grep output" +`, + expectErrorMessage: "linter errors:\n - applications.app1.web.commands.start: invalid syntax: 1:16: " + + "`|` must be followed by a statement", + }, + { + name: "multiple applications with one invalid script", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + hooks: + build: "echo 'Building app1'" + web: + commands: + start: "node server.js" + app2: + type: "nodejs:20" + hooks: + build: "for ((i=0; i<5; i++)) do echo $i; done" # bash syntax, not POSIX + web: + commands: + start: "python app.py" +`, + expectErrorMessage: "linter errors:\n - applications.app2.hooks.build: invalid syntax: 1:5: " + + "c-style fors are a bash/zsh feature; tried parsing as posix", + }, + { + name: "empty YAML", + yamlContent: ` +applications: {}`, + }, + { + name: "invalid YAML", + yamlContent: `not valid yaml: ]`, + expectErrorMessage: "failed to parse YAML: yaml: did not find expected node content", + }, + { + name: "missing start command for non-PHP application", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + hooks: + build: "echo 'Building app1'" +`, + expectWarningMessage: "linter warnings:\n - applications.app1.web.commands.start: " + + "a start command is needed for non-PHP applications", + }, + { + name: "PHP application without start command - no warning", + yamlContent: ` +applications: + app1: + type: "php:8.2" + hooks: + build: "echo 'Building PHP app'" +`, + }, + { + name: "composable application without start command - no warning", + yamlContent: ` +applications: + app1: + type: "composable:nginx" + hooks: + build: "echo 'Building composable app'" +`, + }, + { + name: "non-PHP application with start command - no warning", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + hooks: + build: "echo 'Building app1'" + web: + commands: + start: "node server.js" +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(tt.yamlContent) + if err != nil { + assert.Equal(t, tt.expectErrorMessage, err.Error()) + return + } + result := lint.CheckScripts(cfg) + + if tt.expectErrorMessage != "" { + assert.True(t, result.HasErrors()) + assert.Equal(t, tt.expectErrorMessage, result.Error()) + } else { + assert.False(t, result.HasErrors()) + } + + if tt.expectWarningMessage != "" { + assert.True(t, result.HasWarnings()) + assert.Equal(t, tt.expectWarningMessage, result.Error()) + } else { + assert.False(t, result.HasWarnings()) + } + }) + } +} diff --git a/internal/lint/testdata/registry.json b/internal/lint/testdata/registry.json new file mode 100644 index 000000000..2053ec17a --- /dev/null +++ b/internal/lint/testdata/registry.json @@ -0,0 +1,1319 @@ +{ + "chrome-headless": { + "description": "", + "disk": false, + "docs": { + "relationship_name": "chrome-headless", + "service_name": "chrome-headless", + "url": "/add-services/headless-chrome.html" + }, + "endpoint": "http", + "min_disk_size": null, + "name": "Headless Chrome", + "repo_name": "chrome-headless", + "runtime": false, + "type": "chrome-headless", + "versions": { + "deprecated": [], + "supported": [ + "120", + "113", + "95", + "91", + "86", + "84", + "83", + "81", + "80", + "73" + ], + "legacy": [ + "86", + "84", + "83", + "81", + "80", + "73" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "95" + ] + } + }, + "dotnet": { + "description": "ASP.NET 5 application container.", + "repo_name": "dotnet", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/dotnet.html", + "web": { + "commands": { + "start": "dotnet application.dll" + }, + "locations": { + "/": { + "root": "wwwroot", + "allow": true, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "set -e", + "dotnet publish --output \"$PLATFORM_OUTPUT_DIR\" -p:UseRazorBuildServer=false -p:UseSharedCompilation=false" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "C#/.Net Core", + "runtime": true, + "type": "dotnet", + "versions": { + "deprecated": [ + "5.0", + "3.1", + "2.2", + "2.1", + "2.0" + ], + "supported": [ + "7.0", + "6.0", + "8.0" + ], + "legacy": [ + "3.1", + "2.2", + "2.1", + "2.0" + ] + } + }, + "elasticsearch": { + "description": "A manufacture service for Elasticsearch", + "disk": true, + "docs": { + "relationship_name": "elasticsearch", + "service_name": "elasticsearch", + "url": "/add-services/elasticsearch.html" + }, + "endpoint": "elasticsearch", + "min_disk_size": 256, + "name": "Elasticsearch", + "repo_name": "elasticsearch", + "runtime": false, + "type": "elasticsearch", + "versions": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5", + "5.4", + "5.2", + "2.4", + "1.7", + "1.4" + ], + "supported": [ + "8.5", + "7.17" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.5", + "7.17" + ], + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.6", + "7.5", + "7.2", + "6.8", + "6.5", + "5.6", + "5.2", + "2.4", + "1.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "7.10", + "7.9", + "7.7", + "7.5", + "7.2", + "6.8", + "6.5" + ], + "supported": [] + } + }, + "elixir": { + "description": "", + "repo_name": "elixir", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/elixir.html", + "web": { + "commands": { + "start": "mix run --no-halt" + }, + "locations": { + "/": { + "allow": false, + "root": "web", + "passthru": true + } + } + }, + "hooks": { + "build": [ + "|", + "mix local.hex --force", + "mix local.rebar --force", + "mix do deps.get --only prod, deps.compile, compile" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Elixir", + "runtime": true, + "type": "elixir", + "versions": { + "deprecated": [ + "1.13", + "1.12", + "1.11", + "1.10", + "1.9" + ], + "supported": [ + "1.18", + "1.15", + "1.14" + ], + "legacy": [ + "1.10", + "1.9" + ] + } + }, + "golang": { + "description": "", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/go.html", + "web": { + "upstream": { + "socket_family": "tcp", + "protocol": "http" + }, + "commands": { + "start": "./bin/app" + }, + "locations": { + "/": { + "allow": false, + "passthru": true + } + } + }, + "hooks": { + "build": [ + "go build -o bin/app" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Go", + "repo_name": "golang", + "runtime": true, + "type": "golang", + "versions": { + "deprecated": [ + "1.19", + "1.18", + "1.17", + "1.16", + "1.15", + "1.14", + "1.13", + "1.12", + "1.11", + "1.10", + "1.9", + "1.8" + ], + "supported": [ + "1.23", + "1.22", + "1.21", + "1.20" + ] + } + }, + "influxdb": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "influxdb", + "service_name": "influxdb", + "url": "/add-services/influxdb.html" + }, + "endpoint": "influxdb", + "min_disk_size": null, + "name": "InfluxDB", + "repo_name": "influxdb", + "runtime": false, + "type": "influxdb", + "versions": { + "deprecated": [ + "2.2", + "1.8", + "1.7", + "1.3", + "1.2" + ], + "supported": [ + "2.7", + "2.3" + ] + } + }, + "java": { + "description": "", + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/java.html", + "web": { + "commands": { + "start": "java -jar target/application.jar --server.port=$PORT" + } + }, + "hooks": { + "build": [ + "mvn clean install" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Java", + "repo_name": "java", + "runtime": true, + "type": "java", + "versions": { + "deprecated": [ + "14", + "13", + "12" + ], + "supported": [ + "21", + "19", + "18", + "17", + "11", + "8" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "17", + "11", + "8" + ], + "deprecated": [ + "15", + "13", + "7" + ] + } + }, + "kafka": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "kafka", + "service_name": "kafka", + "url": "/add-services/kafka.html" + }, + "endpoint": "kafka", + "min_disk_size": 512, + "name": "Kafka", + "repo_name": "kafka", + "runtime": false, + "type": "kafka", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ], + "supported": [ + "3.7", + "3.6", + "3.4", + "3.2" + ], + "legacy": [ + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" + ] + } + }, + "lisp": { + "description": "", + "id": 1102, + "repo_name": "lisp", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/lisp.html", + "web": { + "commands": { + "start": "./example" + }, + "locations": { + "/": { + "allow": false, + "passthru": true + } + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Lisp", + "runtime": true, + "type": "lisp", + "versions": { + "deprecated": [], + "supported": [ + "2.1", + "2.0", + "1.5" + ] + } + }, + "mariadb": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "mariadb", + "service_name": "mariadb", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mariadb", + "versions": { + "deprecated": [ + "10.2", + "10.1", + "10.3", + "10.0", + "5.5" + ], + "supported": [ + "11.4", + "11.2", + "11.0", + "10.11", + "10.6", + "10.5", + "10.4" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "10.11 Galera", + "10.8 Galera", + "10.7 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera", + "10.0 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "mysql": { + "description": "A manufacture-based container for MariaDB", + "repo_name": "mariadb", + "disk": true, + "docs": { + "relationship_name": "mysql", + "service_name": "mysql", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "MariaDB/MySQL", + "runtime": false, + "type": "mysql", + "versions": { + "deprecated": [ + "10.2", + "10.1", + "10.0", + "5.5" + ], + "supported": [ + "11.0", + "10.11", + "10.6", + "10.5", + "10.4", + "10.3" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera", + "10.0 Galera" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "10.11 Galera", + "10.6 Galera", + "10.5 Galera", + "10.4 Galera", + "10.3 Galera" + ], + "deprecated": [ + "10.2 Galera", + "10.1 Galera" + ] + } + }, + "memcached": { + "description": "Memcached service.", + "repo_name": "memcached", + "disk": false, + "docs": { + "relationship_name": "memcached", + "service_name": "memcached", + "url": "/add-services/memcached.html" + }, + "endpoint": "memcached", + "min_disk_size": null, + "name": "Memcached", + "runtime": false, + "type": "memcached", + "versions": { + "deprecated": [], + "supported": [ + "1.6", + "1.5", + "1.4" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "1.4*" + ] + } + }, + "mongodb": { + "description": "Experimental MongoDB support on Platform.sh", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodb", + "service_name": "mongodb", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb", + "versions": { + "deprecated": [ + "4.0.3", + "3.6", + "3.4", + "3.2", + "3.0" + ], + "supported": [] + } + }, + "mongodb-enterprise": { + "description": "Support for the enterprise edition of MongoDB", + "repo_name": "mongodb", + "disk": true, + "docs": { + "relationship_name": "mongodb-enterprise", + "service_name": "mongodb-enterprise", + "url": "/add-services/mongodb.html" + }, + "endpoint": "mongodb", + "min_disk_size": 512, + "name": "MongoDB", + "runtime": false, + "type": "mongodb-enterprise", + "premium": true, + "versions": { + "supported": [ + "7.0", + "6.0", + "5.0", + "4.4" + ], + "deprecated": [ + "4.2", + "4.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "6.0", + "5.0", + "4.4" + ], + "deprecated": [ + "4.2", + "4.0" + ] + } + }, + "network-storage": { + "description": "", + "repo_name": "network-storage", + "disk": true, + "docs": { + "relationship_name": "null", + "service_name": "network-storage", + "url": "/add-services/network-storage.html" + }, + "endpoint": "something", + "min_disk_size": null, + "name": "Network Storage", + "runtime": false, + "type": "network-storage", + "versions": { + "deprecated": [ + "1.0" + ], + "supported": [ + "2.0" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2.0" + ] + } + }, + "nodejs": { + "description": "NodeJS service for Platform", + "repo_name": "nodejs", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/nodejs.html", + "web": { + "commands": { + "start": "node index.js" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "JavaScript/Node.js", + "runtime": true, + "type": "nodejs", + "versions": { + "deprecated": [ + "16", + "14", + "12", + "10", + "8", + "6", + "4.8", + "4.7", + "0.12" + ], + "supported": [ + "22", + "20", + "18" + ] + }, + "versions-dedicated-gen-2": { + "supported": [], + "deprecated": [ + "16", + "14", + "12", + "10", + "9.8" + ] + } + }, + "opensearch": { + "description": "A manufacture service for OpenSearch", + "disk": true, + "docs": { + "relationship_name": "opensearch", + "service_name": "opensearch", + "url": "/add-services/opensearch.html" + }, + "endpoint": "opensearch", + "min_disk_size": 256, + "name": "OpenSearch", + "repo_name": "opensearch", + "runtime": false, + "type": "opensearch", + "versions": { + "deprecated": [ + "1.2", + "1.1" + ], + "supported": [ + "2", + "1" + ], + "legacy": [ + "1.1" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [], + "supported": [ + "2.5", + "1.2" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [], + "supported": [ + "2" + ] + } + }, + "oracle-mysql": { + "description": "Images using MySQL from Oracle instead of MariaDB still providing mysql endpoints", + "repo_name": "oracle-mysql", + "disk": true, + "docs": { + "relationship_name": "oracle-mysql", + "service_name": "oracle-mysql", + "url": "/add-services/mysql.html" + }, + "endpoint": "mysql", + "min_disk_size": 256, + "name": "Oracle MySQL", + "runtime": false, + "type": "oracle-mysql", + "versions": { + "deprecated": [], + "supported": [ + "8.0", + "5.7" + ] + } + }, + "php": { + "description": "PHP service for Platform.sh.", + "repo_name": "php", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/php.html", + "web": { + "locations": { + "/": { + "root": "web", + "passthru": "/index.php" + } + } + }, + "hooks": { + "build": [ + "|", + "set -e" + ], + "deploy": [ + "|", + "set -e" + ] + }, + "build": { + "flavor": "composer" + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "PHP", + "runtime": true, + "type": "php", + "versions-dedicated-gen-2": { + "supported": [ + "8.2", + "8.1", + "8.0" + ], + "deprecated": [ + "7.4", + "7.3", + "7.2", + "7.1", + "7.0" + ] + }, + "versions": { + "deprecated": [ + "8.0", + "7.4", + "7.3", + "7.2", + "7.1", + "7.0", + "5.6", + "5.5", + "5.4" + ], + "supported": [ + "8.4", + "8.3", + "8.2", + "8.1" + ] + } + }, + "postgresql": { + "description": "PostgreSQL service for Platform.sh.", + "repo_name": "postgresql", + "disk": true, + "docs": { + "relationship_name": "postgresql", + "service_name": "postgresql", + "url": "/add-services/postgresql.html" + }, + "endpoint": "postgresql", + "min_disk_size": null, + "name": "PostgreSQL", + "runtime": false, + "type": "postgresql", + "versions": { + "deprecated": [ + "11", + "10", + "9.6", + "9.5", + "9.4", + "9.3" + ], + "supported": [ + "17", + "16", + "15", + "14", + "13", + "12" + ] + }, + "versions-dedicated-gen-2": { + "deprecated": [ + "11*", + "9.6*", + "9.5", + "9.4", + "9.3" + ], + "supported": [] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "11", + "10" + ], + "supported": [ + "17", + "16", + "15", + "14", + "13", + "12" + ] + } + }, + "python": { + "description": "", + "repo_name": "python", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/python.html", + "web": { + "commands": { + "start": "python server.py" + } + }, + "hooks": { + "build": [ + "|", + "pipenv install --system --deploy" + ] + }, + "dependencies": { + "python3": { + "pipenv": "2018.10.13" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Python", + "runtime": true, + "type": "python", + "versions": { + "deprecated": [ + "3.7", + "3.6", + "3.5", + "2.7*" + ], + "supported": [ + "3.13", + "3.12", + "3.11", + "3.10", + "3.9", + "3.8" + ] + } + }, + "rabbitmq": { + "description": "A manufacture-based container for RabbitMQ", + "repo_name": "rabbitmq", + "disk": true, + "docs": { + "relationship_name": "rabbitmq", + "service_name": "rabbitmq", + "url": "/add-services/rabbitmq.html" + }, + "endpoint": "rabbitmq", + "min_disk_size": 512, + "name": "RabbitMQ", + "runtime": false, + "type": "rabbitmq", + "versions": { + "deprecated": [ + "3.11", + "3.10", + "3.9", + "3.8", + "3.7", + "3.6", + "3.5" + ], + "supported": [ + "4.1", + "4.0", + "3.13", + "3.12" + ], + "legacy": [ + "3.8", + "3.7", + "3.6", + "3.5" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "3.13", + "3.12" + ], + "deprecated": [ + "3.11", + "3.10", + "3.9", + "3.8", + "3.7" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "3.11", + "3.10", + "3.9" + ], + "supported": [ + "3.13", + "3.12" + ] + } + }, + "redis": { + "description": "A manufacture-based Redis container ", + "repo_name": "redis", + "disk": false, + "docs": { + "relationship_name": "redis", + "service_name": "redis", + "url": "/add-services/redis.html" + }, + "endpoint": "redis", + "min_disk_size": null, + "name": "Redis", + "runtime": false, + "type": "redis", + "versions": { + "deprecated": [ + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "7.2", + "7.0", + "6.2" + ], + "legacy": [ + "6.0" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "7.0", + "6.2" + ], + "deprecated": [ + "6.0", + "5.0", + "3.2" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "6.0", + "5.0", + "4.0", + "3.2", + "3.0", + "2.8" + ], + "supported": [ + "7.2", + "7.0", + "6.2" + ] + } + }, + "ruby": { + "description": "", + "repo_name": "ruby", + "disk": false, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/ruby.html", + "web": { + "upstream": { + "socket_family": "unix" + }, + "commands": { + "start": "unicorn -l $SOCKET -E production config.ru" + }, + "locations": { + "/": { + "root": "public", + "passthru": true, + "expires": "1h", + "allow": true + } + } + }, + "hooks": { + "build": [ + "|", + "bundle install --without development test" + ], + "deploy": [ + "|", + "RACK_ENV=production bundle exec rake db:migrate" + ] + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Ruby", + "runtime": true, + "type": "ruby", + "versions": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ], + "supported": [ + "3.4", + "3.3", + "3.2", + "3.1", + "3.0" + ], + "legacy": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ] + }, + "versions-dedicated-gen-3": { + "deprecated": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ], + "supported": [ + "3.3", + "3.2", + "3.1", + "3.0" + ], + "legacy": [ + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" + ] + } + }, + "rust": { + "description": "", + "repo_name": "rust", + "disk": true, + "docs": { + "relationship_name": null, + "service_name": null, + "url": "/languages/rust.html", + "web": { + "commands": { + "start": "./target/debug/hello" + } + } + }, + "endpoint": null, + "min_disk_size": null, + "name": "Rust", + "runtime": true, + "type": "rust", + "versions": { + "deprecated": [], + "supported": [ + "1" + ] + } + }, + "solr": { + "description": "", + "repo_name": "solr", + "disk": true, + "docs": { + "relationship_name": "solr", + "service_name": "solr", + "url": "/add-services/solr.html" + }, + "endpoint": "solr", + "min_disk_size": 256, + "name": "Solr", + "runtime": false, + "type": "solr", + "versions": { + "deprecated": [ + "8.6", + "8.4", + "8.0", + "7.7", + "7.6", + "6.6", + "6.3", + "4.10", + "3.6" + ], + "supported": [ + "9.6", + "9.4", + "9.2", + "9.1", + "8.11" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "8.11" + ], + "deprecated": [ + "8.6", + "8.0", + "7.7", + "6.6", + "6.3", + "4.10" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "9.6", + "9.4", + "9.2", + "9.1", + "8.11" + ], + "deprecated": [] + } + }, + "varnish": { + "description": "", + "repo_name": "varnish", + "disk": false, + "docs": { + "relationship_name": "varnish", + "service_name": "varnish", + "url": "/add-services/varnish.html" + }, + "endpoint": "http+stats", + "min_disk_size": null, + "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", + "service_relationships": "application: 'app:http'", + "name": "Varnish", + "runtime": false, + "type": "varnish", + "versions": { + "deprecated": [ + "5.1", + "5.2", + "6.3", + "6.4", + "7.1" + ], + "supported": [ + "7.6", + "7.3", + "7.2", + "6.0" + ] + } + }, + "vault-kms": { + "description": "", + "disk": true, + "docs": { + "relationship_name": "vault-kms", + "service_name": "vault-kms", + "url": "/add-services/vault.html" + }, + "endpoint": "manage_keys", + "min_disk_size": 512, + "configuration": " configuration:\n endpoints:\n :\n - policy: \n key: \n type: ", + "name": "Vault KMS", + "repo_name": "vault-kms", + "runtime": false, + "type": "vault-kms", + "versions": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ], + "legacy": [ + "1.6" + ] + }, + "versions-dedicated-gen-2": { + "supported": [ + "1.6" + ] + }, + "versions-dedicated-gen-3": { + "supported": [ + "1.12" + ], + "deprecated": [ + "1.8", + "1.6" + ] + } + } +} diff --git a/internal/lint/types.go b/internal/lint/types.go new file mode 100644 index 000000000..9a5928395 --- /dev/null +++ b/internal/lint/types.go @@ -0,0 +1,95 @@ +package lint + +import ( + "fmt" + "slices" + "strings" + + "github.com/upsun/cli/internal/lint/registry" +) + +// CheckTypes checks that application, service and worker types are supported images and versions. +func CheckTypes(cfg *Config, reg registry.Registry) *Result { + result := &Result{} + + check := func(t string, runtime bool) error { return checkType(t, reg, runtime) } + + for appName, app := range cfg.Applications { + if app.Type == "" && !isStackEmpty(app.Stack) { + // For backwards compatibility, we allow 'stack' to be specified without 'type'. + result.AddWarning("applications."+appName, + "'type' should be specified (as a composable image) when using 'stack'") + continue + } + if err := check(app.Type, true); err != nil { + result.AddError("applications."+appName+".type", err.Error()) + } + if strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { + result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") + } + } + for appName, app := range cfg.Applications { + for workerName, w := range app.Workers { + if w.Type != "" { + if err := check(w.Type, true); err != nil { + result.AddError("applications."+appName+".workers."+workerName+".type", err.Error()) + } + } + } + } + for serviceName, service := range cfg.Services { + if err := check(service.Type, false); err != nil { + result.AddError("services."+serviceName+".type", err.Error()) + } + } + + return result +} + +func checkType(t string, reg registry.Registry, runtime bool) error { + if t == "" { + return fmt.Errorf("type cannot be empty") + } + + parts := strings.SplitN(t, ":", 2) + var version string + var imageType = parts[0] + if len(parts) > 1 { + version = parts[1] + } + + if img, ok := reg[imageType]; ok { + if img.IsRuntime && !runtime { + return fmt.Errorf("type '%s' is a runtime type, not a service type", imageType) + } else if !img.IsRuntime && runtime { + return fmt.Errorf("type '%s' is a service type, not a runtime type", imageType) + } + + // Allow supported or legacy versions, but only mention supported ones in the error. + allVersions := append(img.Versions.Supported, img.Versions.Legacy...) //nolint:gocritic + if !slices.Contains(allVersions, version) { + if hasMajorVersion(allVersions, version) { + return fmt.Errorf( + "version '%s' is not precise enough for type '%s'; it must be exactly one of: %s", + version, imageType, strings.Join(img.Versions.Supported, ", ")) + } + return fmt.Errorf( + "version '%s' is not supported for type '%s'; it must be exactly one of: %s", + version, imageType, strings.Join(img.Versions.Supported, ", ")) + } + return nil + } + + return fmt.Errorf("type not found: '%s'; it must be one of: %s "+ + "(check the Registry for supported types, or make an application using a composable image)", + imageType, strings.Join(reg.AllTypes(runtime), ", ")) +} + +func hasMajorVersion(l []string, v string) bool { + for _, c := range l { + if strings.HasPrefix(c, v+".") { + return true + } + } + return false +} diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go new file mode 100644 index 000000000..1afb4e00b --- /dev/null +++ b/internal/lint/types_test.go @@ -0,0 +1,118 @@ +package lint_test + +import ( + _ "embed" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/lint" + "github.com/upsun/cli/internal/lint/registry" +) + +//go:embed testdata/registry.json +var registryJSON []byte + +func TestCheckTypes(t *testing.T) { + testRegistry, err := registry.Parse(registryJSON) + require.NoError(t, err) + + cases := []struct { + name string + content string + expectErrorMessage string + }{ + { + name: "correct", + // N.B. YAML requires spaces for indents, not tabs. + content: ` +applications: + foo: + type: php:8.4 +services: + database: + type: mariadb:11.4`, + }, + { + name: "legacy_redis", + content: ` +applications: + foo: + type: php:8.4 +services: + cache: + type: redis:6.0`, + }, + { + name: "unsupported_php", + content: ` +applications: + foo: + type: php:5.3 +services: + database: + type: mariadb:11.4`, + expectErrorMessage: "linter errors:\n - applications.foo.type: version '5.3' is not supported for type 'php'; " + //nolint:lll + "it must be exactly one of: 8.4, 8.3, 8.2, 8.1", + }, + { + name: "not_found_type", + content: ` +applications: + foo: + type: strapi:latest`, + expectErrorMessage: "linter errors:\n - applications.foo.type: type not found: 'strapi'; it must be one of: " + + "composable, dotnet, elixir, golang, java, nodejs, php, python, ruby, rust " + + "(check the Registry for supported types, or make an application using a composable image)", + }, + { + name: "service_runtime_type", + content: ` +applications: + foo: + type: php:8.4 +services: + myservice: + 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: + - bun@1 + - ffmpeg`, + expectErrorMessage: `linter warnings: + - applications.foo: 'type' should be specified (as a composable image) when using 'stack'`, + }, + { + name: "composable_without_stack", + content: fmt.Sprintf(` +applications: + foo: + type: composable:%s`, registry.ChannelStable), + expectErrorMessage: `linter warnings: + - applications.foo: 'stack' should be specified when using a composable image`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + if err != nil { + assert.FailNow(t, "decodeConfig failed", err) + } + result := lint.CheckTypes(cfg, testRegistry) + if c.expectErrorMessage != "" { + assert.True(t, result.HasErrors() || result.HasWarnings()) + assert.Equal(t, c.expectErrorMessage, result.Error()) + } else { + assert.False(t, result.HasErrors()) + } + }) + } +} diff --git a/internal/lint/web.go b/internal/lint/web.go new file mode 100644 index 000000000..4acb73de1 --- /dev/null +++ b/internal/lint/web.go @@ -0,0 +1,90 @@ +package lint + +import ( + "fmt" + "strings" + + "github.com/dlclark/regexp2/v2" +) + +// invalidPathChars contains characters that are invalid in URL paths without percent-encoding. +const invalidPathChars = "^#?<>[]{}\\|\"` " + +// validateLocationPath checks if a location key is a valid URL path. +// It returns an error message if invalid, or an empty string if valid. +func validateLocationPath(path string) string { + if !strings.HasPrefix(path, "/") { + return "must start with a slash" + } + if strings.ContainsAny(path, invalidPathChars) { + return "contains invalid characters" + } + // Special case for "/" - valid as-is. + if path == "/" { + return "" + } + // Check for empty segments, "." or ".." segments. + parts := strings.Split(strings.TrimSuffix(path, "/"), "/") + for _, part := range parts[1:] { // Skip first empty part from leading slash. + if part == "" || part == "." || part == ".." { + return "cannot include empty parts, '.' or '..'" + } + } + return "" +} + +func CheckWebConfig(cfg *Config) *Result { + result := &Result{} + + for appName, app := range cfg.Applications { + for locName, loc := range app.Web.Locations { + // Validate location key format. + locKeyPath := "applications." + appName + ".web.locations" + if errMsg := validateLocationPath(locName); errMsg != "" { + result.AddError(locKeyPath, fmt.Sprintf("location key %q %s", locName, errMsg)) + continue + } + + path := "applications." + appName + ".web.locations[\"" + locName + "\"].root" + + // Lint root path + if loc.Root != "" { + // Check for leading slash (not allowed) + if loc.Root == "/" { + result.AddError(path, "the root cannot begin with a slash (use an empty string instead)") + continue + } else if strings.HasPrefix(loc.Root, "/") { + result.AddError(path, "the root cannot begin with a slash") + continue + } + + // Check for invalid path components (no leading slash here) + parts := strings.SplitSeq(strings.TrimSuffix(loc.Root, "/"), "/") + for part := range parts { + if part == "" || part == "." || part == ".." { + path := "applications." + appName + ".web.locations[\"" + locName + "\"].root" + result.AddError(path, "the root path cannot include empty parts, '.' or '..'") + break + } + } + } + + // Lint rules regular expressions + for rulePattern := range loc.Rules { + if rulePattern == "" { + path := "applications." + appName + ".web.locations[\"" + locName + "\"].rules" + result.AddError(path, "rule pattern cannot be empty") + continue + } + + // Use regexp2 to validate PCRE regex syntax (Nginx uses PCRE) + _, err := regexp2.Compile(rulePattern) + if err != nil { + path := "applications." + appName + ".web.locations[\"" + locName + "\"].rules" + result.AddError(path, "invalid regular expression: "+err.Error()) + } + } + } + } + return result +} diff --git a/internal/lint/web_test.go b/internal/lint/web_test.go new file mode 100644 index 000000000..c195ddfd3 --- /dev/null +++ b/internal/lint/web_test.go @@ -0,0 +1,479 @@ +package lint_test + +import ( + "testing" + + "github.com/upsun/cli/internal/lint" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckWebConfig(t *testing.T) { + cases := []struct { + name string + content string + wantErr bool + errMatch string + }{ + { + name: "location key without leading slash", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "api": + root: "public" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"api\" must start with a slash", + }, + { + name: "location key with empty segment", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/api//v1": + root: "public" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"/api//v1\" cannot include empty parts, '.' or '..'", + }, + { + name: "location key with dot segment", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/api/./v1": + root: "public" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"/api/./v1\" cannot include empty parts, '.' or '..'", + }, + { + name: "location key with dotdot segment", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/api/../v1": + root: "public" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"/api/../v1\" cannot include empty parts, '.' or '..'", + }, + { + name: "location key with regex pattern from issue AI-105", + content: ` +applications: + testdemo: + type: nodejs:24 + web: + locations: + "/": + root: "public" + "^/(api|health)": + passthru: true +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.testdemo.web.locations: location key \"^/(api|health)\" must start with a slash", + }, + { + name: "location key with query string character", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/api?version=1": + root: "public" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"/api?version=1\" contains invalid characters", + }, + { + name: "location key with fragment character", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/page#section": + root: "public" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations: location key \"/page#section\" contains invalid characters", + }, + { + name: "valid location keys", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + "/api": + root: "api" + "/api/v1": + root: "api/v1" + "/health-check": + root: "health" + "/static_files": + root: "static" + "/path.with.dots": + root: "path" + "/~user": + root: "user" + "/user@example": + root: "user" + "/path$end": + root: "path" + "/path(group)": + root: "path" + "/file+name": + root: "file" + "/trailing/": + root: "trailing" +`, + }, + { + name: "invalid root with leading slash", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/public" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + { + name: "invalid root with leading slash and trailing slash", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/public/" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + { + name: "empty root", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "" +`, + }, + { + name: "valid root without slash", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" +`, + }, + { + name: "invalid single slash root", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash (use an empty string instead)", + }, + { + name: "invalid root with leading slash and empty part", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/foo//bar" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + { + name: "invalid root with empty part (no leading slash)", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "foo//bar" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root path cannot include empty parts, '.' or '..'", + }, + { + name: "invalid root with leading slash and dot", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/foo/./bar" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + { + name: "invalid root with dot (no leading slash)", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "foo/./bar" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root path cannot include empty parts, '.' or '..'", + }, + { + name: "invalid root with leading slash and dotdot", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/foo/../bar" +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + { + name: "invalid root with dotdot (no leading slash)", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "foo/../bar" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root path cannot include empty parts, '.' or '..'", + }, + { + name: "multiple failing locations with leading slashes", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/foo": + root: "/foo/./bar" + "/bar": + root: "/bar//baz" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/bar\"].root: the root cannot begin with a slash\n" + + " - applications.app1.web.locations[\"/foo\"].root: the root cannot begin with a slash", + }, + { + name: "multiple failing apps with leading slashes", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/foo": + root: "/foo/./bar" + app2: + type: php:8.4 + web: + locations: + "/bar": + root: "/bar//baz" +`, + wantErr: true, + //nolint:lll + errMatch: "linter errors:\n - applications.app1.web.locations[\"/foo\"].root: the root cannot begin with a slash\n" + + " - applications.app2.web.locations[\"/bar\"].root: the root cannot begin with a slash", + }, + { + name: "valid regex rules", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + rules: + "^/api/": + allow: false + "\\.css$": + expires: "1d" + "^/images/.*\\.(jpg|png|gif)$": + cache: true +`, + }, + { + name: "invalid regex rule - unmatched parentheses", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + rules: + "^/api/(unclosed": + allow: false +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].rules: " + + "invalid regular expression: error parsing regexp: missing closing ) in `^/api/(unclosed`", + }, + { + name: "invalid regex rule - invalid escape sequence", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + rules: + "\\x": + allow: false +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].rules: " + + "invalid regular expression: error parsing regexp: insufficient hexadecimal digits in `\\x`", + }, + { + name: "empty regex rule pattern", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + rules: + "": + allow: false +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].rules: rule pattern cannot be empty", + }, + { + name: "multiple invalid regex rules", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "public" + rules: + "^/api/(unclosed": + allow: false + "[invalid": + cache: true +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].rules: " + + "invalid regular expression: error parsing regexp: missing closing ) in `^/api/(unclosed`\n" + + " - applications.app1.web.locations[\"/\"].rules: " + + "invalid regular expression: error parsing regexp: unterminated [] set in `[invalid`", + }, + { + name: "mixed root and regex linter errors", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + root: "/foo/../bar" + rules: + "^/api/(unclosed": + allow: false +`, + wantErr: true, + errMatch: "linter errors:\n - applications.app1.web.locations[\"/\"].root: the root cannot begin with a slash", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + assert.NoError(t, err) + result := lint.CheckWebConfig(cfg) + if c.wantErr { + require.True(t, result.HasErrors()) + assert.Equal(t, c.errMatch, result.Error()) + } else { + assert.False(t, result.HasErrors()) + } + }) + } +} diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go new file mode 100644 index 000000000..524031067 --- /dev/null +++ b/internal/lint/yaml.go @@ -0,0 +1,41 @@ +package lint + +import ( + "fmt" + "strings" + + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" +) + +// CheckYAMLSchema checks that YAML content matches a JSON schema. +func CheckYAMLSchema(content string, schema *gojsonschema.Schema) *Result { + result := &Result{} + + var data = make(map[string]any) + if err := yaml.Unmarshal([]byte(content), &data); err != nil { + result.AddError("", interpretYAMLError(err)) + return result + } + + schemaResult, err := schema.Validate(gojsonschema.NewGoLoader(data)) + if err != nil { + result.AddError("", err.Error()) + return result + } + if !schemaResult.Valid() { + for _, e := range schemaResult.Errors() { + result.AddError(e.Field(), e.Description()) + } + } + + return result +} + +func interpretYAMLError(err error) string { + msg := err.Error() + if strings.Contains(msg, "unknown escape character") { + return fmt.Sprintf("%s: perhaps use single quotes for complex strings", msg) + } + return msg +} diff --git a/internal/lint/yaml_test.go b/internal/lint/yaml_test.go new file mode 100644 index 000000000..fbfa475e3 --- /dev/null +++ b/internal/lint/yaml_test.go @@ -0,0 +1,98 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xeipuuv/gojsonschema" + + "github.com/upsun/cli/internal/lint" +) + +func TestCheckYAMLSchema(t *testing.T) { + cases := []struct { + name string + content string + wantErr bool + errorMsg string + }{ + { + name: "valid YAML with schema compliance", + content: ` +key: value +list: + - item1 + - item2`, + wantErr: false, + }, + { + name: "valid YAML but not matching schema", + content: `invalidKey: someValue`, + wantErr: true, + errorMsg: "linter errors:", + }, + { + name: "invalid YAML malformed syntax", + content: ` +key: value +list: + - item1 + - item2 + [misplacedItem]`, + wantErr: true, + errorMsg: "yaml: line 6: could not find expected ':'", + }, + { + name: "empty YAML content", + wantErr: true, + errorMsg: "linter errors:", + }, + { + name: "YAML with invalid types against schema", + content: ` +key: 12345 +list: + - item1 + - item2`, + wantErr: true, + errorMsg: "linter errors:", + }, + } + + schema := mockSchema() + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result := lint.CheckYAMLSchema(c.content, schema) + if c.wantErr { + assert.True(t, result.HasErrors()) + if c.errorMsg != "" { + assert.Contains(t, result.Error(), c.errorMsg) + } + } else { + assert.False(t, result.HasErrors()) + } + }) + } +} + +func mockSchema() *gojsonschema.Schema { + schema, _ := gojsonschema.NewSchema(gojsonschema.NewStringLoader(` + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["key"] + }`)) + return schema +} From e03749660b42ebda4a367a4a638e9ce8f1a66140 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:13:51 +0100 Subject: [PATCH 02/36] feat(lint): add native lint command for Flex config Replace the platformify-backed app:config-validate command (which delegated to the legacy PHP CLI) with a native Go command that runs the ported linter and reports all errors and warnings at once. - Add internal/lint/normalize.go: DetectStyle plus LintDir, which detect the configuration style from the directory layout (.upsun vs .platform) and lint the merged Flex configuration. Fixed-style linting is stubbed pending Phase 3. - Add commands/lint.go: the "lint" command (aliases "validate", "app:config-validate") accepting an optional path or stdin, with text and JSON output, exiting non-zero on errors. - Rename Lint to LintContent and add JSON tags to Issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/lint.go | 109 ++++++++++++++++++++++++++++++++ commands/root.go | 11 +--- internal/lint/linter.go | 2 +- internal/lint/linter_test.go | 2 +- internal/lint/normalize.go | 103 ++++++++++++++++++++++++++++++ internal/lint/normalize_test.go | 88 ++++++++++++++++++++++++++ internal/lint/result.go | 4 +- 7 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 commands/lint.go create mode 100644 internal/lint/normalize.go create mode 100644 internal/lint/normalize_test.go diff --git a/commands/lint.go b/commands/lint.go new file mode 100644 index 000000000..d644855c2 --- /dev/null +++ b/commands/lint.go @@ -0,0 +1,109 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/upsun/cli/internal/config" + "github.com/upsun/cli/internal/lint" +) + +// errLintFailed signals that the configuration has errors, for a non-zero exit +// code. Its message is empty because output is printed by the command itself. +var errLintFailed = errors.New("") + +func newLintCommand(cnf *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "lint [path]", + Short: "Validate project configuration", + Aliases: []string{"validate", "app:config-validate"}, + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: runLint, + } + cmd.Flags().Bool("stdin", false, "Read merged Flex configuration from standard input") + cmd.Flags().String("format", "text", "Output format: text or json") + cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) { + internalCmd := innerAppConfigValidateCommand(cnf) + fmt.Println(internalCmd.HelpPage(cnf)) + }) + return cmd +} + +func runLint(cmd *cobra.Command, args []string) error { + result, format, err := lintInput(cmd, args) + if err != nil { + // Print operational errors ourselves, since the command silences errors. + fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(err.Error())) + return errLintFailed + } + return printLintResult(cmd, result, format) +} + +func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) { + useStdin, _ := cmd.Flags().GetBool("stdin") + format, _ := cmd.Flags().GetString("format") + if format != "text" && format != "json" { + return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) + } + + if !useStdin && len(args) == 0 { + if stat, err := os.Stdin.Stat(); err == nil && (stat.Mode()&os.ModeCharDevice) == 0 { + useStdin = true + } + } + + ctx := cmd.Context() + if useStdin { + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, format, err + } + result, err := lint.LintContent(ctx, string(content)) + return result, format, err + } + + path := "." + if len(args) == 1 { + path = args[0] + } + result, _, err := lint.LintDir(ctx, path) + return result, format, err +} + +func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error { + if format == "json" { + out := struct { + Errors []lint.Issue `json:"errors"` + Warnings []lint.Issue `json:"warnings"` + }{Errors: result.Errors, Warnings: result.Warnings} + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + return err + } + if result.HasErrors() { + return errLintFailed + } + return nil + } + + w := cmd.ErrOrStderr() + if result.HasErrors() { + fmt.Fprintln(w, color.RedString(result.String())) + return errLintFailed + } + if result.HasWarnings() { + fmt.Fprintln(w, color.YellowString(result.String())) + return nil + } + fmt.Fprintln(w, color.GreenString("✓ The configuration is valid.")) + return nil +} diff --git a/commands/root.go b/commands/root.go index 0db2a8240..c445cf944 100644 --- a/commands/root.go +++ b/commands/root.go @@ -13,7 +13,6 @@ import ( "strings" "github.com/fatih/color" - "github.com/platformsh/platformify/commands" "github.com/platformsh/platformify/vendorization" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -150,14 +149,6 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob " This implies --no-interaction. Ignored in verbose mode.", ) - validateCmd := commands.NewValidateCommand(assets) - validateCmd.Use = "app:config-validate" - validateCmd.Aliases = []string{"validate", "lint"} - validateCmd.SetHelpFunc(func(_ *cobra.Command, _ []string) { - internalCmd := innerAppConfigValidateCommand(cnf) - fmt.Println(internalCmd.HelpPage(cnf)) - }) - // Add subcommands. cmd.AddCommand( newCompleteCommand(cnf), @@ -165,8 +156,8 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob newCompletionCommand(cnf), newHelpCommand(cnf), newInitCommand(cnf, assets), + newLintCommand(cnf), newListCommand(cnf), - validateCmd, versionCommand, ) if cnf.Service.ProjectConfigFlavor == "upsun" { diff --git a/internal/lint/linter.go b/internal/lint/linter.go index 991803b7e..205cc36a1 100644 --- a/internal/lint/linter.go +++ b/internal/lint/linter.go @@ -12,7 +12,7 @@ import ( var ErrEmptyContent = errors.New("empty content") // Lint checks generated configuration and returns a Result. -func Lint(_ context.Context, content string) (*Result, error) { +func LintContent(_ context.Context, content string) (*Result, error) { if len(content) == 0 { return nil, ErrEmptyContent } diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 5d9a62b43..4d6e3eecc 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -94,7 +94,7 @@ services: for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := Lint(context.Background(), tc.content) + result, err := LintContent(context.Background(), tc.content) assert.NoError(t, err) if tc.wantErr { diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go new file mode 100644 index 000000000..8e6aedfa9 --- /dev/null +++ b/internal/lint/normalize.go @@ -0,0 +1,103 @@ +package lint + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// Style identifies which configuration style a project uses. +type Style int + +const ( + StyleUnknown Style = iota + // StyleFlex is the Upsun unified configuration (.upsun/*.yaml). + StyleFlex + // StyleFixed is the legacy Platform.sh configuration (.platform.app.yaml, .platform/*.yaml). + StyleFixed +) + +func (s Style) String() string { + switch s { + case StyleFlex: + return "flex" + case StyleFixed: + return "fixed" + default: + return "unknown" + } +} + +// LintDir detects the configuration style in dir and lints it, returning the +// detected style. Detection is based purely on the directory layout. +func LintDir(ctx context.Context, dir string) (*Result, Style, error) { + flex := hasFlexConfig(dir) + fixed := hasFixedConfig(dir) + + switch { + case flex: + content, err := loadFlex(dir) + if err != nil { + return nil, StyleFlex, err + } + result, err := LintContent(ctx, content) + if err != nil { + return nil, StyleFlex, err + } + if fixed { + result.AddWarning("", "both .upsun and .platform configuration found; "+ + "linting the .upsun (Flex) configuration") + } + return result, StyleFlex, nil + case fixed: + return nil, StyleFixed, fmt.Errorf("fixed-style (.platform) configuration linting is not yet implemented") + default: + return nil, StyleUnknown, fmt.Errorf( + "no Upsun configuration found in %q (looked for .upsun/*.yaml and .platform[.app].yaml)", dir) + } +} + +// loadFlex merges the .upsun/*.yaml files in dir into a single YAML document. +func loadFlex(dir string) (string, error) { + return getMergedConfigFiles(os.DirFS(dir), ".") +} + +// hasFlexConfig reports whether dir contains .upsun/*.yaml configuration files. +func hasFlexConfig(dir string) bool { + files, err := findUpsunConfigFiles(os.DirFS(dir), ".") + return err == nil && len(files) > 0 +} + +// hasFixedConfig reports whether dir contains legacy Platform.sh configuration. +func hasFixedConfig(dir string) bool { + if fi, err := os.Stat(filepath.Join(dir, ".platform")); err == nil && fi.IsDir() { + return true + } + return len(findFixedAppFiles(dir)) > 0 +} + +// findFixedAppFiles returns the paths of all .platform.app.yaml files under dir. +func findFixedAppFiles(dir string) []string { + var found []string + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + switch d.Name() { + case ".git", "node_modules", "vendor": + if path != dir { + return fs.SkipDir + } + } + return nil + } + if d.Name() == ".platform.app.yaml" { + found = append(found, path) + } + return nil + }) + return found +} diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go new file mode 100644 index 000000000..20588ead4 --- /dev/null +++ b/internal/lint/normalize_test.go @@ -0,0 +1,88 @@ +package lint + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func TestDetectStyle(t *testing.T) { + t.Run("flex", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), "applications: {}") + assert.True(t, hasFlexConfig(dir)) + assert.False(t, hasFixedConfig(dir)) + }) + + t.Run("fixed via app file", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") + assert.False(t, hasFlexConfig(dir)) + assert.True(t, hasFixedConfig(dir)) + }) + + t.Run("fixed via .platform dir", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".platform", "routes.yaml"), "{}") + assert.True(t, hasFixedConfig(dir)) + }) + + t.Run("none", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, hasFlexConfig(dir)) + assert.False(t, hasFixedConfig(dir)) + }) +} + +func TestLintDir_Flex(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `applications: + app: + type: "php:8.3" + relationships: + database: "db:postgresql" +services: + db: + type: "postgresql:16" +routes: + "https://{default}/": + type: upstream + upstream: "app:http" +`) + result, style, err := LintDir(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, StyleFlex, style) + assert.False(t, result.HasErrors(), "expected no errors, got: %s", result) +} + +func TestLintDir_NoConfig(t *testing.T) { + dir := t.TempDir() + _, style, err := LintDir(context.Background(), dir) + require.Error(t, err) + assert.Equal(t, StyleUnknown, style) + assert.Contains(t, err.Error(), "no Upsun configuration found") +} + +func TestLintDir_BothPresentPrefersFlex(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `applications: + app: + type: "php:8.3" +`) + writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") + result, style, err := LintDir(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, StyleFlex, style) + assert.True(t, result.HasWarnings()) + assert.Contains(t, result.String(), "both .upsun and .platform") +} diff --git a/internal/lint/result.go b/internal/lint/result.go index 4a784ccb2..1f359dc15 100644 --- a/internal/lint/result.go +++ b/internal/lint/result.go @@ -14,8 +14,8 @@ type Result struct { // Issue represents a single linting problem with its location in the content. type Issue struct { - Path string // e.g., "applications.foo.type", "services.database" - Message string + Path string `json:"path"` // e.g., "applications.foo.type", "services.database" + Message string `json:"message"` } // AddError adds an error to the linter result. From 4cdf0d3ec9458a8700ac6acb400df83c42db012c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:23:37 +0100 Subject: [PATCH 03/36] feat(lint): support Fixed-style (legacy Platform.sh) config Add linting for legacy Platform.sh configuration: .platform.app.yaml files and .platform/applications.yaml (list or map form), plus optional .platform/routes.yaml and .platform/services.yaml. CheckDir detects the style from the directory layout and normalizes Fixed-style config into the same Config the Flex path uses, so the semantic checks are shared. - Add the three Fixed-style JSON schemas (application, routes, services) copied from platformify, with per-file loaders and CheckSchemaScoped to attribute schema errors to their source file or app. - Gate composable-image and stack warnings to Flex in CheckTypes. - Guard against Flex-style keys appearing in a Fixed-style file. - Inject the application name from the map key when validating map-form applications.yaml, matching the canonical parser. - Rename the entrypoints to CheckContent and CheckDir, consistent with the Check* family, and satisfy the repository linters. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/lint.go | 4 +- internal/lint/dependencies.go | 3 +- internal/lint/fixed.go | 256 +++++ internal/lint/fixed_test.go | 114 +++ internal/lint/linter.go | 23 +- internal/lint/linter_test.go | 2 +- internal/lint/merge.go | 10 +- internal/lint/names.go | 3 +- internal/lint/normalize.go | 11 +- internal/lint/normalize_test.go | 8 +- internal/lint/registry/model.go | 7 +- internal/lint/registry/registry.go | 3 +- internal/lint/relationships.go | 6 +- internal/lint/routes.go | 2 +- internal/lint/schema/fixed.go | 63 ++ .../lint/schema/platformsh.application.json | 935 ++++++++++++++++++ internal/lint/schema/platformsh.routes.json | 401 ++++++++ internal/lint/schema/platformsh.services.json | 114 +++ internal/lint/scripts.go | 3 +- internal/lint/types.go | 13 +- internal/lint/types_test.go | 2 +- internal/lint/web.go | 3 +- internal/lint/yaml.go | 31 + 23 files changed, 1974 insertions(+), 43 deletions(-) create mode 100644 internal/lint/fixed.go create mode 100644 internal/lint/fixed_test.go create mode 100644 internal/lint/schema/fixed.go create mode 100644 internal/lint/schema/platformsh.application.json create mode 100644 internal/lint/schema/platformsh.routes.json create mode 100644 internal/lint/schema/platformsh.services.json diff --git a/commands/lint.go b/commands/lint.go index d644855c2..6ae72bb54 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -66,7 +66,7 @@ func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) if err != nil { return nil, format, err } - result, err := lint.LintContent(ctx, string(content)) + result, err := lint.CheckContent(ctx, string(content)) return result, format, err } @@ -74,7 +74,7 @@ func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) if len(args) == 1 { path = args[0] } - result, _, err := lint.LintDir(ctx, path) + result, _, err := lint.CheckDir(ctx, path) return result, format, err } diff --git a/internal/lint/dependencies.go b/internal/lint/dependencies.go index b5f4822ff..ab6941c44 100644 --- a/internal/lint/dependencies.go +++ b/internal/lint/dependencies.go @@ -14,7 +14,8 @@ func CheckDependencies(cfg *Config) *Result { "ruby": true, } - for appName, app := range cfg.Applications { + for appName := range cfg.Applications { + app := cfg.Applications[appName] for depType, packages := range app.Dependencies { // Lint dependency type if !validTypes[depType] { diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go new file mode 100644 index 000000000..924c297fe --- /dev/null +++ b/internal/lint/fixed.go @@ -0,0 +1,256 @@ +package lint + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" + + "github.com/upsun/cli/internal/lint/schema" +) + +// Configuration section keys and the legacy per-app config file name. +const ( + keyApplications = "applications" + keyServices = "services" + keyRoutes = "routes" + fixedAppConfig = ".platform.app.yaml" +) + +// flexTopKeys are the top-level keys that indicate Flex-style configuration. +var flexTopKeys = []string{keyApplications, keyServices, keyRoutes} + +// lintFixed lints legacy Platform.sh (Fixed-style) configuration in dir: +// .platform.app.yaml files and/or .platform/applications.yaml, plus optional +// .platform/routes.yaml and .platform/services.yaml. +func lintFixed(_ context.Context, dir string) (*Result, error) { + result := &Result{} + + apps, err := loadFixedApplications(dir, result) + if err != nil { + return nil, err + } + + services, err := loadFixedSection(dir, "services.yaml", schema.LoadServices, result) + if err != nil { + return nil, err + } + routes, err := loadFixedSection(dir, "routes.yaml", schema.LoadRoutes, result) + if err != nil { + return nil, err + } + + if len(apps) == 0 && !result.HasErrors() { + result.AddError("", "no application configuration found") + } + + // If the structure is invalid, don't run semantic checks over a broken config. + if result.HasErrors() { + return result, nil + } + + merged := map[string]any{keyApplications: apps} + if len(services) > 0 { + merged[keyServices] = services + } + if len(routes) > 0 { + merged[keyRoutes] = routes + } + + mergedYAML, err := yaml.Marshal(merged) + if err != nil { + return nil, err + } + cfg, err := DecodeConfig(string(mergedYAML)) + if err != nil { + return nil, err + } + + checks, err := runChecks(cfg, StyleFixed) + if err != nil { + return nil, err + } + return Combine(result, checks), nil +} + +// loadFixedApplications collects applications from .platform.app.yaml files and +// .platform/applications.yaml, validating each against the application schema. +func loadFixedApplications(dir string, result *Result) (map[string]any, error) { + appSchema, err := schema.LoadApplication() + if err != nil { + return nil, fmt.Errorf("failed to load application schema: %w", err) + } + + apps := map[string]any{} + add := func(name, source string, data map[string]any) { + if _, dup := apps[name]; dup { + result.AddError(source, fmt.Sprintf("duplicate application name %q", name)) + return + } + apps[name] = data + } + + // Individual .platform.app.yaml files. + for _, abs := range findFixedAppFiles(dir) { + source := relTo(dir, abs) + data, err := readYAMLMap(abs) + if err != nil { + result.AddError(source, err.Error()) + continue + } + if data == nil { + continue + } + if hasAnyKey(data, flexTopKeys) { + result.AddError(source, "this looks like Flex (.upsun) configuration in a Fixed-style file") + continue + } + *result = *Combine(result, CheckSchemaScoped(data, appSchema, source)) + add(fixedAppName(data), source, data) + } + + // .platform/applications.yaml (a list of apps, or a map keyed by app name). + appsFile := filepath.Join(dir, ".platform", "applications.yaml") + raw, err := os.ReadFile(appsFile) + if err != nil { + if os.IsNotExist(err) { + return apps, nil + } + result.AddError(".platform/applications.yaml", err.Error()) + return apps, nil + } + var doc any + if err := yaml.Unmarshal(raw, &doc); err != nil { + result.AddError(".platform/applications.yaml", interpretYAMLError(err)) + return apps, nil + } + switch v := doc.(type) { + case []any: + for i, item := range v { + data, ok := toStringMap(item) + if !ok { + result.AddError(fmt.Sprintf(".platform/applications.yaml[%d]", i), "application must be a map") + continue + } + src := fmt.Sprintf(".platform/applications.yaml[%d]", i) + *result = *Combine(result, CheckSchemaScoped(data, appSchema, src)) + add(fixedAppName(data), src, data) + } + case map[string]any: + for name, item := range v { + if strings.HasPrefix(name, ".") { + continue + } + data, ok := toStringMap(item) + if !ok { + result.AddError(".platform/applications.yaml: "+name, "application must be a map") + continue + } + // In map form the name comes from the key, not the value. + if _, ok := data["name"]; !ok { + data["name"] = name + } + src := ".platform/applications.yaml: " + name + *result = *Combine(result, CheckSchemaScoped(data, appSchema, src)) + add(name, src, data) + } + case nil: + // Empty file. + default: + result.AddError(".platform/applications.yaml", "contents must be a YAML list or map") + } + + return apps, nil +} + +// loadFixedSection reads and schema-validates an optional .platform/, +// returning its decoded map (keyed by name/URL). +func loadFixedSection( + dir, file string, + loadSchema func() (*gojsonschema.Schema, error), + result *Result, +) (map[string]any, error) { + path := filepath.Join(dir, ".platform", file) + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + result.AddError(".platform/"+file, err.Error()) + return nil, nil + } + data := map[string]any{} + if err := yaml.Unmarshal(raw, &data); err != nil { + result.AddError(".platform/"+file, interpretYAMLError(err)) + return nil, nil + } + if len(data) == 0 { + return nil, nil + } + sch, err := loadSchema() + if err != nil { + return nil, fmt.Errorf("failed to load %s schema: %w", file, err) + } + *result = *Combine(result, CheckSchemaScoped(data, sch, ".platform/"+file)) + return data, nil +} + +// fixedAppName returns an application's name from its config, defaulting to "app". +func fixedAppName(data map[string]any) string { + if name, ok := data["name"].(string); ok && name != "" { + return name + } + return "app" +} + +func readYAMLMap(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + data := map[string]any{} + if err := yaml.Unmarshal(raw, &data); err != nil { + return nil, fmt.Errorf("%s", interpretYAMLError(err)) + } + return data, nil +} + +func hasAnyKey(m map[string]any, keys []string) bool { + for _, k := range keys { + if _, ok := m[k]; ok { + return true + } + } + return false +} + +func toStringMap(v any) (map[string]any, bool) { + switch m := v.(type) { + case map[string]any: + return m, true + case map[any]any: + out := make(map[string]any, len(m)) + for k, val := range m { + ks, ok := k.(string) + if !ok { + return nil, false + } + out[ks] = val + } + return out, true + default: + return nil, false + } +} + +// relTo returns abs relative to dir, falling back to abs on error. +func relTo(dir, abs string) string { + if rel, err := filepath.Rel(dir, abs); err == nil { + return rel + } + return abs +} diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go new file mode 100644 index 000000000..f577ca64a --- /dev/null +++ b/internal/lint/fixed_test.go @@ -0,0 +1,114 @@ +package lint + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLintDir_Fixed(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantErrors []string + wantNoErr bool + }{ + { + name: "valid single app", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.3" +relationships: + database: "db:postgresql"`, + ".platform/services.yaml": `db: + type: "postgresql:16"`, + ".platform/routes.yaml": `"https://{default}/": + type: upstream + upstream: "myapp:http"`, + }, + wantNoErr: true, + }, + { + name: "invalid type and bad upstream", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:999"`, + ".platform/routes.yaml": `"https://{default}/": + type: upstream + upstream: "missing:http"`, + }, + wantErrors: []string{ + "applications.myapp.type: version '999' is not supported", + "upstream target 'missing' does not exist", + }, + }, + { + name: "applications.yaml map form", + files: map[string]string{ + ".platform/applications.yaml": `frontend: + type: "php:8.3"`, + }, + wantNoErr: true, + }, + { + name: "applications.yaml list form requires route for multiple apps", + files: map[string]string{ + ".platform/applications.yaml": `- name: frontend + type: "php:8.3" +- name: backend + type: "php:8.3"`, + }, + wantErrors: []string{"at least 1 route must be defined when multiple applications are defined"}, + }, + { + name: "wrong style guard", + files: map[string]string{ + ".platform.app.yaml": `applications: + foo: + type: "php:8.3"`, + }, + wantErrors: []string{"looks like Flex (.upsun) configuration in a Fixed-style file"}, + }, + { + name: "app file missing required name", + files: map[string]string{ + ".platform.app.yaml": `type: "php:8.3"`, + }, + wantErrors: []string{".platform.app.yaml: name is required"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tc.files { + writeFile(t, filepath.Join(dir, name), content) + } + + result, style, err := CheckDir(context.Background(), dir) + require.NoError(t, err) + assert.Equal(t, StyleFixed, style) + + if tc.wantNoErr { + assert.False(t, result.HasErrors(), "expected no errors, got: %s", result) + return + } + assert.True(t, result.HasErrors()) + for _, want := range tc.wantErrors { + assert.Contains(t, result.String(), want) + } + }) + } +} + +func TestLintFixed_DuplicateAppName(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") + writeFile(t, filepath.Join(dir, "b", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") + result, _, err := CheckDir(context.Background(), dir) + require.NoError(t, err) + assert.Contains(t, result.String(), `duplicate application name "same"`) +} diff --git a/internal/lint/linter.go b/internal/lint/linter.go index 205cc36a1..1c6c88c8f 100644 --- a/internal/lint/linter.go +++ b/internal/lint/linter.go @@ -11,17 +11,12 @@ import ( var ErrEmptyContent = errors.New("empty content") -// Lint checks generated configuration and returns a Result. -func LintContent(_ context.Context, content string) (*Result, error) { +// CheckContent checks merged Flex-style configuration content and returns a Result. +func CheckContent(_ context.Context, content string) (*Result, error) { if len(content) == 0 { return nil, ErrEmptyContent } - reg, err := registry.Parsed() - if err != nil { - return nil, fmt.Errorf("failed to load registry: %w", err) - } - yamlSchema, err := schema.Load() if err != nil { return nil, fmt.Errorf("failed to load schema: %w", err) @@ -37,11 +32,21 @@ func LintContent(_ context.Context, content string) (*Result, error) { return nil, err } - // Run all other validation checks. + return runChecks(cfg, StyleFlex) +} + +// runChecks runs the semantic checks over a decoded config, adapting some +// checks to the configuration style. +func runChecks(cfg *Config, style Style) (*Result, error) { + reg, err := registry.Parsed() + if err != nil { + return nil, fmt.Errorf("failed to load registry: %w", err) + } + return Combine( CheckRelationships(cfg), CheckNames(cfg), - CheckTypes(cfg, reg), + CheckTypes(cfg, reg, style), CheckScripts(cfg), CheckWebConfig(cfg), CheckDependencies(cfg), diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 4d6e3eecc..e7f9196ce 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -94,7 +94,7 @@ services: for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := LintContent(context.Background(), tc.content) + result, err := CheckContent(context.Background(), tc.content) assert.NoError(t, err) if tc.wantErr { diff --git a/internal/lint/merge.go b/internal/lint/merge.go index f52f1cc23..1fe3fd809 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -35,9 +35,9 @@ func findUpsunConfigFiles(fsys fs.FS, path string) ([]string, error) { // If a key is duplicated across files, it returns an error. Returns the merged YAML as a string. func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { merged := map[string]map[string]any{ - "applications": {}, - "routes": {}, - "services": {}, + keyApplications: {}, + keyRoutes: {}, + keyServices: {}, } for _, file := range files { b, err := fs.ReadFile(fsys, file) @@ -48,7 +48,7 @@ func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { if err := yaml.Unmarshal(b, &doc); err != nil { return "", fmt.Errorf("failed to parse YAML in %s: %w", file, err) } - for _, key := range []string{"applications", "routes", "services"} { + for _, key := range []string{keyApplications, keyRoutes, keyServices} { if section, ok := doc[key]; ok && section != nil { sectionMap, ok := section.(map[string]any) if !ok { @@ -64,7 +64,7 @@ func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { } } out := map[string]any{} - for _, key := range []string{"applications", "routes", "services"} { + for _, key := range []string{keyApplications, keyRoutes, keyServices} { if len(merged[key]) > 0 { out[key] = merged[key] } diff --git a/internal/lint/names.go b/internal/lint/names.go index 6b0008615..1e93d30cd 100644 --- a/internal/lint/names.go +++ b/internal/lint/names.go @@ -18,7 +18,8 @@ var ( func CheckNames(cfg *Config) *Result { result := &Result{} - for name, app := range cfg.Applications { + for name := range cfg.Applications { + app := cfg.Applications[name] if err := validateServiceName(name, "application"); err != "" { result.AddError("applications."+name, err) } diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 8e6aedfa9..3ed2739c1 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -30,9 +30,9 @@ func (s Style) String() string { } } -// LintDir detects the configuration style in dir and lints it, returning the +// CheckDir detects the configuration style in dir and lints it, returning the // detected style. Detection is based purely on the directory layout. -func LintDir(ctx context.Context, dir string) (*Result, Style, error) { +func CheckDir(ctx context.Context, dir string) (*Result, Style, error) { flex := hasFlexConfig(dir) fixed := hasFixedConfig(dir) @@ -42,7 +42,7 @@ func LintDir(ctx context.Context, dir string) (*Result, Style, error) { if err != nil { return nil, StyleFlex, err } - result, err := LintContent(ctx, content) + result, err := CheckContent(ctx, content) if err != nil { return nil, StyleFlex, err } @@ -52,7 +52,8 @@ func LintDir(ctx context.Context, dir string) (*Result, Style, error) { } return result, StyleFlex, nil case fixed: - return nil, StyleFixed, fmt.Errorf("fixed-style (.platform) configuration linting is not yet implemented") + result, err := lintFixed(ctx, dir) + return result, StyleFixed, err default: return nil, StyleUnknown, fmt.Errorf( "no Upsun configuration found in %q (looked for .upsun/*.yaml and .platform[.app].yaml)", dir) @@ -94,7 +95,7 @@ func findFixedAppFiles(dir string) []string { } return nil } - if d.Name() == ".platform.app.yaml" { + if d.Name() == fixedAppConfig { found = append(found, path) } return nil diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index 20588ead4..3c72d516a 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -13,7 +13,7 @@ import ( func writeFile(t *testing.T, path, content string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) } func TestDetectStyle(t *testing.T) { @@ -59,7 +59,7 @@ routes: type: upstream upstream: "app:http" `) - result, style, err := LintDir(context.Background(), dir) + result, style, err := CheckDir(context.Background(), dir) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.False(t, result.HasErrors(), "expected no errors, got: %s", result) @@ -67,7 +67,7 @@ routes: func TestLintDir_NoConfig(t *testing.T) { dir := t.TempDir() - _, style, err := LintDir(context.Background(), dir) + _, style, err := CheckDir(context.Background(), dir) require.Error(t, err) assert.Equal(t, StyleUnknown, style) assert.Contains(t, err.Error(), "no Upsun configuration found") @@ -80,7 +80,7 @@ func TestLintDir_BothPresentPrefersFlex(t *testing.T) { type: "php:8.3" `) writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") - result, style, err := LintDir(context.Background(), dir) + result, style, err := CheckDir(context.Background(), dir) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.True(t, result.HasWarnings()) diff --git a/internal/lint/registry/model.go b/internal/lint/registry/model.go index 4f5f4b7e2..690070402 100644 --- a/internal/lint/registry/model.go +++ b/internal/lint/registry/model.go @@ -9,8 +9,8 @@ type Registry map[string]Image func (r Registry) AllTypes(runtime bool) []string { types := make([]string, 0, len(r)) - for k, v := range r { - if v.IsRuntime == runtime { + for k := range r { + if r[k].IsRuntime == runtime { types = append(types, k) } } @@ -120,7 +120,8 @@ func (v VersionInfo) LatestVersion() string { // with latest_version as a direct field for easier Jinja template access. func (r Registry) ForTemplates() map[string]map[string]any { result := make(map[string]map[string]any, len(r)) - for key, img := range r { + for key := range r { + img := r[key] result[key] = map[string]any{ "name": img.Name, "type": img.Type, diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go index 9b2dfbc7a..776b224da 100644 --- a/internal/lint/registry/registry.go +++ b/internal/lint/registry/registry.go @@ -33,7 +33,8 @@ func Parsed() (Registry, error) { // clean reduces irrelevant information in a registry, for the purposes of this project. func clean(reg Registry) { - for k, img := range reg { + for k := range reg { + img := reg[k] // Remove deprecated version info. img.Versions.Deprecated = nil // Remove descriptions. diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go index fa41c363e..2c6f6f067 100644 --- a/internal/lint/relationships.go +++ b/internal/lint/relationships.go @@ -17,7 +17,8 @@ func CheckRelationships(cfg *Config) *Result { serviceNames[name] = source return "" } - for appName, app := range cfg.Applications { + for appName := range cfg.Applications { + app := cfg.Applications[appName] if msg := addService(appName, "applications"); msg != "" { result.AddError("applications."+appName, msg) } @@ -34,7 +35,8 @@ func CheckRelationships(cfg *Config) *Result { } var linkedServices = make(map[string]struct{}) - for appName, appConfig := range cfg.Applications { + for appName := range cfg.Applications { + appConfig := cfg.Applications[appName] for relName, value := range appConfig.Relationships { // By default, the relationship links to the service with the same name. var relationshipService = relName diff --git a/internal/lint/routes.go b/internal/lint/routes.go index c2f112bb1..55e0300ba 100644 --- a/internal/lint/routes.go +++ b/internal/lint/routes.go @@ -53,7 +53,7 @@ func CheckRoutes(cfg *Config) *Result { // Check if the target exists if !validTargets[targetName] { // Build a helpful error message listing available targets - var availableTargets []string + availableTargets := make([]string, 0, len(validTargets)) for target := range validTargets { availableTargets = append(availableTargets, target) } diff --git a/internal/lint/schema/fixed.go b/internal/lint/schema/fixed.go new file mode 100644 index 000000000..6e5c10ef8 --- /dev/null +++ b/internal/lint/schema/fixed.go @@ -0,0 +1,63 @@ +package schema + +import ( + _ "embed" + "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 + + fixedOnce sync.Once + parsedApplicaton *gojsonschema.Schema + parsedRoutes *gojsonschema.Schema + parsedServices *gojsonschema.Schema + fixedErr error +) + +func loadFixed() error { + fixedOnce.Do(func() { + if parsedApplicaton, fixedErr = gojsonschema.NewSchema( + gojsonschema.NewBytesLoader(applicationSchema)); fixedErr != nil { + return + } + if parsedRoutes, fixedErr = gojsonschema.NewSchema( + gojsonschema.NewBytesLoader(routesSchema)); fixedErr != nil { + return + } + parsedServices, fixedErr = gojsonschema.NewSchema(gojsonschema.NewBytesLoader(servicesSchema)) + }) + return fixedErr +} + +// LoadApplication loads the Fixed-style application (.platform.app.yaml) schema. +func LoadApplication() (*gojsonschema.Schema, error) { + if err := loadFixed(); err != nil { + return nil, err + } + return parsedApplicaton, nil +} + +// LoadRoutes loads the Fixed-style routes (.platform/routes.yaml) schema. +func LoadRoutes() (*gojsonschema.Schema, error) { + if err := loadFixed(); err != nil { + return nil, err + } + return parsedRoutes, nil +} + +// LoadServices loads the Fixed-style services (.platform/services.yaml) schema. +func LoadServices() (*gojsonschema.Schema, error) { + if err := loadFixed(); err != nil { + return nil, err + } + return parsedServices, nil +} diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json new file mode 100644 index 000000000..c9167ed21 --- /dev/null +++ b/internal/lint/schema/platformsh.application.json @@ -0,0 +1,935 @@ +{ + "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", + "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 + } + }, + "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." + } + }, + "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." + } + }, + "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": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + } + ] + } + }, + "oneOf": [ + { + "required": [ + "name", + "type" + ] + }, + { + "required": [ + "name", + "stack" + ] + } + ], + "additionalProperties": false +} diff --git a/internal/lint/schema/platformsh.routes.json b/internal/lint/schema/platformsh.routes.json new file mode 100644 index 000000000..52f92c631 --- /dev/null +++ b/internal/lint/schema/platformsh.routes.json @@ -0,0 +1,401 @@ +{ + "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 new file mode 100644 index 000000000..0ce492156 --- /dev/null +++ b/internal/lint/schema/platformsh.services.json @@ -0,0 +1,114 @@ +{ + "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 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } +} \ No newline at end of file diff --git a/internal/lint/scripts.go b/internal/lint/scripts.go index 93a0a9e79..c14aa6075 100644 --- a/internal/lint/scripts.go +++ b/internal/lint/scripts.go @@ -11,7 +11,8 @@ func CheckScripts(cfg *Config) *Result { result := &Result{} var scripts = make(map[string]string) - for appName, app := range cfg.Applications { + for appName := range cfg.Applications { + app := cfg.Applications[appName] keyPrefix := "applications." + appName + "." // Warn if the start command is not set for non-PHP applications. diff --git a/internal/lint/types.go b/internal/lint/types.go index 9a5928395..5f749e8f1 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -9,13 +9,15 @@ import ( ) // CheckTypes checks that application, service and worker types are supported images and versions. -func CheckTypes(cfg *Config, reg registry.Registry) *Result { +func CheckTypes(cfg *Config, reg registry.Registry, style Style) *Result { result := &Result{} check := func(t string, runtime bool) error { return checkType(t, reg, runtime) } - for appName, app := range cfg.Applications { - if app.Type == "" && !isStackEmpty(app.Stack) { + for appName := range cfg.Applications { + app := cfg.Applications[appName] + // Composable images and the 'stack' key are a Flex-only feature. + if style == StyleFlex && app.Type == "" && !isStackEmpty(app.Stack) { // For backwards compatibility, we allow 'stack' to be specified without 'type'. result.AddWarning("applications."+appName, "'type' should be specified (as a composable image) when using 'stack'") @@ -24,11 +26,12 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { if err := check(app.Type, true); err != nil { result.AddError("applications."+appName+".type", err.Error()) } - if strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { + if style == StyleFlex && strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") } } - for appName, app := range cfg.Applications { + for appName := range cfg.Applications { + app := cfg.Applications[appName] for workerName, w := range app.Workers { if w.Type != "" { if err := check(w.Type, true); err != nil { diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 1afb4e00b..3bcf3953f 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -106,7 +106,7 @@ applications: if err != nil { assert.FailNow(t, "decodeConfig failed", err) } - result := lint.CheckTypes(cfg, testRegistry) + result := lint.CheckTypes(cfg, testRegistry, lint.StyleFlex) if c.expectErrorMessage != "" { assert.True(t, result.HasErrors() || result.HasWarnings()) assert.Equal(t, c.expectErrorMessage, result.Error()) diff --git a/internal/lint/web.go b/internal/lint/web.go index 4acb73de1..60d073b0c 100644 --- a/internal/lint/web.go +++ b/internal/lint/web.go @@ -36,7 +36,8 @@ func validateLocationPath(path string) string { func CheckWebConfig(cfg *Config) *Result { result := &Result{} - for appName, app := range cfg.Applications { + for appName := range cfg.Applications { + app := cfg.Applications[appName] for locName, loc := range app.Web.Locations { // Validate location key format. locKeyPath := "applications." + appName + ".web.locations" diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index 524031067..fb641bb8f 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -32,6 +32,37 @@ func CheckYAMLSchema(content string, schema *gojsonschema.Schema) *Result { return result } +// CheckSchemaScoped validates already-parsed data against a JSON schema, +// prefixing each issue's path with pathPrefix (e.g. a source file or app name). +func CheckSchemaScoped(data any, schema *gojsonschema.Schema, pathPrefix string) *Result { + result := &Result{} + + schemaResult, err := schema.Validate(gojsonschema.NewGoLoader(data)) + if err != nil { + result.AddError(pathPrefix, err.Error()) + return result + } + if !schemaResult.Valid() { + for _, e := range schemaResult.Errors() { + result.AddError(scopePath(pathPrefix, e.Field()), e.Description()) + } + } + + return result +} + +// scopePath joins a path prefix and a schema field path. +func scopePath(prefix, field string) string { + switch { + case prefix == "": + return field + case field == "" || field == "(root)": + return prefix + default: + return prefix + ": " + field + } +} + func interpretYAMLError(err error) string { msg := err.Error() if strings.Contains(msg, "unknown escape character") { From 805a54f8c91696bf217edf4998fd48a85f941dfc Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:31:12 +0100 Subject: [PATCH 04/36] feat(lint): refresh embedded registry and schemas from upstream Add tooling to regenerate the embedded lint assets: - gen.go (build-tagged) fetches https://meta.upsun.com/images and transforms it into registry.json, mapping per-version status to supported/legacy and service/runtime. Regenerate with `go generate ./internal/lint/registry` or `make lint-assets`. - `make lint-assets` also refreshes the Flex and Fixed-style schemas from platformify. The meta.upsun.com schema is intentionally not used: it validates types via remote $refs (fetched at runtime) and duplicates the registry-based type check, so type and version validation stays in CheckTypes. - `make lint-assets-check` and a CI job fail when the committed assets are stale. Refresh the registry from meta.upsun.com and make the registry test robust to version drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 17 + Makefile | 17 + internal/lint/linter_test.go | 6 +- internal/lint/registry/gen.go | 113 ++ internal/lint/registry/registry.go | 3 + internal/lint/registry/registry.json | 1230 ++--------------- internal/lint/registry/registry_test.go | 5 +- internal/lint/schema/schema.go | 4 + internal/lint/schema/upsun-config-schema.json | 1 + 9 files changed, 295 insertions(+), 1101 deletions(-) create mode 100644 internal/lint/registry/gen.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a62e6154..dc1d61a99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,23 @@ jobs: - name: Check goreleaser config run: make goreleaser-check + # Checks that the embedded lint registry and schemas match upstream. + # Fails when upstream publishes changes; run `make lint-assets` to refresh. + lint-assets: + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + + - name: Check embedded lint assets are up to date + run: make lint-assets-check + legacy-php: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index f50d2f545..be11ec607 100644 --- a/Makefile +++ b/Makefile @@ -142,6 +142,23 @@ lint-gomod: lint-golangci: golangci-lint run --timeout=2m +# Embedded lint assets, refreshed from upstream. +# - registry.json: transformed from https://meta.upsun.com/images by gen.go. +# - upsun-config-schema.json and the platformsh.*.json schemas: from platformify. +PLATFORMIFY_SCHEMA_URL = https://raw.githubusercontent.com/platformsh/platformify/refs/heads/main/validator/schema + +.PHONY: lint-assets +lint-assets: ## Refresh the embedded lint registry and schemas from upstream + cd internal/lint/registry && GOEXPERIMENT=jsonv2 go run gen.go + curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json + curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.application.json -o internal/lint/schema/platformsh.application.json + curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.routes.json -o internal/lint/schema/platformsh.routes.json + curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.services.json -o internal/lint/schema/platformsh.services.json + +.PHONY: lint-assets-check +lint-assets-check: lint-assets ## Fail if the embedded lint assets are stale + git diff --exit-code -- internal/lint/registry/registry.json internal/lint/schema + .goreleaser.vendor.yaml: check-vendor ## Generate the goreleaser vendor config cat .goreleaser.vendor.yaml.tpl | envsubst > .goreleaser.vendor.yaml diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index e7f9196ce..9e3649ca8 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -39,7 +39,7 @@ services: {} content: ` applications: foo: - type: golang:1.24 + type: golang:1.25 relationships: database: web: @@ -55,7 +55,7 @@ services: content: ` applications: foo: - type: golang:1.24 + type: golang:1.25 relationships: database: web: @@ -72,7 +72,7 @@ services: content: ` applications: foo: - type: golang:1.24 + type: golang:1.25 relationships: database: web: diff --git a/internal/lint/registry/gen.go b/internal/lint/registry/gen.go new file mode 100644 index 000000000..10d6f800e --- /dev/null +++ b/internal/lint/registry/gen.go @@ -0,0 +1,113 @@ +//go:build ignore + +// Command gen fetches the container image registry from meta.upsun.com and +// writes registry.json in the format consumed by the registry package. +// +// Run it with: go run gen.go (or `go generate ./internal/lint/registry`). +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/upsun/cli/internal/lint/registry" +) + +const imagesURL = "https://meta.upsun.com/images" + +// metaImage is the shape of an image entry served by meta.upsun.com/images. +type metaImage struct { + Name string `json:"name"` + Service bool `json:"service"` + Versions map[string]struct { + Upsun struct { + Status string `json:"status"` + } `json:"upsun"` + } `json:"versions"` +} + +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(imagesURL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %s from %s", resp.Status, imagesURL) + } + + var images map[string]metaImage + if err := json.NewDecoder(resp.Body).Decode(&images); err != nil { + return err + } + + reg := make(registry.Registry, len(images)) + for typeName, img := range images { + var supported, legacy []string + for version, info := range img.Versions { + switch info.Upsun.Status { + case "supported": + supported = append(supported, version) + case "deprecated": + // Deprecated versions still deploy, so treat them as legacy (allowed). + legacy = append(legacy, version) + } + // "retired" and "decommissioned" versions are omitted, so they fail linting. + } + sortVersionsDescending(supported) + sortVersionsDescending(legacy) + reg[typeName] = registry.Image{ + Name: img.Name, + Type: typeName, + IsRuntime: !img.Service, + Versions: registry.VersionInfo{Supported: supported, Legacy: legacy}, + } + } + + out, err := json.MarshalIndent(reg, "", " ") + if err != nil { + return err + } + return os.WriteFile("registry.json", append(out, '\n'), 0o644) +} + +// sortVersionsDescending sorts version strings newest-first (e.g. 8.3 before 8.1). +func sortVersionsDescending(versions []string) { + sort.Slice(versions, func(i, j int) bool { + return compareVersions(versions[i], versions[j]) > 0 + }) +} + +// compareVersions compares dotted numeric version strings, falling back to +// string comparison for non-numeric parts. +func compareVersions(a, b string) int { + pa, pb := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(pa) && i < len(pb); i++ { + na, ea := strconv.Atoi(pa[i]) + nb, eb := strconv.Atoi(pb[i]) + if ea == nil && eb == nil { + if na != nb { + return na - nb + } + continue + } + if c := strings.Compare(pa[i], pb[i]); c != 0 { + return c + } + } + return len(pa) - len(pb) +} diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go index 776b224da..50265a232 100644 --- a/internal/lint/registry/registry.go +++ b/internal/lint/registry/registry.go @@ -9,6 +9,9 @@ import ( // ChannelStable is the current stable NixOS channel, used for composable images. const ChannelStable = "25.11" +// registry.json is generated from https://meta.upsun.com/images by gen.go. +// +//go:generate go run gen.go //go:embed registry.json var Data []byte diff --git a/internal/lint/registry/registry.json b/internal/lint/registry/registry.json index 54766858d..f0b9e46ef 100644 --- a/internal/lint/registry/registry.json +++ b/internal/lint/registry/registry.json @@ -1,1358 +1,394 @@ { "chrome-headless": { - "description": "", - "disk": false, - "docs": { - "relationship_name": "chrome-headless", - "service_name": "chrome-headless", - "url": "/add-services/headless-chrome.html" - }, - "endpoint": "http", - "min_disk_size": null, "name": "Headless Chrome", - "repo_name": "chrome-headless", - "runtime": false, "type": "chrome-headless", + "runtime": false, "versions": { - "deprecated": [ - "73", - "80", - "81", - "83", - "84", - "86" - ], "supported": [ "120", - "113", - "95", - "91" - ], - "legacy": [ - + "113" ] - }, - "versions-dedicated-gen-3": { - "deprecated": [], + } + }, + "clickhouse": { + "name": "ClickHouse", + "type": "clickhouse", + "runtime": false, + "versions": { "supported": [ - "95" + "26.3", + "25.8" + ], + "legacy": [ + "25.3", + "24.3", + "23.8" ] } }, "dotnet": { - "description": "ASP.NET 5 application container.", - "repo_name": "dotnet", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/dotnet.html", - "web": { - "commands": { - "start": "dotnet application.dll" - }, - "locations": { - "/": { - "root": "wwwroot", - "allow": true, - "passthru": true - } - } - }, - "hooks": { - "build": [ - "|", - "set -e", - "dotnet publish --output \"$PLATFORM_OUTPUT_DIR\" -p:UseRazorBuildServer=false -p:UseSharedCompilation=false" - ] - } - }, - "endpoint": null, - "min_disk_size": null, "name": "C#/.Net Core", - "runtime": true, "type": "dotnet", + "runtime": true, "versions": { - "deprecated": [ - "5.0", - "3.1", - "2.2", - "2.1", - "2.0" - ], "supported": [ - "7.0", - "6.0", + "10.0", "8.0" - ], - "legacy": [ - "3.1", - "2.2", - "2.1", - "2.0" ] } }, "elasticsearch": { - "description": "A manufacture service for Elasticsearch", - "disk": true, - "docs": { - "relationship_name": "elasticsearch", - "service_name": "elasticsearch", - "url": "/add-services/elasticsearch.html" - }, - "endpoint": "elasticsearch", - "min_disk_size": 256, "name": "Elasticsearch", - "repo_name": "elasticsearch", - "runtime": false, "type": "elasticsearch", + "runtime": false, "versions": { - "deprecated": [ - "7.10", - "7.9", - "7.7", - "7.5", - "7.2", - "6.8", - "6.5", - "5.4", - "5.2", - "2.4", - "1.7", - "1.4" - ], "supported": [ - "8.5", - "7.17" + "7.10" ] - }, - "versions-dedicated-gen-2": { + } + }, + "elasticsearch-enterprise": { + "name": "elasticsearch-enterprise", + "type": "elasticsearch-enterprise", + "runtime": false, + "versions": { "supported": [ - "7.2", - "7.5", - "7.6", - "7.7", - "7.9", - "7.10", - "7.17", - "8.5", - "8.8" - ], - "deprecated": [ - "6.8", - "6.5", - "5.6", - "5.2", - "2.4", - "1.7" + "9.3", + "8.19" ] - }, - "versions-dedicated-gen-3": { - "deprecated": [ - "7.10", - "7.9", - "7.7", - "7.5", - "7.2", - "6.8", - "6.5" - ], - "supported": [] } }, "elixir": { - "description": "", - "repo_name": "elixir", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/elixir.html", - "web": { - "commands": { - "start": "mix run --no-halt" - }, - "locations": { - "/": { - "allow": false, - "root": "web", - "passthru": true - } - } - }, - "hooks": { - "build": [ - "|", - "mix local.hex --force", - "mix local.rebar --force", - "mix do deps.get --only prod, deps.compile, compile" - ] - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Elixir", - "runtime": true, "type": "elixir", + "runtime": true, "versions": { - "deprecated": [ - "1.13", - "1.12", - "1.11", - "1.10", - "1.9" - ], "supported": [ - "1.18", - "1.15", - "1.14" + "1.19", + "1.15" ], "legacy": [ - "1.10", - "1.9" + "1.18" ] } }, "golang": { - "description": "", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/go.html", - "web": { - "upstream": { - "socket_family": "tcp", - "protocol": "http" - }, - "commands": { - "start": "./bin/app" - }, - "locations": { - "/": { - "allow": false, - "passthru": true - } - } - }, - "hooks": { - "build": [ - "go build -o bin/app" - ] - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Go", - "repo_name": "golang", - "runtime": true, "type": "golang", + "runtime": true, + "versions": { + "supported": [ + "1.26", + "1.25" + ] + } + }, + "gotenberg": { + "name": "Gotenberg", + "type": "gotenberg", + "runtime": false, "versions": { - "deprecated": [ - "1.19", - "1.18", - "1.17", - "1.16", - "1.15", - "1.14", - "1.13", - "1.12", - "1.11", - "1.10", - "1.9", - "1.8" - ], "supported": [ - "1.25", - "1.24", - "1.23", - "1.22", - "1.21", - "1.20" + "8" ] } }, "influxdb": { - "description": "", - "disk": true, - "docs": { - "relationship_name": "influxdb", - "service_name": "influxdb", - "url": "/add-services/influxdb.html" - }, - "endpoint": "influxdb", - "min_disk_size": null, "name": "InfluxDB", - "repo_name": "influxdb", - "runtime": false, "type": "influxdb", + "runtime": false, "versions": { - "deprecated": [ - "2.2", - "1.8", - "1.7", - "1.3", - "1.2" - ], "supported": [ + "3", "2.7", "2.3" ] } }, "java": { - "description": "", - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/java.html", - "web": { - "commands": { - "start": "java -jar target/application.jar --server.port=$PORT" - } - }, - "hooks": { - "build": [ - "mvn clean install" - ] - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Java", - "repo_name": "java", - "runtime": true, "type": "java", + "runtime": true, "versions": { - "deprecated": [ - "14", - "13", - "12" - ], "supported": [ + "25", "21", - "19", - "18", - "17", - "11", - "8" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "17", - "11", - "8" - ], - "deprecated": [ - "15", - "13", - "7" + "17" ] } }, "kafka": { - "description": "", - "disk": true, - "docs": { - "relationship_name": "kafka", - "service_name": "kafka", - "url": "/add-services/kafka.html" - }, - "endpoint": "kafka", - "min_disk_size": 512, "name": "Kafka", - "repo_name": "kafka", - "runtime": false, "type": "kafka", + "runtime": false, "versions": { - "deprecated": [ - "2.7", - "2.6", - "2.5", - "2.4", - "2.3", - "2.2", - "2.1" - ], "supported": [ - "3.7", - "3.6", - "3.4", - "3.2" - ], - "legacy": [ - "2.6", - "2.5", - "2.4", - "2.3", - "2.2", - "2.1" + "4.1" ] } }, "mariadb": { - "description": "A manufacture-based container for MariaDB", - "repo_name": "mariadb", - "disk": true, - "docs": { - "relationship_name": "mariadb", - "service_name": "mariadb", - "url": "/add-services/mysql.html" - }, - "endpoint": "mysql", - "min_disk_size": 256, "name": "MariaDB/MySQL", - "runtime": false, "type": "mariadb", + "runtime": false, "versions": { - "deprecated": [ - "11.2", - "11.0", - "10.5", - "10.4", - "10.3", - "10.2", - "10.1", - "10.0", - "5.5" - ], "supported": [ "11.8", "11.4", "10.11", "10.6" ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "11.4 Galera", - "10.11 Galera", - "10.6 Galera", - "10.5 Galera", - "10.4 Galera" - ], - "deprecated": [ - "11.2 Galera", - "10.2 Galera", - "10.1 Galera", - "10.0 Galera", - "10.3 Galera" - ] - }, - "versions-dedicated-gen-3": { - "supported": [ - "10.11 Galera", - "10.6 Galera", - "10.5 Galera", - "10.4 Galera", - "10.3 Galera" - ], - "deprecated": [ - "10.2 Galera", - "10.1 Galera" - ] } }, - "mysql": { - "description": "A manufacture-based container for MariaDB", - "repo_name": "mariadb", - "disk": true, - "docs": { - "relationship_name": "mysql", - "service_name": "mysql", - "url": "/add-services/mysql.html" - }, - "endpoint": "mysql", - "min_disk_size": 256, - "name": "MariaDB/MySQL", + "mariadb-replica": { + "name": "mariadb-replica", + "type": "mariadb-replica", + "runtime": false + }, + "memcached": { + "name": "Memcached", + "type": "memcached", "runtime": false, - "type": "mysql", "versions": { - "deprecated": [ - "11.2", - "10.4", - "10.2", - "10.1", - "10.0", - "5.5" - ], "supported": [ - "11.0", - "10.11", - "10.6", - "10.5", - "10.4", - "10.3" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "11.4 Galera", - "10.11 Galera", - "10.6 Galera", - "10.5 Galera", - "10.5 Galera" - ], - "deprecated": [ - "11.2 Galera", - "10.4 Galera", - "10.3 Galera", - "10.2 Galera", - "10.1 Galera", - "10.0 Galera" - ] - }, - "versions-dedicated-gen-3": { - "supported": [ - "10.11 Galera", - "10.6 Galera", - "10.5 Galera", - "10.4 Galera", - "10.3 Galera" - ], - "deprecated": [ - "10.2 Galera", - "10.1 Galera" + "1.6" ] } }, - "memcached": { - "description": "Memcached service.", - "repo_name": "memcached", - "disk": false, - "docs": { - "relationship_name": "memcached", - "service_name": "memcached", - "url": "/add-services/memcached.html" - }, - "endpoint": "memcached", - "min_disk_size": null, - "name": "Memcached", + "mercure": { + "name": "mercure", + "type": "mercure", "runtime": false, - "type": "memcached", "versions": { - "deprecated": [], "supported": [ - "1.6", - "1.5", - "1.4" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "1.5", - "1.4" + "0" ] } }, "mongodb": { - "description": "Experimental MongoDB support on Upsun Fixed", - "repo_name": "mongodb", - "disk": true, - "docs": { - "relationship_name": "mongodb", - "service_name": "mongodb", - "url": "/add-services/mongodb.html" - }, - "endpoint": "mongodb", - "min_disk_size": 512, - "name": "MongoDB", - "runtime": false, + "name": "mongodb", "type": "mongodb", + "runtime": false, "versions": { - "deprecated": [ - "4.0.3", - "3.6", - "3.4", - "3.2", - "3.0" - ], - "supported": [] + "supported": [ + "4.0" + ] } }, "mongodb-enterprise": { - "description": "Support for the enterprise edition of MongoDB", - "repo_name": "mongodb", - "disk": true, - "docs": { - "relationship_name": "mongodb-enterprise", - "service_name": "mongodb-enterprise", - "url": "/add-services/mongodb.html" - }, - "endpoint": "mongodb", - "min_disk_size": 512, "name": "MongoDB", - "runtime": false, "type": "mongodb-enterprise", - "premium": true, + "runtime": false, "versions": { "supported": [ - "7.0", - "6.0", - "5.0", - "4.4" - ], - "deprecated": [ - "4.2", - "4.0" + "7.0" ] - }, - "versions-dedicated-gen-2": { + } + }, + "mysql": { + "name": "MariaDB/MySQL", + "type": "mysql", + "runtime": false, + "versions": { "supported": [ - "7.0", - "6.0", - "5.0", - "4.4" - ], - "deprecated": [ - "4.2", - "4.0" + "11.8", + "11.4", + "10.11", + "10.6" ] } }, "network-storage": { - "description": "", - "repo_name": "network-storage", - "disk": true, - "docs": { - "relationship_name": "null", - "service_name": "network-storage", - "url": "/add-services/network-storage.html" - }, - "endpoint": "something", - "min_disk_size": null, "name": "Network Storage", - "runtime": false, "type": "network-storage", + "runtime": false, "versions": { - "deprecated": [ - "2.0" - ], "supported": [ "1.0" - ] - }, - "versions-dedicated-gen-3": { - "deprecated": [], - "supported": [ + ], + "legacy": [ "2.0" ] } }, "nodejs": { - "description": "NodeJS service for Upsun Fixed", - "repo_name": "nodejs", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/nodejs.html", - "web": { - "commands": { - "start": "node index.js" - } - } - }, - "endpoint": null, - "min_disk_size": null, "name": "JavaScript/Node.js", - "runtime": true, "type": "nodejs", + "runtime": true, "versions": { - "deprecated": [ - "18", - "16", - "14", - "12", - "10", - "8", - "6", - "4.8", - "4.7", - "0.12" - ], - "supported": [ - "24", - "22", - "20" - ] - }, - "versions-dedicated-gen-2": { "supported": [ - "22", - "20", - "19" + "26", + "24" ], - "deprecated": [ - "18", - "16", - "14", - "12", - "10", - "9.8" + "legacy": [ + "22" ] } }, "opensearch": { - "description": "A manufacture service for OpenSearch", - "disk": true, - "docs": { - "relationship_name": "opensearch", - "service_name": "opensearch", - "url": "/add-services/opensearch.html" - }, - "endpoint": "opensearch", - "min_disk_size": 256, "name": "OpenSearch", - "repo_name": "opensearch", - "runtime": false, "type": "opensearch", + "runtime": false, "versions": { - "deprecated": [ - "1.3", - "1.2", - "1.1" - ], "supported": [ - "3", - "2" + "3" ], "legacy": [ - "1.1" - ] - }, - "versions-dedicated-gen-2": { - "deprecated": [], - "supported": [ - "1.2", - "1.99", - "2.12", - "2.14", - "2.5", - "2.18", - "2.19", - "2.99" - ] - }, - "versions-dedicated-gen-3": { - "deprecated": [], - "supported": [ "2" ] } }, "oracle-mysql": { - "description": "Images using MySQL from Oracle instead of MariaDB still providing mysql endpoints", - "repo_name": "oracle-mysql", - "disk": true, - "docs": { - "relationship_name": "oracle-mysql", - "service_name": "oracle-mysql", - "url": "/add-services/mysql.html" - }, - "endpoint": "mysql", - "min_disk_size": 256, "name": "Oracle MySQL", - "runtime": false, "type": "oracle-mysql", + "runtime": false, "versions": { - "deprecated": [], "supported": [ - "8.0", - "5.7" + "8.4" ] } }, "php": { - "description": "PHP service for Upsun Fixed.", - "repo_name": "php", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/php.html", - "web": { - "locations": { - "/": { - "root": "web", - "passthru": "/index.php" - } - } - }, - "hooks": { - "build": [ - "|", - "set -e" - ], - "deploy": [ - "|", - "set -e" - ] - }, - "build": { - "flavor": "composer" - } - }, - "endpoint": null, - "min_disk_size": null, "name": "PHP", - "runtime": true, "type": "php", - "versions-dedicated-gen-2": { - "supported": [ - "8.5", - "8.4", - "8.3", - "8.2", - "8.1", - "8.0" - ], - "deprecated": [ - "7.4", - "7.3", - "7.2", - "7.1", - "7.0" - ] - }, + "runtime": true, "versions": { - "deprecated": [ - "8.0", - "7.4", - "7.3", - "7.2", - "7.1", - "7.0", - "5.6", - "5.5", - "5.4" - ], "supported": [ "8.5", - "8.4", + "8.4" + ], + "legacy": [ "8.3", - "8.2", - "8.1" + "8.2" ] } }, "postgresql": { - "description": "PostgreSQL service for Upsun Fixed.", - "repo_name": "postgresql", - "disk": true, - "docs": { - "relationship_name": "postgresql", - "service_name": "postgresql", - "url": "/add-services/postgresql.html" - }, - "endpoint": "postgresql", - "min_disk_size": null, "name": "PostgreSQL", - "runtime": false, "type": "postgresql", + "runtime": false, "versions": { - "deprecated": [ - "11", - "10", - "9.6", - "9.5", - "9.4", - "9.3" - ], "supported": [ "18", "17", "16", "15", - "14", - "13", - "12" - ] - }, - "versions-dedicated-gen-2": { - "deprecated": [ - "11*", - "9.6*", - "9.5", - "9.4", - "9.3" - ], - "supported": [] - }, - "versions-dedicated-gen-3": { - "deprecated": [ - "11", - "10" - ], - "supported": [ - "17", - "16", - "15", - "14", - "13", - "12" + "14" ] } }, + "postgresql-replica": { + "name": "postgresql-replica", + "type": "postgresql-replica", + "runtime": false + }, "python": { - "description": "", - "repo_name": "python", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/python.html", - "web": { - "commands": { - "start": "python server.py" - } - }, - "hooks": { - "build": [ - "|", - "pipenv install --system --deploy" - ] - }, - "dependencies": { - "python3": { - "pipenv": "2018.10.13" - } - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Python", - "runtime": true, "type": "python", + "runtime": true, "versions": { - "deprecated": [ - "3.7", - "3.6", - "3.5", - "2.7*" - ], "supported": [ - "3.13", + "3.14", + "3.13" + ], + "legacy": [ "3.12", "3.11", - "3.10", - "3.9", - "3.8" + "3.10" ] } }, "rabbitmq": { - "description": "A manufacture-based container for RabbitMQ", - "repo_name": "rabbitmq", - "disk": true, - "docs": { - "relationship_name": "rabbitmq", - "service_name": "rabbitmq", - "url": "/add-services/rabbitmq.html" - }, - "endpoint": "rabbitmq", - "min_disk_size": 512, "name": "RabbitMQ", - "runtime": false, "type": "rabbitmq", + "runtime": false, "versions": { - "deprecated": [ - "3.11", - "3.10", - "3.9", - "3.8", - "3.7", - "3.6", - "3.5" - ], - "supported": [ - "4.1", - "4.0", - "3.13", - "3.12" - ], - "legacy": [ - "3.8", - "3.7", - "3.6", - "3.5" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "4.1", - "4.0", - "3.13", - "3.12" - ], - "deprecated": [ - "3.11", - "3.10", - "3.9", - "3.8", - "3.7" - ] - }, - "versions-dedicated-gen-3": { - "deprecated": [ - "3.11", - "3.10", - "3.9" - ], "supported": [ - "3.13", - "3.12" + "4.2" ] } }, "redis": { - "description": "A manufacture-based Redis container ", - "repo_name": "redis", - "disk": false, - "docs": { - "relationship_name": "redis", - "service_name": "redis", - "url": "/add-services/redis.html" - }, - "endpoint": "redis", - "min_disk_size": null, "name": "Redis", - "runtime": false, "type": "redis", + "runtime": false, "versions": { - "deprecated": [ - "7.0", - "6.2", - "6.0", - "5.0", - "4.0", - "3.2", - "3.0", - "2.8" - ], "supported": [ - "8.0", - "7.2" + "8.0" ], "legacy": [ - "6.0" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "8.0", - "7.4", - "7.2" - ], - "deprecated": [ - "7.0", - "6.2", - "6.0", - "5.0", - "3.2" - ] - }, - "versions-dedicated-gen-3": { - "deprecated": [ - "7.0", - "6.2", - "6.0", - "5.0", - "4.0", - "3.2", - "3.0", - "2.8" - ], - "supported": [ - "8.0", - "7.2" + "7.2", + "6.2" ] } }, "ruby": { - "description": "", - "repo_name": "ruby", - "disk": false, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/ruby.html", - "web": { - "upstream": { - "socket_family": "unix" - }, - "commands": { - "start": "unicorn -l $SOCKET -E production config.ru" - }, - "locations": { - "/": { - "root": "public", - "passthru": true, - "expires": "1h", - "allow": true - } - } - }, - "hooks": { - "build": [ - "|", - "bundle install --without development test" - ], - "deploy": [ - "|", - "RACK_ENV=production bundle exec rake db:migrate" - ] - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Ruby", - "runtime": true, "type": "ruby", + "runtime": true, "versions": { - "deprecated": [ - "2.7", - "2.6", - "2.5", - "2.4", - "2.3" - ], "supported": [ + "4.0", "3.4", - "3.3", - "3.2", - "3.1", - "3.0" - ], - "legacy": [ - "2.7", - "2.6", - "2.5", - "2.4", - "2.3" - ] - }, - "versions-dedicated-gen-3": { - "deprecated": [ - "2.7", - "2.6", - "2.5", - "2.4", - "2.3" - ], - "supported": [ - "3.3", - "3.2", - "3.1", - "3.0" - ], - "legacy": [ - "2.7", - "2.6", - "2.5", - "2.4", - "2.3" + "3.3" ] } }, "rust": { - "description": "", - "repo_name": "rust", - "disk": true, - "docs": { - "relationship_name": null, - "service_name": null, - "url": "/languages/rust.html", - "web": { - "commands": { - "start": "./target/debug/hello" - } - } - }, - "endpoint": null, - "min_disk_size": null, "name": "Rust", - "runtime": true, "type": "rust", + "runtime": true, "versions": { - "deprecated": [], "supported": [ "1" ] } }, "solr": { - "description": "", - "repo_name": "solr", - "disk": true, - "docs": { - "relationship_name": "solr", - "service_name": "solr", - "url": "/add-services/solr.html" - }, - "endpoint": "solr", - "min_disk_size": 256, "name": "Solr", - "runtime": false, "type": "solr", + "runtime": false, "versions": { - "deprecated": [ - "8.11", - "8.6", - "8.4", - "8.0", - "7.7", - "7.6", - "6.6", - "6.3", - "4.10", - "3.6" - ], - "supported": [ - "9.9", - "9.6", - "9.4", - "9.2", - "9.1" - ] - }, - "versions-dedicated-gen-2": { "supported": [ + "10.0", "9.9", - "9.7", - "9.6", - "9.4", - "9.2", - "9.1", - "9.0" - ], - "deprecated": [ - "8.11", - "8.6", - "8.0", - "7.7", - "6.6", - "6.3", - "4.10" - ] - }, - "versions-dedicated-gen-3": { - "supported": [ "9.6", "9.4", "9.2", "9.1" - - ], - "deprecated": [ - "8.11" ] } }, "valkey": { - "description": "", - "repo_name": "valkey", - "disk": false, - "docs": { - "relationship_name": "valkey", - "service_name": "valkey", - "url": "/add-services/valkey.html" - }, - "endpoint": "http+stats", - "min_disk_size": null, - "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", - "service_relationships": "application: 'app:http'", "name": "Valkey", - "runtime": false, "type": "valkey", + "runtime": false, "versions": { "supported": [ + "9.0", "8.1", "8.0" - ], - "deprecated": [] - }, - "versions-dedicated-gen-2": { - "supported": [ - "8.1", - "8.0" - ], - "deprecated": [] + ] } }, "varnish": { - "description": "", - "repo_name": "varnish", - "disk": false, - "docs": { - "relationship_name": "varnish", - "service_name": "varnish", - "url": "/add-services/varnish.html" - }, - "endpoint": "http+stats", - "min_disk_size": null, - "configuration": " configuration:\n vcl: !include\n type: string\n path: config.vcl", - "service_relationships": "application: 'app:http'", "name": "Varnish", - "runtime": false, "type": "varnish", + "runtime": false, "versions": { - "deprecated": [ - "5.1", - "5.2", - "6.3", - "6.4", - "7.1" - ], "supported": [ - "7.6", - "7.3", - "7.2", + "9.0", "6.0" ] } }, "vault-kms": { - "description": "", - "disk": true, - "docs": { - "relationship_name": "vault-kms", - "service_name": "vault-kms", - "url": "/add-services/vault.html" - }, - "endpoint": "manage_keys", - "min_disk_size": 512, - "configuration": " configuration:\n endpoints:\n :\n - policy: \n key: \n type: ", "name": "Vault KMS", - "repo_name": "vault-kms", - "runtime": false, "type": "vault-kms", + "runtime": false, "versions": { "supported": [ "1.12" - ], - "deprecated": [ - "1.8", - "1.6" - ], - "legacy": [ - "1.6" - ] - }, - "versions-dedicated-gen-2": { - "supported": [ - "1.6" - ] - }, - "versions-dedicated-gen-3": { - "supported": [ - "1.12" - ], - "deprecated": [ - "1.8", - "1.6" ] } } diff --git a/internal/lint/registry/registry_test.go b/internal/lint/registry/registry_test.go index d7cc6799f..a847ec4c7 100644 --- a/internal/lint/registry/registry_test.go +++ b/internal/lint/registry/registry_test.go @@ -14,6 +14,9 @@ func TestRegistry(t *testing.T) { assert.NoError(t, err) assert.NotEmpty(t, reg) assert.Contains(t, reg, "golang") - assert.Contains(t, reg["golang"].Versions.Supported, "1.24") + // Don't assert a specific version, which drifts as the registry is refreshed. + assert.NotEmpty(t, reg["golang"].Versions.Supported) + assert.True(t, reg["golang"].IsRuntime) + // redis-persistent is added by clean(). assert.Contains(t, reg, "redis-persistent") } diff --git a/internal/lint/schema/schema.go b/internal/lint/schema/schema.go index d6d52bdd1..464bcfaf9 100644 --- a/internal/lint/schema/schema.go +++ b/internal/lint/schema/schema.go @@ -8,6 +8,10 @@ import ( ) var ( + // upsun-config-schema.json is the self-contained Flex schema from platformify, + // refreshed by `make lint-assets`. The meta.upsun.com schema is not used here: + // it resolves type/version via remote $refs (fetched at runtime) and duplicates + // the registry-based type check. Type/version validation is done by CheckTypes. //go:embed upsun-config-schema.json configSchema []byte parsedSchema *gojsonschema.Schema diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index 11e536d78..3e8557eba 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -723,6 +723,7 @@ }, { "required": [ + "type", "stack" ] } From 068c5b9ed6c7751ab3be8d6f3c1aa48880db9d56 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:34:05 +0100 Subject: [PATCH 05/36] docs(lint): document path argument and output flags Update the app:config-validate help metadata to document the optional path argument and the --format and --stdin options, and note the native lint command in CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +++-- commands/list_models.go | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 877ee04f0..6bdba82bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ go test -v -run TestName ./path/to/package ### Hybrid CLI System The CLI operates as a wrapper around a legacy PHP CLI: -- Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure +- Go layer: Handles new commands (init, list, version, config:install, project:convert, lint) and core infrastructure - PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper` - The PHP CLI (platform.phar) is embedded at build time via go:embed - An index of legacy commands (commands.json, from `list --all --format=json`) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console @@ -67,7 +67,8 @@ 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 +- Native Go commands: init, list, version, config:install, project:convert, completion, lint +- `lint.go`: Native config linter (aliases `validate`, `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once - Unrecognized commands are passed to the legacy PHP CLI **Configuration**: `internal/config/` diff --git a/commands/list_models.go b/commands/list_models.go index 690b249ef..108a42122 100644 --- a/commands/list_models.go +++ b/commands/list_models.go @@ -112,23 +112,55 @@ func innerAppConfigValidateCommand(cnf *config.Config) Command { Command: "config-validate", }, Usage: []string{ - cnf.Application.Executable + " app:config-validate", + cnf.Application.Executable + " app:config-validate []", }, Aliases: []string{ "validate", "lint", }, - Description: "Validate the config files of a project", + Description: "Validate the configuration files of a project, reporting all errors at once", Help: "", Examples: []Example{ { Commandline: "", Description: "Validate the project configuration files in your current directory", }, + { + Commandline: "--format=json", + Description: "Validate the current directory and output the results as JSON", + }, }, Definition: Definition{ - Arguments: &orderedmap.OrderedMap[string, Argument]{}, + Arguments: orderedmap.New[string, Argument](orderedmap.WithInitialData[string, Argument]( + orderedmap.Pair[string, Argument]{ + Key: "path", + Value: Argument{ + Name: "path", + IsRequired: false, + IsArray: false, + Description: "The path to a project directory to validate (default: the current directory)", + }, + }, + )), Options: orderedmap.New[string, Option](orderedmap.WithInitialData[string, Option]( + orderedmap.Pair[string, Option]{ + Key: "--format", + Value: Option{ + Name: "--format", + AcceptValue: true, + IsValueRequired: true, + Description: "The output format: \"text\" or \"json\"", + Default: Any{"text"}, + }, + }, + orderedmap.Pair[string, Option]{ + Key: "--stdin", + Value: Option{ + Name: "--stdin", + Description: "Read merged Flex configuration from standard input", + Default: Any{false}, + }, + }, orderedmap.Pair[string, Option]{ Key: HelpOption.GetName(), Value: HelpOption, From acebaf6f7f7d7bd580c9e5254b39ec442e92394d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:42:00 +0100 Subject: [PATCH 06/36] fix(lint): don't read stdin in non-interactive contexts; review fixes Address findings from code review: - lint: only read piped stdin when it carries content; otherwise (a non-interactive shell or CI, where stdin is not a TTY but empty) fall back to linting the directory. Previously `lint` with no arguments errored with "empty content" in CI. Explicit --stdin still errors on empty input. - lint: make app:config-validate the primary command name (aliases lint, validate), matching the listing/help metadata and generated docs. - Fixed-style: reject a name set inside a map-form applications.yaml entry, matching the canonical parser and avoiding inconsistent app identity keying. - Add Result.Merge and use it instead of reallocating via Combine; drop the dead map[any]any branch in toStringMap; fix a misspelled identifier. Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/lint.go | 42 ++++++++++++++++++++++++++--------- internal/lint/fixed.go | 41 +++++++++++++--------------------- internal/lint/fixed_test.go | 9 ++++++++ internal/lint/result.go | 9 ++++++++ internal/lint/schema/fixed.go | 14 ++++++------ 5 files changed, 72 insertions(+), 43 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index 6ae72bb54..c2d9ee6ab 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -1,11 +1,13 @@ package commands import ( + "context" "encoding/json" "errors" "fmt" "io" "os" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" @@ -20,9 +22,9 @@ var errLintFailed = errors.New("") func newLintCommand(cnf *config.Config) *cobra.Command { cmd := &cobra.Command{ - Use: "lint [path]", + Use: "app:config-validate [path]", Short: "Validate project configuration", - Aliases: []string{"validate", "app:config-validate"}, + Aliases: []string{"lint", "validate"}, Args: cobra.MaximumNArgs(1), SilenceErrors: true, SilenceUsage: true, @@ -48,26 +50,29 @@ func runLint(cmd *cobra.Command, args []string) error { } func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) { - useStdin, _ := cmd.Flags().GetBool("stdin") + explicitStdin, _ := cmd.Flags().GetBool("stdin") format, _ := cmd.Flags().GetString("format") if format != "text" && format != "json" { return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) } - if !useStdin && len(args) == 0 { - if stat, err := os.Stdin.Stat(); err == nil && (stat.Mode()&os.ModeCharDevice) == 0 { - useStdin = true - } + ctx := cmd.Context() + if explicitStdin { + result, err := lintStdin(ctx, cmd) + return result, format, err } - ctx := cmd.Context() - if useStdin { + // With no path argument, lint piped stdin if it carries content; otherwise + // (e.g. a non-interactive shell or CI with no input) fall back to the directory. + if len(args) == 0 && stdinIsPiped() { content, err := io.ReadAll(cmd.InOrStdin()) if err != nil { return nil, format, err } - result, err := lint.CheckContent(ctx, string(content)) - return result, format, err + if strings.TrimSpace(string(content)) != "" { + result, err := lint.CheckContent(ctx, string(content)) + return result, format, err + } } path := "." @@ -78,6 +83,21 @@ func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) return result, format, err } +// lintStdin reads configuration from standard input and lints it. +func lintStdin(ctx context.Context, cmd *cobra.Command) (*lint.Result, error) { + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, err + } + return lint.CheckContent(ctx, string(content)) +} + +// stdinIsPiped reports whether standard input is a pipe or file rather than a terminal. +func stdinIsPiped() bool { + stat, err := os.Stdin.Stat() + return err == nil && (stat.Mode()&os.ModeCharDevice) == 0 +} + func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error { if format == "json" { out := struct { diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index 924c297fe..adeed218a 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -74,7 +74,8 @@ func lintFixed(_ context.Context, dir string) (*Result, error) { if err != nil { return nil, err } - return Combine(result, checks), nil + result.Merge(checks) + return result, nil } // loadFixedApplications collects applications from .platform.app.yaml files and @@ -109,7 +110,7 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { result.AddError(source, "this looks like Flex (.upsun) configuration in a Fixed-style file") continue } - *result = *Combine(result, CheckSchemaScoped(data, appSchema, source)) + result.Merge(CheckSchemaScoped(data, appSchema, source)) add(fixedAppName(data), source, data) } @@ -137,7 +138,7 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { continue } src := fmt.Sprintf(".platform/applications.yaml[%d]", i) - *result = *Combine(result, CheckSchemaScoped(data, appSchema, src)) + result.Merge(CheckSchemaScoped(data, appSchema, src)) add(fixedAppName(data), src, data) } case map[string]any: @@ -150,12 +151,14 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { result.AddError(".platform/applications.yaml: "+name, "application must be a map") continue } - // In map form the name comes from the key, not the value. - if _, ok := data["name"]; !ok { - data["name"] = name - } src := ".platform/applications.yaml: " + name - *result = *Combine(result, CheckSchemaScoped(data, appSchema, src)) + // In map form the name comes from the key and must not be set in the value. + if _, ok := data["name"]; ok { + result.AddError(src, "the application name must not be set here; it is taken from the key") + continue + } + data["name"] = name + result.Merge(CheckSchemaScoped(data, appSchema, src)) add(name, src, data) } case nil: @@ -195,7 +198,7 @@ func loadFixedSection( if err != nil { return nil, fmt.Errorf("failed to load %s schema: %w", file, err) } - *result = *Combine(result, CheckSchemaScoped(data, sch, ".platform/"+file)) + result.Merge(CheckSchemaScoped(data, sch, ".platform/"+file)) return data, nil } @@ -228,23 +231,11 @@ func hasAnyKey(m map[string]any, keys []string) bool { return false } +// toStringMap asserts that v is a YAML map. yaml.v3 always decodes mappings to +// map[string]any, so a plain type assertion suffices. func toStringMap(v any) (map[string]any, bool) { - switch m := v.(type) { - case map[string]any: - return m, true - case map[any]any: - out := make(map[string]any, len(m)) - for k, val := range m { - ks, ok := k.(string) - if !ok { - return nil, false - } - out[ks] = val - } - return out, true - default: - return nil, false - } + m, ok := v.(map[string]any) + return m, ok } // relTo returns abs relative to dir, falling back to abs on error. diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index f577ca64a..b9b9dd755 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -63,6 +63,15 @@ type: "php:999"`, }, wantErrors: []string{"at least 1 route must be defined when multiple applications are defined"}, }, + { + name: "applications.yaml map form must not set name in value", + files: map[string]string{ + ".platform/applications.yaml": `frontend: + name: frontend + type: "php:8.3"`, + }, + wantErrors: []string{"the application name must not be set here"}, + }, { name: "wrong style guard", files: map[string]string{ diff --git a/internal/lint/result.go b/internal/lint/result.go index 1f359dc15..6ee0f712c 100644 --- a/internal/lint/result.go +++ b/internal/lint/result.go @@ -98,6 +98,15 @@ func (r *Result) String() string { return r.formatResult(true) } +// Merge appends another result's errors and warnings into this one. +func (r *Result) Merge(other *Result) { + if other == nil { + return + } + r.Errors = append(r.Errors, other.Errors...) + r.Warnings = append(r.Warnings, other.Warnings...) +} + // Combine combines a list of validation results. func Combine(results ...*Result) *Result { result := &Result{} diff --git a/internal/lint/schema/fixed.go b/internal/lint/schema/fixed.go index 6e5c10ef8..7255458a7 100644 --- a/internal/lint/schema/fixed.go +++ b/internal/lint/schema/fixed.go @@ -16,16 +16,16 @@ var ( //go:embed platformsh.services.json servicesSchema []byte - fixedOnce sync.Once - parsedApplicaton *gojsonschema.Schema - parsedRoutes *gojsonschema.Schema - parsedServices *gojsonschema.Schema - fixedErr error + fixedOnce sync.Once + parsedApplication *gojsonschema.Schema + parsedRoutes *gojsonschema.Schema + parsedServices *gojsonschema.Schema + fixedErr error ) func loadFixed() error { fixedOnce.Do(func() { - if parsedApplicaton, fixedErr = gojsonschema.NewSchema( + if parsedApplication, fixedErr = gojsonschema.NewSchema( gojsonschema.NewBytesLoader(applicationSchema)); fixedErr != nil { return } @@ -43,7 +43,7 @@ func LoadApplication() (*gojsonschema.Schema, error) { if err := loadFixed(); err != nil { return nil, err } - return parsedApplicaton, nil + return parsedApplication, nil } // LoadRoutes loads the Fixed-style routes (.platform/routes.yaml) schema. From 446db76f51966d55fc2dffd1c867c23da718d7a8 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 10:19:35 +0100 Subject: [PATCH 07/36] fix(lint): sort available route targets for deterministic output The "available targets: ..." message in CheckRoutes was built by ranging over a map, so its order varied between runs. Sort it before joining. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/lint/routes.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/lint/routes.go b/internal/lint/routes.go index 55e0300ba..0aea07a60 100644 --- a/internal/lint/routes.go +++ b/internal/lint/routes.go @@ -2,6 +2,7 @@ package lint import ( "fmt" + "sort" "strings" ) @@ -57,6 +58,7 @@ func CheckRoutes(cfg *Config) *Result { for target := range validTargets { availableTargets = append(availableTargets, target) } + sort.Strings(availableTargets) if len(availableTargets) == 0 { result.AddError(fmt.Sprintf("routes[%q].upstream", routeURL), From bc3fbc6448b74d0131b796e07857ab97bed4d463 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 12:18:35 +0100 Subject: [PATCH 08/36] feat(lint): resolve project root and detect style from vendor config Make config-style detection match how a project actually deploys, and drive the directory names from the CLI's own config so vendor/white-label builds work. - Resolve the project root by walking up to the nearest enclosing .git (falling back to the given path), so lint can run from any subdirectory. The nearest .git is used, not the topmost, so a stray repository higher up the tree (e.g. a dotfiles repo, or /tmp) cannot hijack the result. - Detect Flex vs Fixed from the vendor's conventions (project_config_flavor, project_config_dir, app_config_file) plus the files present: Flex wins when present, else Fixed, else the native format. A first-party build (.upsun or .platform) also recognizes the other first-party format as a migration case; white-label builds use only their own names. - Anchor Fixed detection at the root (a config directory or a top-level app file). Nested per-app files are still collected once a project is confirmed, but a stray fixture no longer turns an unrelated repo into a project. - Warn about nested copies of any known config directory (.upsun, .platform, or the configured dir); the platform only reads them at the project root. - Make the duplicate application-name error name both source files. - Accept .yml as well as .yaml for Fixed routes/services/applications files. - Add app_config_file to the Go config schema (it was already in the YAML). Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/lint.go | 26 +++- internal/config/schema.go | 1 + internal/lint/fixed.go | 84 +++++------ internal/lint/fixed_test.go | 11 +- internal/lint/merge.go | 20 +-- internal/lint/merge_test.go | 10 +- internal/lint/normalize.go | 240 +++++++++++++++++++++++++++----- internal/lint/normalize_test.go | 145 +++++++++++++++---- 8 files changed, 412 insertions(+), 125 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index c2d9ee6ab..47108e9c7 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/fatih/color" @@ -28,7 +29,9 @@ func newLintCommand(cnf *config.Config) *cobra.Command { Args: cobra.MaximumNArgs(1), SilenceErrors: true, SilenceUsage: true, - RunE: runLint, + RunE: func(cmd *cobra.Command, args []string) error { + return runLint(cmd, args, vendorFromConfig(cnf)) + }, } cmd.Flags().Bool("stdin", false, "Read merged Flex configuration from standard input") cmd.Flags().String("format", "text", "Output format: text or json") @@ -39,8 +42,17 @@ func newLintCommand(cnf *config.Config) *cobra.Command { return cmd } -func runLint(cmd *cobra.Command, args []string) error { - result, format, err := lintInput(cmd, args) +// vendorFromConfig builds the linter's vendor conventions from the CLI config. +func vendorFromConfig(cnf *config.Config) lint.Vendor { + return lint.Vendor{ + Flavor: cnf.Service.ProjectConfigFlavor, + ConfigDir: cnf.Service.ProjectConfigDir, + AppFile: cnf.Service.AppConfigFile, + } +} + +func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error { + result, format, err := lintInput(cmd, args, vendor) if err != nil { // Print operational errors ourselves, since the command silences errors. fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(err.Error())) @@ -49,7 +61,7 @@ func runLint(cmd *cobra.Command, args []string) error { return printLintResult(cmd, result, format) } -func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) { +func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Result, string, error) { explicitStdin, _ := cmd.Flags().GetBool("stdin") format, _ := cmd.Flags().GetString("format") if format != "text" && format != "json" { @@ -79,7 +91,11 @@ func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) if len(args) == 1 { path = args[0] } - result, _, err := lint.CheckDir(ctx, path) + root := lint.FindProjectRoot(path) + if abs, err := filepath.Abs(path); err == nil && abs != root { + fmt.Fprintln(cmd.ErrOrStderr(), color.New(color.Faint).Sprintf("Linting project root: %s", root)) + } + result, _, err := lint.CheckDir(ctx, root, vendor) return result, format, err } diff --git a/internal/config/schema.go b/internal/config/schema.go index 4824b54f3..17938e994 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -70,6 +70,7 @@ type Config struct { Name string `validate:"required"` // e.g. "Upsun" EnvPrefix string `validate:"required" yaml:"env_prefix"` // e.g. "PLATFORM_" ProjectConfigDir string `validate:"required" yaml:"project_config_dir"` // e.g. ".platform" + AppConfigFile string `validate:"omitempty" yaml:"app_config_file,omitempty"` // e.g. ".platform.app.yaml" (Fixed only) ProjectConfigFlavor string `validate:"omitempty" yaml:"project_config_flavor,omitempty"` // default: "platform" ConsoleURL string `validate:"omitempty,url" yaml:"console_url,omitempty"` // e.g. "https://console.upsun.com" DocsURL string `validate:"omitempty,url" yaml:"docs_url,omitempty"` // e.g. "https://docs.upsun.com" diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index adeed218a..d864a4cad 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -13,33 +13,32 @@ import ( "github.com/upsun/cli/internal/lint/schema" ) -// Configuration section keys and the legacy per-app config file name. +// Configuration section keys. const ( keyApplications = "applications" keyServices = "services" keyRoutes = "routes" - fixedAppConfig = ".platform.app.yaml" ) // flexTopKeys are the top-level keys that indicate Flex-style configuration. var flexTopKeys = []string{keyApplications, keyServices, keyRoutes} -// lintFixed lints legacy Platform.sh (Fixed-style) configuration in dir: -// .platform.app.yaml files and/or .platform/applications.yaml, plus optional -// .platform/routes.yaml and .platform/services.yaml. -func lintFixed(_ context.Context, dir string) (*Result, error) { +// lintFixed lints Fixed-style configuration in dir using the resolved names in +// cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus +// optional cfg.dir/routes.yaml and cfg.dir/services.yaml. +func lintFixed(_ context.Context, dir string, cfg fixedNames) (*Result, error) { result := &Result{} - apps, err := loadFixedApplications(dir, result) + apps, err := loadFixedApplications(dir, cfg, result) if err != nil { return nil, err } - services, err := loadFixedSection(dir, "services.yaml", schema.LoadServices, result) + services, err := loadFixedSection(dir, cfg.dir, keyServices, schema.LoadServices, result) if err != nil { return nil, err } - routes, err := loadFixedSection(dir, "routes.yaml", schema.LoadRoutes, result) + routes, err := loadFixedSection(dir, cfg.dir, keyRoutes, schema.LoadRoutes, result) if err != nil { return nil, err } @@ -65,12 +64,12 @@ func lintFixed(_ context.Context, dir string) (*Result, error) { if err != nil { return nil, err } - cfg, err := DecodeConfig(string(mergedYAML)) + decoded, err := DecodeConfig(string(mergedYAML)) if err != nil { return nil, err } - checks, err := runChecks(cfg, StyleFixed) + checks, err := runChecks(decoded, StyleFixed) if err != nil { return nil, err } @@ -78,25 +77,28 @@ func lintFixed(_ context.Context, dir string) (*Result, error) { return result, nil } -// loadFixedApplications collects applications from .platform.app.yaml files and -// .platform/applications.yaml, validating each against the application schema. -func loadFixedApplications(dir string, result *Result) (map[string]any, error) { +// loadFixedApplications collects applications from per-app config files and +// cfg.dir/applications.yaml, validating each against the application schema. +func loadFixedApplications(dir string, cfg fixedNames, result *Result) (map[string]any, error) { appSchema, err := schema.LoadApplication() if err != nil { return nil, fmt.Errorf("failed to load application schema: %w", err) } apps := map[string]any{} + sources := map[string]string{} add := func(name, source string, data map[string]any) { if _, dup := apps[name]; dup { - result.AddError(source, fmt.Sprintf("duplicate application name %q", name)) + result.AddError(source, fmt.Sprintf( + "duplicate application name %q (already defined in %s)", name, sources[name])) return } apps[name] = data + sources[name] = source } - // Individual .platform.app.yaml files. - for _, abs := range findFixedAppFiles(dir) { + // Individual per-app config files (e.g. .platform.app.yaml). + for _, abs := range findFixedAppFiles(dir, cfg.app) { source := relTo(dir, abs) data, err := readYAMLMap(abs) if err != nil { @@ -107,26 +109,27 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { continue } if hasAnyKey(data, flexTopKeys) { - result.AddError(source, "this looks like Flex (.upsun) configuration in a Fixed-style file") + result.AddError(source, "this looks like Flex configuration in a Fixed-style file") continue } result.Merge(CheckSchemaScoped(data, appSchema, source)) add(fixedAppName(data), source, data) } - // .platform/applications.yaml (a list of apps, or a map keyed by app name). - appsFile := filepath.Join(dir, ".platform", "applications.yaml") + // cfg.dir/applications.yaml (a list of apps, or a map keyed by app name). + appsFile, ok := firstExistingYAML(dir, cfg.dir, keyApplications) + if !ok { + return apps, nil + } + label := relTo(dir, appsFile) raw, err := os.ReadFile(appsFile) if err != nil { - if os.IsNotExist(err) { - return apps, nil - } - result.AddError(".platform/applications.yaml", err.Error()) + result.AddError(label, err.Error()) return apps, nil } var doc any if err := yaml.Unmarshal(raw, &doc); err != nil { - result.AddError(".platform/applications.yaml", interpretYAMLError(err)) + result.AddError(label, interpretYAMLError(err)) return apps, nil } switch v := doc.(type) { @@ -134,10 +137,10 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { for i, item := range v { data, ok := toStringMap(item) if !ok { - result.AddError(fmt.Sprintf(".platform/applications.yaml[%d]", i), "application must be a map") + result.AddError(fmt.Sprintf("%s[%d]", label, i), "application must be a map") continue } - src := fmt.Sprintf(".platform/applications.yaml[%d]", i) + src := fmt.Sprintf("%s[%d]", label, i) result.Merge(CheckSchemaScoped(data, appSchema, src)) add(fixedAppName(data), src, data) } @@ -148,10 +151,10 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { } data, ok := toStringMap(item) if !ok { - result.AddError(".platform/applications.yaml: "+name, "application must be a map") + result.AddError(label+": "+name, "application must be a map") continue } - src := ".platform/applications.yaml: " + name + src := label + ": " + name // In map form the name comes from the key and must not be set in the value. if _, ok := data["name"]; ok { result.AddError(src, "the application name must not be set here; it is taken from the key") @@ -164,31 +167,32 @@ func loadFixedApplications(dir string, result *Result) (map[string]any, error) { case nil: // Empty file. default: - result.AddError(".platform/applications.yaml", "contents must be a YAML list or map") + result.AddError(label, "contents must be a YAML list or map") } return apps, nil } -// loadFixedSection reads and schema-validates an optional .platform/, +// loadFixedSection reads and schema-validates an optional cfg.dir/.{yaml,yml}, // returning its decoded map (keyed by name/URL). func loadFixedSection( - dir, file string, + dir, configDir, base string, loadSchema func() (*gojsonschema.Schema, error), result *Result, ) (map[string]any, error) { - path := filepath.Join(dir, ".platform", file) + path, ok := firstExistingYAML(dir, configDir, base) + if !ok { + return nil, nil + } + label := relTo(dir, path) raw, err := os.ReadFile(path) if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - result.AddError(".platform/"+file, err.Error()) + result.AddError(label, err.Error()) return nil, nil } data := map[string]any{} if err := yaml.Unmarshal(raw, &data); err != nil { - result.AddError(".platform/"+file, interpretYAMLError(err)) + result.AddError(label, interpretYAMLError(err)) return nil, nil } if len(data) == 0 { @@ -196,9 +200,9 @@ func loadFixedSection( } sch, err := loadSchema() if err != nil { - return nil, fmt.Errorf("failed to load %s schema: %w", file, err) + return nil, fmt.Errorf("failed to load %s schema: %w", base, err) } - result.Merge(CheckSchemaScoped(data, sch, ".platform/"+file)) + result.Merge(CheckSchemaScoped(data, sch, label)) return data, nil } diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index b9b9dd755..b193dcc2e 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -79,7 +79,7 @@ type: "php:999"`, foo: type: "php:8.3"`, }, - wantErrors: []string{"looks like Flex (.upsun) configuration in a Fixed-style file"}, + wantErrors: []string{"looks like Flex configuration in a Fixed-style file"}, }, { name: "app file missing required name", @@ -97,7 +97,7 @@ type: "php:999"`, writeFile(t, filepath.Join(dir, name), content) } - result, style, err := CheckDir(context.Background(), dir) + result, style, err := CheckDir(context.Background(), dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFixed, style) @@ -115,9 +115,14 @@ type: "php:999"`, func TestLintFixed_DuplicateAppName(t *testing.T) { dir := t.TempDir() + // A multi-app layout: shared config at the root, one app per subdirectory. + writeFile(t, filepath.Join(dir, ".platform", "routes.yaml"), "{}") writeFile(t, filepath.Join(dir, "a", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") writeFile(t, filepath.Join(dir, "b", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") - result, _, err := CheckDir(context.Background(), dir) + result, _, err := CheckDir(context.Background(), dir, upsunVendor()) require.NoError(t, err) + // The error should name both source files so it is actionable. assert.Contains(t, result.String(), `duplicate application name "same"`) + assert.Contains(t, result.String(), filepath.Join("a", ".platform.app.yaml")) + assert.Contains(t, result.String(), filepath.Join("b", ".platform.app.yaml")) } diff --git a/internal/lint/merge.go b/internal/lint/merge.go index 1fe3fd809..6b36a5eae 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -8,19 +8,19 @@ import ( "gopkg.in/yaml.v3" ) -// findUpsunConfigFiles returns all .upsun/*.yaml and .upsun/*.yml files in the given directory. -func findUpsunConfigFiles(fsys fs.FS, path string) ([]string, error) { - // Find both .yaml and .yml files +// findFlexConfigFiles returns all *.yaml and *.yml files in the Flex config +// directory (e.g. .upsun) of the given path. Flex files may have any name. +func findFlexConfigFiles(fsys fs.FS, path, configDir string) ([]string, error) { patterns := []string{ - filepath.Join(path, ".upsun", "*.yaml"), - filepath.Join(path, ".upsun", "*.yml"), + filepath.Join(path, configDir, "*.yaml"), + filepath.Join(path, configDir, "*.yml"), } var allMatches []string for _, pattern := range patterns { matches, err := fs.Glob(fsys, pattern) if err != nil { - return nil, fmt.Errorf("could not glob .upsun directory: %w", err) + return nil, fmt.Errorf("could not glob %s directory: %w", configDir, err) } allMatches = append(allMatches, matches...) } @@ -76,10 +76,10 @@ func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { return string(buf), nil } -// getMergedConfigFiles merges all .upsun/*.yaml files in the given directory. -// It is a convenience wrapper for findUpsunConfigFiles + mergeConfigFiles. -func getMergedConfigFiles(fsys fs.FS, path string) (string, error) { - files, err := findUpsunConfigFiles(fsys, path) +// getMergedConfigFiles merges all Flex config files in the given directory. +// It is a convenience wrapper for findFlexConfigFiles + mergeConfigFiles. +func getMergedConfigFiles(fsys fs.FS, path, configDir string) (string, error) { + files, err := findFlexConfigFiles(fsys, path, configDir) if err != nil { return "", err } diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go index ed0e8604d..7ea283ad7 100644 --- a/internal/lint/merge_test.go +++ b/internal/lint/merge_test.go @@ -7,14 +7,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestFindUpsunConfigFiles(t *testing.T) { +func TestFindFlexConfigFiles(t *testing.T) { fsys := fstest.MapFS{ ".upsun/a.yaml": &fstest.MapFile{Data: []byte("{}")}, ".upsun/b.yaml": &fstest.MapFile{Data: []byte("{}")}, ".upsun/c.yml": &fstest.MapFile{Data: []byte("{}")}, ".upsun/notyaml.txt": &fstest.MapFile{Data: []byte("not yaml")}, } - files, err := findUpsunConfigFiles(fsys, ".") + files, err := findFlexConfigFiles(fsys, ".", ".upsun") require.NoError(t, err) require.ElementsMatch(t, []string{".upsun/a.yaml", ".upsun/b.yaml", ".upsun/c.yml"}, files) } @@ -54,7 +54,7 @@ func TestGetMergedConfigFiles_Success(t *testing.T) { ".upsun/b.yml": &fstest.MapFile{Data: []byte(`services: db: {type: mariadb}`)}, } - merged, err := getMergedConfigFiles(fsys, ".") + merged, err := getMergedConfigFiles(fsys, ".", ".upsun") require.NoError(t, err) require.Contains(t, merged, "foo") require.Contains(t, merged, "db") @@ -62,7 +62,7 @@ func TestGetMergedConfigFiles_Success(t *testing.T) { func TestGetMergedConfigFiles_NoUpsunDir(t *testing.T) { fsys := fstest.MapFS{} - _, err := getMergedConfigFiles(fsys, ".") + _, err := getMergedConfigFiles(fsys, ".", ".upsun") require.Error(t, err) require.Contains(t, err.Error(), ".upsun") } @@ -71,7 +71,7 @@ func TestGetMergedConfigFiles_NoYamlFiles(t *testing.T) { fsys := fstest.MapFS{ ".upsun/notyaml.txt": &fstest.MapFile{Data: []byte("not yaml")}, } - _, err := getMergedConfigFiles(fsys, ".") + _, err := getMergedConfigFiles(fsys, ".", ".upsun") require.Error(t, err) require.Contains(t, err.Error(), "no configuration files found") } diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 3ed2739c1..4e35ff7f2 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) // Style identifies which configuration style a project uses. @@ -30,15 +31,69 @@ func (s Style) String() string { } } +// First-party config conventions. The native format and directory of any CLI +// come from its config (see Vendor); these constants are the cross-brand +// counterpart that a first-party build also recognizes. +const ( + flavorUpsun = "upsun" // project_config_flavor for the Flex format + firstPartyFlexDir = ".upsun" // Upsun (Flex) project config directory + firstPartyFixedDir = ".platform" + firstPartyFixedApp = ".platform.app.yaml" +) + +// Vendor describes the running CLI's configuration conventions, taken from the +// service section of its config (project_config_flavor, project_config_dir, +// app_config_file). It determines the native format and directory names, and +// whether a first-party cross-brand counterpart is also recognized. +type Vendor struct { + Flavor string // "upsun" (Flex) or "platform" (Fixed) + ConfigDir string // e.g. ".upsun", ".platform", ".acme" + AppFile string // Fixed per-app config file, e.g. ".platform.app.yaml" +} + +// fixedNames is the pair of names that identify a Fixed-style layout. +type fixedNames struct { + dir string // config directory, e.g. ".platform" + app string // per-app config file, e.g. ".platform.app.yaml" +} + +// candidates returns the Flex directories and Fixed name-sets to look for. The +// native format comes from the vendor config; a first-party build (config dir +// is .upsun or .platform) also recognizes the other first-party format. +func (v Vendor) candidates() (flexDirs []string, fixed []fixedNames) { + cfgDir := v.ConfigDir + if cfgDir == "" { + cfgDir = firstPartyFlexDir + } + if v.Flavor == flavorUpsun { + flexDirs = []string{cfgDir} + if cfgDir == firstPartyFlexDir { // First-party Upsun: Platform.sh counterpart. + fixed = []fixedNames{{firstPartyFixedDir, firstPartyFixedApp}} + } + return flexDirs, fixed + } + app := v.AppFile + if app == "" { + app = firstPartyFixedApp + } + fixed = []fixedNames{{cfgDir, app}} + if cfgDir == firstPartyFixedDir { // First-party Platform.sh: Upsun counterpart. + flexDirs = []string{firstPartyFlexDir} + } + return flexDirs, fixed +} + // CheckDir detects the configuration style in dir and lints it, returning the -// detected style. Detection is based purely on the directory layout. -func CheckDir(ctx context.Context, dir string) (*Result, Style, error) { - flex := hasFlexConfig(dir) - fixed := hasFixedConfig(dir) +// detected style. Detection uses the vendor's name conventions and the files +// present: Flex wins when present, else Fixed, else the native format. +func CheckDir(ctx context.Context, dir string, vendor Vendor) (*Result, Style, error) { + flexDirs, fixedSet := vendor.candidates() + flexDir, flexOK := detectFlex(dir, flexDirs) + fixedCfg, fixedOK := detectFixed(dir, fixedSet) switch { - case flex: - content, err := loadFlex(dir) + case flexOK: + content, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) if err != nil { return nil, StyleFlex, err } @@ -46,59 +101,168 @@ func CheckDir(ctx context.Context, dir string) (*Result, Style, error) { if err != nil { return nil, StyleFlex, err } - if fixed { - result.AddWarning("", "both .upsun and .platform configuration found; "+ - "linting the .upsun (Flex) configuration") + addStrayWarnings(result, dir, flexDirs, fixedSet) + if fixedOK { + result.AddWarning("", fmt.Sprintf( + "both %s and %s configuration found; linting the %s (Flex) configuration", + flexDir, fixedCfg.dir, flexDir)) } return result, StyleFlex, nil - case fixed: - result, err := lintFixed(ctx, dir) - return result, StyleFixed, err + case fixedOK: + result, err := lintFixed(ctx, dir, fixedCfg) + if err != nil { + return nil, StyleFixed, err + } + addStrayWarnings(result, dir, flexDirs, fixedSet) + return result, StyleFixed, nil default: return nil, StyleUnknown, fmt.Errorf( - "no Upsun configuration found in %q (looked for .upsun/*.yaml and .platform[.app].yaml)", dir) + "no configuration found in %q (looked for %s)", dir, lookedFor(flexDirs, fixedSet)) } } -// loadFlex merges the .upsun/*.yaml files in dir into a single YAML document. -func loadFlex(dir string) (string, error) { - return getMergedConfigFiles(os.DirFS(dir), ".") +// detectFlex returns the first candidate Flex directory that contains YAML files. +func detectFlex(root string, dirs []string) (string, bool) { + for _, d := range dirs { + if files, err := findFlexConfigFiles(os.DirFS(root), ".", d); err == nil && len(files) > 0 { + return d, true + } + } + return "", false } -// hasFlexConfig reports whether dir contains .upsun/*.yaml configuration files. -func hasFlexConfig(dir string) bool { - files, err := findUpsunConfigFiles(os.DirFS(dir), ".") - return err == nil && len(files) > 0 +// detectFixed returns the first candidate Fixed layout anchored at the root: its +// config directory exists, or a per-app config file sits at the top level. The +// anchor is required so a stray nested config file (e.g. a test fixture) does not +// turn an unrelated repository into a project; nested per-app files are still +// collected once a project is confirmed (see loadFixedApplications). +func detectFixed(root string, set []fixedNames) (fixedNames, bool) { + for _, f := range set { + if fi, err := os.Stat(filepath.Join(root, f.dir)); err == nil && fi.IsDir() { + return f, true + } + if _, ok := firstExistingYAML(root, "", f.app); ok { + return f, true + } + } + return fixedNames{}, false } -// hasFixedConfig reports whether dir contains legacy Platform.sh configuration. -func hasFixedConfig(dir string) bool { - if fi, err := os.Stat(filepath.Join(dir, ".platform")); err == nil && fi.IsDir() { - return true +// lookedFor builds the hint listing the locations CheckDir searched. +func lookedFor(flexDirs []string, fixed []fixedNames) string { + parts := make([]string, 0, len(flexDirs)+2*len(fixed)) + for _, d := range flexDirs { + parts = append(parts, d+"/*.yaml") } - return len(findFixedAppFiles(dir)) > 0 + for _, f := range fixed { + parts = append(parts, f.app, f.dir+"/") + } + return strings.Join(parts, ", ") } -// findFixedAppFiles returns the paths of all .platform.app.yaml files under dir. -func findFixedAppFiles(dir string) []string { - var found []string - _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { +// FindProjectRoot resolves the project root for a path: the nearest ancestor +// directory that contains a .git entry, or the path itself if none is found. +// This lets the command be run from anywhere inside a repository. The nearest +// (not topmost) .git is used so a stray repository higher up the tree (e.g. a +// dotfiles repo in the home directory) cannot hijack the result. +func FindProjectRoot(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + for dir := abs; ; { + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return abs + } + dir = parent + } +} + +// addStrayWarnings warns about nested copies of any config directory the vendor +// knows about. The platform only reads a config directory at the project root, +// so any below it are ignored. +func addStrayWarnings(result *Result, root string, flexDirs []string, fixed []fixedNames) { + configDirs := map[string]bool{} + for _, d := range flexDirs { + configDirs[d] = true + } + for _, f := range fixed { + configDirs[f.dir] = true + } + walkProject(root, func(path string, d fs.DirEntry) { + // A known config directory below the root (not a root-level copy, which + // detection already handles) is ignored by the platform. + if d.IsDir() && configDirs[d.Name()] && filepath.Dir(path) != root { + result.AddWarning(relTo(root, path), + fmt.Sprintf("this %s directory is not at the project root and will be ignored", d.Name())) + } + }) +} + +// maxFixedAppDepth bounds how deep the walk descends looking for per-app config +// files. It mirrors the legacy CLI's safeguard against slow, over-broad searches. +const maxFixedAppDepth = 5 + +// walkProject walks root, pruning VCS/dependency directories and limiting depth, +// invoking visit for each remaining entry (files and directories). +func walkProject(root string, visit func(path string, d fs.DirEntry)) { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } - if d.IsDir() { + if d.IsDir() && path != root { switch d.Name() { - case ".git", "node_modules", "vendor": - if path != dir { - return fs.SkipDir - } + case ".git", ".idea", "node_modules", "vendor", "builds": + return fs.SkipDir + } + if rel, err := filepath.Rel(root, path); err == nil && + len(strings.Split(filepath.ToSlash(rel), "/")) >= maxFixedAppDepth { + return fs.SkipDir } - return nil - } - if d.Name() == fixedAppConfig { - found = append(found, path) } + visit(path, d) return nil }) +} + +// findFixedAppFiles returns the paths of per-app config files (appFile, or its +// .yml variant) under dir, descending into subdirectories up to maxFixedAppDepth. +func findFixedAppFiles(dir, appFile string) []string { + names := yamlVariants(appFile) + var found []string + walkProject(dir, func(path string, d fs.DirEntry) { + if d.IsDir() { + return + } + for _, name := range names { + if d.Name() == name { + found = append(found, path) + break + } + } + }) return found } + +// firstExistingYAML returns the first existing path among the .yaml/.yml variants +// of base within dir under root, and whether one was found. +func firstExistingYAML(root, dir, base string) (string, bool) { + for _, name := range yamlVariants(base) { + p := filepath.Join(root, dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +// yamlVariants returns base with both .yaml and .yml extensions, stripping any +// existing .yaml/.yml suffix first. +func yamlVariants(base string) []string { + base = strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml") + return []string{base + ".yaml", base + ".yml"} +} diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index 3c72d516a..2ea5cc907 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -16,34 +16,131 @@ func writeFile(t *testing.T, path, content string) { require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) } +// upsunVendor is the first-party Upsun configuration used by most tests. +func upsunVendor() Vendor { + return Vendor{Flavor: "upsun", ConfigDir: ".upsun"} +} + func TestDetectStyle(t *testing.T) { - t.Run("flex", func(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), "applications: {}") - assert.True(t, hasFlexConfig(dir)) - assert.False(t, hasFixedConfig(dir)) - }) + cases := []struct { + name string + files map[string]string + want Style + wantErr bool + }{ + {"flex", map[string]string{".upsun/config.yaml": "applications: {}"}, StyleFlex, false}, + {"fixed via app file", map[string]string{".platform.app.yaml": "name: app\ntype: \"php:8.3\""}, StyleFixed, false}, + {"fixed via .platform dir", map[string]string{".platform/routes.yaml": "{}"}, StyleFixed, false}, + {"none", nil, StyleUnknown, true}, + // A per-app file buried in a subdirectory (e.g. a test fixture) must not, + // on its own, mark the directory as a Fixed-style project. + {"nested app file alone is not a project", + map[string]string{"fixtures/example/.platform.app.yaml": "name: app"}, StyleUnknown, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for p, c := range tc.files { + writeFile(t, filepath.Join(dir, p), c) + } + _, style, err := CheckDir(context.Background(), dir, upsunVendor()) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.want, style) + }) + } +} - t.Run("fixed via app file", func(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") - assert.False(t, hasFlexConfig(dir)) - assert.True(t, hasFixedConfig(dir)) - }) +func TestCheckDir_Vendors(t *testing.T) { + const app = "name: app\ntype: \"php:8.3\"\n" + platformVendor := Vendor{Flavor: "platform", ConfigDir: ".platform", AppFile: ".platform.app.yaml"} + acmeFixed := Vendor{Flavor: "platform", ConfigDir: ".acme", AppFile: "acme.app.yaml"} + acmeFlex := Vendor{Flavor: "upsun", ConfigDir: ".acme"} + + cases := []struct { + name string + vendor Vendor + files map[string]string + want Style + wantErr bool + }{ + // Scenario B: Platform.sh CLI recognizes the Upsun Flex counterpart. + {"platform: .upsun present wins", platformVendor, + map[string]string{".upsun/config.yaml": "applications: {}", ".platform.app.yaml": app}, StyleFlex, false}, + {"platform: no .upsun is Fixed", platformVendor, + map[string]string{".platform.app.yaml": app}, StyleFixed, false}, + // Scenario C: white-label Fixed uses its own names, no counterpart. + {"white-label fixed via app file", acmeFixed, + map[string]string{"acme.app.yaml": app}, StyleFixed, false}, + {"white-label fixed ignores .upsun counterpart", acmeFixed, + map[string]string{".upsun/config.yaml": "applications: {}"}, StyleUnknown, true}, + // Scenario D: white-label Flex uses its own dir, no counterpart. + {"white-label flex via its dir", acmeFlex, + map[string]string{".acme/config.yaml": "applications: {}"}, StyleFlex, false}, + {"white-label flex ignores .platform counterpart", acmeFlex, + map[string]string{".platform.app.yaml": app}, StyleUnknown, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for p, c := range tc.files { + writeFile(t, filepath.Join(dir, p), c) + } + _, style, err := CheckDir(context.Background(), dir, tc.vendor) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.want, style) + }) + } +} - t.Run("fixed via .platform dir", func(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, ".platform", "routes.yaml"), "{}") - assert.True(t, hasFixedConfig(dir)) +func TestFindProjectRoot(t *testing.T) { + t.Run("ascends to the enclosing git root", func(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".git", "HEAD"), "ref: refs/heads/main") + sub := filepath.Join(root, "services", "api") + require.NoError(t, os.MkdirAll(sub, 0o755)) + assert.Equal(t, root, FindProjectRoot(sub)) }) - t.Run("none", func(t *testing.T) { - dir := t.TempDir() - assert.False(t, hasFlexConfig(dir)) - assert.False(t, hasFixedConfig(dir)) + t.Run("nearest git root wins over an outer one", func(t *testing.T) { + outer := t.TempDir() + writeFile(t, filepath.Join(outer, ".git", "HEAD"), "ref: refs/heads/main") + inner := filepath.Join(outer, "vendor", "pkg") + writeFile(t, filepath.Join(inner, ".git", "HEAD"), "ref: refs/heads/main") + assert.Equal(t, inner, FindProjectRoot(inner)) }) } +func TestLintFixed_StrayPlatformDir(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app\ntype: \"php:8.3\"") + writeFile(t, filepath.Join(dir, "legacy", ".platform", "routes.yaml"), "{}") + result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, StyleFixed, style) + assert.Contains(t, result.String(), "legacy/.platform") + assert.Contains(t, result.String(), "not at the project root and will be ignored") +} + +func TestLintFixed_StrayUpsunDir(t *testing.T) { + // A stray .upsun (the counterpart config dir) inside a Fixed project is also + // ignored by the platform and should be warned about. + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app\ntype: \"php:8.3\"") + writeFile(t, filepath.Join(dir, "sub", ".upsun", "config.yaml"), "applications: {}") + result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, StyleFixed, style) + assert.Contains(t, result.String(), "sub/.upsun") +} + func TestLintDir_Flex(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `applications: @@ -59,7 +156,7 @@ routes: type: upstream upstream: "app:http" `) - result, style, err := CheckDir(context.Background(), dir) + result, style, err := CheckDir(context.Background(), dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.False(t, result.HasErrors(), "expected no errors, got: %s", result) @@ -67,10 +164,10 @@ routes: func TestLintDir_NoConfig(t *testing.T) { dir := t.TempDir() - _, style, err := CheckDir(context.Background(), dir) + _, style, err := CheckDir(context.Background(), dir, upsunVendor()) require.Error(t, err) assert.Equal(t, StyleUnknown, style) - assert.Contains(t, err.Error(), "no Upsun configuration found") + assert.Contains(t, err.Error(), "no configuration found") } func TestLintDir_BothPresentPrefersFlex(t *testing.T) { @@ -80,7 +177,7 @@ func TestLintDir_BothPresentPrefersFlex(t *testing.T) { type: "php:8.3" `) writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") - result, style, err := CheckDir(context.Background(), dir) + result, style, err := CheckDir(context.Background(), dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.True(t, result.HasWarnings()) From 1603cf5ec0aff76e4db202179770c44a8f46c636 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 12:38:49 +0100 Subject: [PATCH 09/36] feat(lint): clearer command output - Print the validated directory on its own line every run ("Validating configuration in directory: ", path in cyan). - Colour only the "Linter errors:" / "Linter warnings:" headings (bold red / bold yellow); the issue lines use the default colour so they stay readable. - Keep the green check mark but leave "The configuration is valid." in the default colour. - Capitalize the first letter of operational errors for display (the Go error strings stay lowercase per convention), so "no configuration found" reads as "No configuration found". Co-Authored-By: Claude Opus 4.8 (1M context) --- commands/lint.go | 38 +++++++++++++++++++++++++++++--------- internal/lint/result.go | 24 +++++++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index 47108e9c7..cf04df3b4 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -7,8 +7,8 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" + "unicode" "github.com/fatih/color" "github.com/spf13/cobra" @@ -55,7 +55,8 @@ func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error { result, format, err := lintInput(cmd, args, vendor) if err != nil { // Print operational errors ourselves, since the command silences errors. - fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(err.Error())) + // Go error strings are lowercase by convention; capitalize for display. + fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(capitalizeFirst(err.Error()))) return errLintFailed } return printLintResult(cmd, result, format) @@ -92,13 +93,23 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res path = args[0] } root := lint.FindProjectRoot(path) - if abs, err := filepath.Abs(path); err == nil && abs != root { - fmt.Fprintln(cmd.ErrOrStderr(), color.New(color.Faint).Sprintf("Linting project root: %s", root)) + if format == "text" { + fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root)) } result, _, err := lint.CheckDir(ctx, root, vendor) return result, format, err } +// capitalizeFirst upper-cases the first rune of s for user-facing display. +func capitalizeFirst(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} + // lintStdin reads configuration from standard input and lints it. func lintStdin(ctx context.Context, cmd *cobra.Command) (*lint.Result, error) { content, err := io.ReadAll(cmd.InOrStdin()) @@ -132,14 +143,23 @@ func printLintResult(cmd *cobra.Command, result *lint.Result, format string) err } w := cmd.ErrOrStderr() + printIssueSection(w, color.New(color.FgRed, color.Bold), "Linter errors:", result.ErrorLines()) + printIssueSection(w, color.New(color.FgYellow, color.Bold), "Linter warnings:", result.WarningLines()) if result.HasErrors() { - fmt.Fprintln(w, color.RedString(result.String())) return errLintFailed } - if result.HasWarnings() { - fmt.Fprintln(w, color.YellowString(result.String())) - return nil + if !result.HasWarnings() { + fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.") } - fmt.Fprintln(w, color.GreenString("✓ The configuration is valid.")) return nil } + +// printIssueSection prints a colored heading followed by the issue lines in the +// default color. It is a no-op when there are no lines. +func printIssueSection(w io.Writer, heading *color.Color, title string, lines []string) { + if len(lines) == 0 { + return + } + fmt.Fprintln(w, heading.Sprint(title)) + fmt.Fprintln(w, strings.Join(lines, "\n")) +} diff --git a/internal/lint/result.go b/internal/lint/result.go index 6ee0f712c..da5c127f4 100644 --- a/internal/lint/result.go +++ b/internal/lint/result.go @@ -44,13 +44,14 @@ func (r *Result) HasWarnings() bool { return len(r.Warnings) > 0 } -// formatIssues formats a list of validation issues with a given prefix. -func formatIssues(issues []Issue, prefix string) []string { +// issueLines formats a list of validation issues into sorted lines, without a +// heading. Callers that want a heading prepend their own (see formatIssues). +func issueLines(issues []Issue) []string { if len(issues) == 0 { return nil } - var messages []string + messages := make([]string, 0, len(issues)) for _, issue := range issues { if issue.Path != "" { messages = append(messages, fmt.Sprintf(" - %s: %s", issue.Path, issue.Message)) @@ -61,9 +62,22 @@ func formatIssues(issues []Issue, prefix string) []string { // Sort the issue messages for consistent ordering. sort.Strings(messages) + return messages +} + +// ErrorLines returns the formatted, sorted error lines without a heading. +func (r *Result) ErrorLines() []string { return issueLines(r.Errors) } - // Add prefix at the beginning. - return append([]string{prefix}, messages...) +// WarningLines returns the formatted, sorted warning lines without a heading. +func (r *Result) WarningLines() []string { return issueLines(r.Warnings) } + +// formatIssues formats a list of validation issues with a given prefix. +func formatIssues(issues []Issue, prefix string) []string { + lines := issueLines(issues) + if lines == nil { + return nil + } + return append([]string{prefix}, lines...) } // formatResult formats all lint issues with appropriate capitalization. From 196654c788d301d20ffdc2afef68ff6dc7181c82 Mon Sep 17 00:00:00 2001 From: Bojan Zivanovic Date: Fri, 7 Aug 2026 19:09:05 +0200 Subject: [PATCH 10/36] Drop GOEXPERIMENT=jsonv2 Go 1.27 is about to be released where this is on by default anyway. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index be11ec607..b68a1a2fa 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ PLATFORMIFY_SCHEMA_URL = https://raw.githubusercontent.com/platformsh/platformif .PHONY: lint-assets lint-assets: ## Refresh the embedded lint registry and schemas from upstream - cd internal/lint/registry && GOEXPERIMENT=jsonv2 go run gen.go + cd internal/lint/registry && go run gen.go curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.application.json -o internal/lint/schema/platformsh.application.json curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.routes.json -o internal/lint/schema/platformsh.routes.json From a7dc7aee2f095fab15b3bd58e6750fdae9c19c92 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 21:53:22 -0400 Subject: [PATCH 11/36] fix(lint): build Flex glob patterns with forward slashes io/fs glob patterns are always slash-separated, but filepath.Join used the OS separator, so on Windows the pattern was `.upsun\*.yaml`. path.Match treats the backslash as an escape, so nothing matched: detection returned false and every Flex project reported "no configuration files found". Use path.Join, and rename the parameter to `dir` so it no longer shadows the package. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lint/merge.go | 16 +++++++++------- internal/lint/merge_test.go | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/lint/merge.go b/internal/lint/merge.go index 6b36a5eae..e6511f440 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -3,17 +3,19 @@ package lint import ( "fmt" "io/fs" - "path/filepath" + "path" "gopkg.in/yaml.v3" ) // findFlexConfigFiles returns all *.yaml and *.yml files in the Flex config -// directory (e.g. .upsun) of the given path. Flex files may have any name. -func findFlexConfigFiles(fsys fs.FS, path, configDir string) ([]string, error) { +// directory (e.g. .upsun) of the given directory. Flex files may have any name. +func findFlexConfigFiles(fsys fs.FS, dir, configDir string) ([]string, error) { + // io/fs paths are always slash-separated, so path.Join is used here rather + // than filepath.Join, whose Windows separator would match nothing. patterns := []string{ - filepath.Join(path, configDir, "*.yaml"), - filepath.Join(path, configDir, "*.yml"), + path.Join(dir, configDir, "*.yaml"), + path.Join(dir, configDir, "*.yml"), } var allMatches []string @@ -78,8 +80,8 @@ func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { // getMergedConfigFiles merges all Flex config files in the given directory. // It is a convenience wrapper for findFlexConfigFiles + mergeConfigFiles. -func getMergedConfigFiles(fsys fs.FS, path, configDir string) (string, error) { - files, err := findFlexConfigFiles(fsys, path, configDir) +func getMergedConfigFiles(fsys fs.FS, dir, configDir string) (string, error) { + files, err := findFlexConfigFiles(fsys, dir, configDir) if err != nil { return "", err } diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go index 7ea283ad7..06c9ca731 100644 --- a/internal/lint/merge_test.go +++ b/internal/lint/merge_test.go @@ -19,6 +19,22 @@ func TestFindFlexConfigFiles(t *testing.T) { require.ElementsMatch(t, []string{".upsun/a.yaml", ".upsun/b.yaml", ".upsun/c.yml"}, files) } +// io/fs glob patterns are always slash-separated, on every platform. A nested +// directory catches a pattern built with the OS separator, which would match +// nothing on Windows. +func TestFindFlexConfigFiles_NestedDir(t *testing.T) { + fsys := fstest.MapFS{ + "sub/dir/.upsun/a.yaml": &fstest.MapFile{Data: []byte("{}")}, + "sub/dir/.upsun/b.yml": &fstest.MapFile{Data: []byte("{}")}, + } + files, err := findFlexConfigFiles(fsys, "sub/dir", ".upsun") + require.NoError(t, err) + require.ElementsMatch(t, []string{"sub/dir/.upsun/a.yaml", "sub/dir/.upsun/b.yml"}, files) + + _, err = findFlexConfigFiles(fstest.MapFS{}, "sub/dir", ".upsun") + require.ErrorContains(t, err, "sub/dir/.upsun/*.yaml") +} + func TestMergeConfigFiles_Success(t *testing.T) { fsys := fstest.MapFS{ "a.yaml": &fstest.MapFile{Data: []byte(`applications: From edea3fb26c4a1ce492942e4a2b3589cb4f0315f1 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 21:53:45 -0400 Subject: [PATCH 12/36] fix(lint): always emit JSON arrays for errors and warnings For a valid configuration both slices are nil, so --format json produced {"errors": null, "warnings": null}. The output documents these as arrays, so a consumer iterating them without a null guard broke on valid input. Co-Authored-By: Claude Opus 5 (1M context) --- commands/lint.go | 11 +++++++- commands/lint_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 commands/lint_test.go diff --git a/commands/lint.go b/commands/lint.go index cf04df3b4..72db4b0eb 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -125,12 +125,21 @@ func stdinIsPiped() bool { return err == nil && (stat.Mode()&os.ModeCharDevice) == 0 } +// issuesOrEmpty replaces a nil slice with an empty one, so that the JSON output +// always contains arrays rather than null. +func issuesOrEmpty(issues []lint.Issue) []lint.Issue { + if issues == nil { + return []lint.Issue{} + } + return issues +} + func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error { if format == "json" { out := struct { Errors []lint.Issue `json:"errors"` Warnings []lint.Issue `json:"warnings"` - }{Errors: result.Errors, Warnings: result.Warnings} + }{Errors: issuesOrEmpty(result.Errors), Warnings: issuesOrEmpty(result.Warnings)} enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") if err := enc.Encode(out); err != nil { diff --git a/commands/lint_test.go b/commands/lint_test.go new file mode 100644 index 000000000..eed57d71b --- /dev/null +++ b/commands/lint_test.go @@ -0,0 +1,59 @@ +package commands + +import ( + "bytes" + "encoding/json" + "io" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/lint" +) + +// The JSON output documents "errors" and "warnings" as arrays, so a valid +// configuration must still yield arrays rather than null. +func TestPrintLintResult_JSONAlwaysArrays(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + + require.NoError(t, printLintResult(cmd, &lint.Result{}, "json")) + + // Pointers distinguish a JSON null from an empty array. + var decoded struct { + Errors *[]lint.Issue `json:"errors"` + Warnings *[]lint.Issue `json:"warnings"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) + require.NotNil(t, decoded.Errors, "errors should be an array, not null") + require.NotNil(t, decoded.Warnings, "warnings should be an array, not null") + assert.Empty(t, *decoded.Errors) + assert.Empty(t, *decoded.Warnings) +} + +func TestPrintLintResult_JSONWithIssues(t *testing.T) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + + result := &lint.Result{} + result.AddError("applications.app1.type", "invalid type") + result.AddWarning("applications.app1.web.commands.start", "a start command is needed") + + require.ErrorIs(t, printLintResult(cmd, result, "json"), errLintFailed) + + var decoded struct { + Errors []lint.Issue `json:"errors"` + Warnings []lint.Issue `json:"warnings"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &decoded)) + assert.Equal(t, []lint.Issue{{Path: "applications.app1.type", Message: "invalid type"}}, decoded.Errors) + assert.Equal(t, []lint.Issue{ + {Path: "applications.app1.web.commands.start", Message: "a start command is needed"}, + }, decoded.Warnings) +} From f3e00fbcd1d41dc8762ea37978f509154c199ba9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 21:53:58 -0400 Subject: [PATCH 13/36] feat(lint): check the shell syntax of worker commands Hooks, web commands and crons were parsed, but worker commands were not decoded at all, so a syntax error in one went unreported. Both schemas require workers.*.commands.start; pre_start is also accepted, and post_start in Flex. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lint/config_schema.go | 6 ++++++ internal/lint/scripts.go | 6 ++++++ internal/lint/scripts_test.go | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index ee816faf6..e20b91346 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -41,6 +41,12 @@ type Config struct { Workers map[string]struct { Type string `yaml:"type,omitempty"` + + Commands struct { + PreStart string `yaml:"pre_start,omitempty"` + Start string `yaml:"start,omitempty"` + PostStart string `yaml:"post_start,omitempty"` // Flex only. + } `yaml:"commands,omitempty"` } `yaml:"workers,omitempty"` Dependencies map[string]map[string]any `yaml:"dependencies,omitempty"` diff --git a/internal/lint/scripts.go b/internal/lint/scripts.go index c14aa6075..9f17f2c42 100644 --- a/internal/lint/scripts.go +++ b/internal/lint/scripts.go @@ -33,6 +33,12 @@ func CheckScripts(cfg *Config) *Result { scripts[cronPrefix+"start"] = cron.Commands.Start scripts[cronPrefix+"stop"] = cron.Commands.Stop } + for workerName, worker := range app.Workers { + workerPrefix := keyPrefix + "workers." + workerName + ".commands." + scripts[workerPrefix+"pre_start"] = worker.Commands.PreStart + scripts[workerPrefix+"start"] = worker.Commands.Start + scripts[workerPrefix+"post_start"] = worker.Commands.PostStart + } } for k, v := range scripts { diff --git a/internal/lint/scripts_test.go b/internal/lint/scripts_test.go index c7b8ba964..cf405aee6 100644 --- a/internal/lint/scripts_test.go +++ b/internal/lint/scripts_test.go @@ -95,6 +95,41 @@ applications: expectErrorMessage: "linter errors:\n - applications.app2.hooks.build: invalid syntax: 1:5: " + "c-style fors are a bash/zsh feature; tried parsing as posix", }, + { + name: "invalid worker start command", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + web: + commands: + start: "node server.js" + workers: + queue: + commands: + start: "node worker.js | | grep output" +`, + expectErrorMessage: "linter errors:\n - applications.app1.workers.queue.commands.start: " + + "invalid syntax: 1:16: `|` must be followed by a statement", + }, + { + name: "invalid worker pre_start command", + yamlContent: ` +applications: + app1: + type: "nodejs:20" + web: + commands: + start: "node server.js" + workers: + queue: + commands: + pre_start: "echo 'unterminated" + start: "node worker.js" +`, + expectErrorMessage: "linter errors:\n - applications.app1.workers.queue.commands.pre_start: " + + "invalid syntax: 1:6: reached EOF without closing quote `'`", + }, { name: "empty YAML", yamlContent: ` From aaccab5c767758a03a32a9fa3e334b9046df62e3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 21:54:11 -0400 Subject: [PATCH 14/36] chore(lint): drop unused registry helper and tidy tests and docs Registry.ForTemplates has no caller: it came from the source project, where the registry fed Jinja templates. Only its own tests used it. mockSchema discarded the error from gojsonschema.NewSchema, so an invalid schema would have returned nil and panicked later instead of failing. Also correct the CLAUDE.md description, which listed app:config-validate as an alias of itself. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- internal/lint/registry/model.go | 17 --------- internal/lint/registry/model_test.go | 57 ---------------------------- internal/lint/yaml_test.go | 9 +++-- 4 files changed, 7 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bdba82bd..e011f5620 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,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 -- `lint.go`: Native config linter (aliases `validate`, `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 - Unrecognized commands are passed to the legacy PHP CLI **Configuration**: `internal/config/` diff --git a/internal/lint/registry/model.go b/internal/lint/registry/model.go index 690070402..8b10a3972 100644 --- a/internal/lint/registry/model.go +++ b/internal/lint/registry/model.go @@ -115,20 +115,3 @@ func (v VersionInfo) LatestVersion() string { } return v.Supported[0] } - -// ForTemplates returns a template-friendly representation of the registry -// with latest_version as a direct field for easier Jinja template access. -func (r Registry) ForTemplates() map[string]map[string]any { - result := make(map[string]map[string]any, len(r)) - for key := range r { - img := r[key] - result[key] = map[string]any{ - "name": img.Name, - "type": img.Type, - "is_runtime": img.IsRuntime, - "latest_version": img.Versions.LatestVersion(), - "versions": img.Versions, - } - } - return result -} diff --git a/internal/lint/registry/model_test.go b/internal/lint/registry/model_test.go index e6b1f8047..b7e67feb2 100644 --- a/internal/lint/registry/model_test.go +++ b/internal/lint/registry/model_test.go @@ -115,60 +115,3 @@ func TestVersionInfo_LatestVersion_WithRealRegistry(t *testing.T) { assert.NotEmpty(t, latest, "PostgreSQL should have a latest version") } } - -func TestRegistry_ForTemplates(t *testing.T) { - reg := registry.Registry{ - "php": registry.Image{ - Name: "PHP", - Type: "php", - IsRuntime: true, - Versions: registry.VersionInfo{ - Supported: []string{"8.4", "8.3"}, - }, - }, - "mariadb": registry.Image{ - Name: "MariaDB", - Type: "mariadb", - IsRuntime: false, - Versions: registry.VersionInfo{ - Supported: []string{"11.4", "11.0"}, - }, - }, - } - - result := reg.ForTemplates() - - assert.Len(t, result, 2) - - phpData, ok := result["php"] - assert.True(t, ok) - assert.Equal(t, "PHP", phpData["name"]) - assert.Equal(t, "php", phpData["type"]) - assert.Equal(t, true, phpData["is_runtime"]) - assert.Equal(t, "8.4", phpData["latest_version"]) - - mariaData, ok := result["mariadb"] - assert.True(t, ok) - assert.Equal(t, "MariaDB", mariaData["name"]) - assert.Equal(t, "mariadb", mariaData["type"]) - assert.Equal(t, false, mariaData["is_runtime"]) - assert.Equal(t, "11.4", mariaData["latest_version"]) -} - -func TestRegistry_ForTemplates_WithRealRegistry(t *testing.T) { - reg, err := registry.Parsed() - assert.NoError(t, err) - - result := reg.ForTemplates() - - assert.Equal(t, len(reg), len(result), "should have same number of entries") - - for key, img := range reg { - data, ok := result[key] - assert.True(t, ok, "should have entry for %s", key) - assert.Equal(t, img.Name, data["name"]) - assert.Equal(t, img.Type, data["type"]) - assert.Equal(t, img.IsRuntime, data["is_runtime"]) - assert.Equal(t, img.Versions.LatestVersion(), data["latest_version"]) - } -} diff --git a/internal/lint/yaml_test.go b/internal/lint/yaml_test.go index fbfa475e3..094669a41 100644 --- a/internal/lint/yaml_test.go +++ b/internal/lint/yaml_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/xeipuuv/gojsonschema" "github.com/upsun/cli/internal/lint" @@ -59,7 +60,7 @@ list: }, } - schema := mockSchema() + schema := mockSchema(t) for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -76,8 +77,9 @@ list: } } -func mockSchema() *gojsonschema.Schema { - schema, _ := gojsonschema.NewSchema(gojsonschema.NewStringLoader(` +func mockSchema(t *testing.T) *gojsonschema.Schema { + t.Helper() + schema, err := gojsonschema.NewSchema(gojsonschema.NewStringLoader(` { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", @@ -94,5 +96,6 @@ func mockSchema() *gojsonschema.Schema { }, "required": ["key"] }`)) + require.NoError(t, err) return schema } From 49da939240a6289c5e7991ea20a1e685bb09f54a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 22:23:45 -0400 Subject: [PATCH 15/36] fix(lint): accept valkey-persistent and replica service types Two service types were rejected outright: valkey-persistent was created inside `if _, ok := reg["valkey"]; !ok`. The registry does contain valkey, so the branch never ran and the alias was never added. Split it into its own check, keyed on the alias, as the redis-persistent block already does. mariadb-replica and postgresql-replica are listed upstream without versions of their own, so every version was rejected, with an empty "it must be exactly one of: " list. They now track the type they replicate. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lint/registry/registry.go | 35 +++++++++++++++++++++---- internal/lint/registry/registry_test.go | 34 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go index 50265a232..9abd4d1ab 100644 --- a/internal/lint/registry/registry.go +++ b/internal/lint/registry/registry.go @@ -3,6 +3,7 @@ package registry import ( _ "embed" "encoding/json" + "strings" "sync" ) @@ -86,14 +87,38 @@ func clean(reg Registry) { } if _, ok := reg["valkey"]; !ok { reg["valkey"] = Image{ - Name: "Valkey (ephemeral)", + Name: "Valkey", Type: "valkey", Versions: VersionInfo{Supported: []string{"8.0"}}, } - reg["valkey-persistent"] = Image{ - Name: "Valkey (persistent)", - Type: "valkey-persistent", - Versions: VersionInfo{Supported: []string{"8.0"}}, + } + if _, ok := reg["valkey-persistent"]; !ok { + // Treat "valkey-persistent" as a copy of "valkey". + if valkey, ok := reg["valkey"]; ok { + reg["valkey-persistent"] = Image{ + Name: "Valkey (persistent)", + Type: "valkey-persistent", + Versions: valkey.Versions, + } + valkey.Name = "Valkey (ephemeral)" + reg["valkey"] = valkey + } + } + + // Replica images are listed upstream without versions of their own, which + // would reject every value. They track the type they replicate. + for name := range reg { + base, isReplica := strings.CutSuffix(name, "-replica") + if !isReplica { + continue + } + replica := reg[name] + if len(replica.Versions.Supported) > 0 { + continue + } + if img, ok := reg[base]; ok { + replica.Versions = img.Versions + reg[name] = replica } } diff --git a/internal/lint/registry/registry_test.go b/internal/lint/registry/registry_test.go index a847ec4c7..db84e6f1e 100644 --- a/internal/lint/registry/registry_test.go +++ b/internal/lint/registry/registry_test.go @@ -20,3 +20,37 @@ func TestRegistry(t *testing.T) { // redis-persistent is added by clean(). assert.Contains(t, reg, "redis-persistent") } + +// The persistent variants are aliases added by clean(), tracking the versions +// of the type they copy. +func TestRegistry_PersistentAliases(t *testing.T) { + reg, err := registry.Parsed() + assert.NoError(t, err) + + for _, c := range []struct{ base, alias string }{ + {"redis", "redis-persistent"}, + {"valkey", "valkey-persistent"}, + } { + t.Run(c.alias, func(t *testing.T) { + assert.Contains(t, reg, c.alias) + assert.NotEmpty(t, reg[c.alias].Versions.Supported) + assert.Equal(t, reg[c.base].Versions, reg[c.alias].Versions) + }) + } +} + +// Replica images are listed upstream without versions of their own, so clean() +// takes them from the base type. Without this they reject every version. +func TestRegistry_ReplicaVersions(t *testing.T) { + reg, err := registry.Parsed() + assert.NoError(t, err) + + for _, base := range []string{"mariadb", "postgresql"} { + t.Run(base+"-replica", func(t *testing.T) { + replica := base + "-replica" + assert.Contains(t, reg, replica) + assert.NotEmpty(t, reg[replica].Versions.Supported) + assert.Equal(t, reg[base].Versions, reg[replica].Versions) + }) + } +} From d76dab349c03aff021f29b4b1e5060aa8f92e7bb Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 22:23:55 -0400 Subject: [PATCH 16/36] fix(lint): scope worker names to their application Worker names were added to the same map as application and service names, so two applications each defining a `queue` worker were reported as a duplicate, as was a worker sharing a name with a service. Worker names are scoped beneath their application, so they are now tracked separately and excluded from the duplicate check. They remain valid relationship targets, as before. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lint/relationships.go | 11 +++++--- internal/lint/relationships_test.go | 40 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go index 2c6f6f067..f0af07f8e 100644 --- a/internal/lint/relationships.go +++ b/internal/lint/relationships.go @@ -17,15 +17,17 @@ func CheckRelationships(cfg *Config) *Result { serviceNames[name] = source return "" } + // Worker names are scoped to their application, so they are tracked + // separately: two applications may each define a worker with the same name, + // and a worker may share a name with an application or service. + var workerNames = make(map[string]struct{}) for appName := range cfg.Applications { app := cfg.Applications[appName] if msg := addService(appName, "applications"); msg != "" { result.AddError("applications."+appName, msg) } for name := range app.Workers { - if msg := addService(name, "applications."+appName+".workers."+name); msg != "" { - result.AddError("applications."+appName+".workers."+name, msg) - } + workerNames[name] = struct{}{} } } for name := range cfg.Services { @@ -55,7 +57,8 @@ func CheckRelationships(cfg *Config) *Result { explicit = true } - if _, exists := serviceNames[relationshipService]; exists { + _, isNamed := serviceNames[relationshipService] + if _, isWorker := workerNames[relationshipService]; isNamed || isWorker { linkedServices[relationshipService] = struct{}{} } else { var msg string diff --git a/internal/lint/relationships_test.go b/internal/lint/relationships_test.go index 9439ca1e0..9887ec556 100644 --- a/internal/lint/relationships_test.go +++ b/internal/lint/relationships_test.go @@ -99,6 +99,46 @@ services: expectErrorMessage: "linter errors:\n - applications.bar.relationships.postgres: relationship 'postgres' in application 'bar' " + //nolint:lll "does not match any service (or app)", }, + { + // Worker names are scoped to their application, so two applications + // may each define a worker with the same name. + name: "correct_duplicate_worker_names_across_apps", + content: ` +applications: + foo: + relationships: + database: + workers: + queue: + commands: + start: "node worker.js" + bar: + relationships: + database: + workers: + queue: + commands: + start: "node worker.js" +services: + database: + type: mariadb:11.4`, + }, + { + // A worker may share a name with a service without clashing. + name: "correct_worker_name_matching_service", + content: ` +applications: + foo: + relationships: + database: + workers: + database: + commands: + start: "node worker.js" +services: + database: + type: mariadb:11.4`, + }, } for _, c := range cases { From 3f311a6b6148387ccc28a51d06d5a03da11d9c8c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 7 Aug 2026 22:50:58 -0400 Subject: [PATCH 17/36] chore(lint): refresh embedded registry and derive replica types The registry had drifted from meta.upsun.com, failing lint-assets-check: mariadb and postgresql gained 12.3, redis gained 8.8, rabbitmq moved to 4.3, and elixir, kafka and python versions moved. The refresh also dropped mariadb-replica and postgresql-replica, which are no longer published upstream at all, so filling in their versions was not enough. They are now created from the type they replicate, like the persistent aliases, which keeps them working whether or not upstream lists them. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lint/registry/registry.go | 20 ++++++++-------- internal/lint/registry/registry.json | 36 +++++++++++----------------- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go index 9abd4d1ab..7e775797c 100644 --- a/internal/lint/registry/registry.go +++ b/internal/lint/registry/registry.go @@ -3,7 +3,6 @@ package registry import ( _ "embed" "encoding/json" - "strings" "sync" ) @@ -105,21 +104,22 @@ func clean(reg Registry) { } } - // Replica images are listed upstream without versions of their own, which - // would reject every value. They track the type they replicate. - for name := range reg { - base, isReplica := strings.CutSuffix(name, "-replica") - if !isReplica { + // Replica images are deployable but are not listed upstream, or are listed + // without versions of their own. They track the type they replicate. + for _, base := range []string{"mariadb", "postgresql"} { + img, ok := reg[base] + if !ok { continue } + name := base + "-replica" replica := reg[name] if len(replica.Versions.Supported) > 0 { continue } - if img, ok := reg[base]; ok { - replica.Versions = img.Versions - reg[name] = replica - } + replica.Name = img.Name + " (replica)" + replica.Type = name + replica.Versions = img.Versions + reg[name] = replica } // Treat "mysql" as an alias of "mariadb". diff --git a/internal/lint/registry/registry.json b/internal/lint/registry/registry.json index f0b9e46ef..971ad6502 100644 --- a/internal/lint/registry/registry.json +++ b/internal/lint/registry/registry.json @@ -64,10 +64,10 @@ "runtime": true, "versions": { "supported": [ - "1.19", - "1.15" + "1.20" ], "legacy": [ + "1.19", "1.18" ] } @@ -123,7 +123,10 @@ "runtime": false, "versions": { "supported": [ - "4.1" + "4.3", + "4.1", + "3.7", + "3.6" ] } }, @@ -133,18 +136,12 @@ "runtime": false, "versions": { "supported": [ + "12.3", "11.8", - "11.4", - "10.11", - "10.6" + "11.4" ] } }, - "mariadb-replica": { - "name": "mariadb-replica", - "type": "mariadb-replica", - "runtime": false - }, "memcached": { "name": "Memcached", "type": "memcached", @@ -191,10 +188,9 @@ "runtime": false, "versions": { "supported": [ + "12.3", "11.8", - "11.4", - "10.11", - "10.6" + "11.4" ] } }, @@ -277,11 +273,6 @@ ] } }, - "postgresql-replica": { - "name": "postgresql-replica", - "type": "postgresql-replica", - "runtime": false - }, "python": { "name": "Python", "type": "python", @@ -304,7 +295,7 @@ "runtime": false, "versions": { "supported": [ - "4.2" + "4.3" ] } }, @@ -314,10 +305,11 @@ "runtime": false, "versions": { "supported": [ - "8.0" + "8.8", + "8.0", + "7.2" ], "legacy": [ - "7.2", "6.2" ] } From cf808f65ebafd7ecfa12cfa5be6d767f9826719f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:18:28 +0100 Subject: [PATCH 18/36] refactor(lint): require --stdin for piped input and drop unused parameters - Piped stdin is no longer read implicitly when no path is given. A script that runs the command with unrelated stdin (e.g. inside a `while read` loop) would otherwise lint that input instead of the directory. Use --stdin to lint merged Flex configuration from standard input. - Remove the unused context parameters from CheckContent, CheckDir and lintFixed, and the unused Style.String method. Co-Authored-By: Claude Opus 5.5 --- commands/lint.go | 30 ++++-------------------------- internal/lint/fixed.go | 3 +-- internal/lint/fixed_test.go | 5 ++--- internal/lint/linter.go | 3 +-- internal/lint/linter_test.go | 3 +-- internal/lint/normalize.go | 18 +++--------------- internal/lint/normalize_test.go | 15 +++++++-------- 7 files changed, 19 insertions(+), 58 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index 72db4b0eb..84a214d27 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -1,12 +1,10 @@ package commands import ( - "context" "encoding/json" "errors" "fmt" "io" - "os" "strings" "unicode" @@ -69,25 +67,11 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) } - ctx := cmd.Context() if explicitStdin { - result, err := lintStdin(ctx, cmd) + result, err := lintStdin(cmd) return result, format, err } - // With no path argument, lint piped stdin if it carries content; otherwise - // (e.g. a non-interactive shell or CI with no input) fall back to the directory. - if len(args) == 0 && stdinIsPiped() { - content, err := io.ReadAll(cmd.InOrStdin()) - if err != nil { - return nil, format, err - } - if strings.TrimSpace(string(content)) != "" { - result, err := lint.CheckContent(ctx, string(content)) - return result, format, err - } - } - path := "." if len(args) == 1 { path = args[0] @@ -96,7 +80,7 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res if format == "text" { fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root)) } - result, _, err := lint.CheckDir(ctx, root, vendor) + result, _, err := lint.CheckDir(root, vendor) return result, format, err } @@ -111,18 +95,12 @@ func capitalizeFirst(s string) string { } // lintStdin reads configuration from standard input and lints it. -func lintStdin(ctx context.Context, cmd *cobra.Command) (*lint.Result, error) { +func lintStdin(cmd *cobra.Command) (*lint.Result, error) { content, err := io.ReadAll(cmd.InOrStdin()) if err != nil { return nil, err } - return lint.CheckContent(ctx, string(content)) -} - -// stdinIsPiped reports whether standard input is a pipe or file rather than a terminal. -func stdinIsPiped() bool { - stat, err := os.Stdin.Stat() - return err == nil && (stat.Mode()&os.ModeCharDevice) == 0 + return lint.CheckContent(string(content)) } // issuesOrEmpty replaces a nil slice with an empty one, so that the JSON output diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index d864a4cad..8754ebb7b 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -1,7 +1,6 @@ package lint import ( - "context" "fmt" "os" "path/filepath" @@ -26,7 +25,7 @@ var flexTopKeys = []string{keyApplications, keyServices, keyRoutes} // lintFixed lints Fixed-style configuration in dir using the resolved names in // cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus // optional cfg.dir/routes.yaml and cfg.dir/services.yaml. -func lintFixed(_ context.Context, dir string, cfg fixedNames) (*Result, error) { +func lintFixed(dir string, cfg fixedNames) (*Result, error) { result := &Result{} apps, err := loadFixedApplications(dir, cfg, result) diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index b193dcc2e..c862cd053 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -1,7 +1,6 @@ package lint import ( - "context" "path/filepath" "testing" @@ -97,7 +96,7 @@ type: "php:999"`, writeFile(t, filepath.Join(dir, name), content) } - result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + result, style, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFixed, style) @@ -119,7 +118,7 @@ func TestLintFixed_DuplicateAppName(t *testing.T) { writeFile(t, filepath.Join(dir, ".platform", "routes.yaml"), "{}") writeFile(t, filepath.Join(dir, "a", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") writeFile(t, filepath.Join(dir, "b", ".platform.app.yaml"), "name: same\ntype: \"php:8.3\"") - result, _, err := CheckDir(context.Background(), dir, upsunVendor()) + result, _, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) // The error should name both source files so it is actionable. assert.Contains(t, result.String(), `duplicate application name "same"`) diff --git a/internal/lint/linter.go b/internal/lint/linter.go index 1c6c88c8f..19ff8dd44 100644 --- a/internal/lint/linter.go +++ b/internal/lint/linter.go @@ -1,7 +1,6 @@ package lint import ( - "context" "errors" "fmt" @@ -12,7 +11,7 @@ import ( var ErrEmptyContent = errors.New("empty content") // CheckContent checks merged Flex-style configuration content and returns a Result. -func CheckContent(_ context.Context, content string) (*Result, error) { +func CheckContent(content string) (*Result, error) { if len(content) == 0 { return nil, ErrEmptyContent } diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 9e3649ca8..d4d873827 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -1,7 +1,6 @@ package lint import ( - "context" "testing" "github.com/stretchr/testify/assert" @@ -94,7 +93,7 @@ services: for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result, err := CheckContent(context.Background(), tc.content) + result, err := CheckContent(tc.content) assert.NoError(t, err) if tc.wantErr { diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 4e35ff7f2..5bae10143 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -1,7 +1,6 @@ package lint import ( - "context" "fmt" "io/fs" "os" @@ -20,17 +19,6 @@ const ( StyleFixed ) -func (s Style) String() string { - switch s { - case StyleFlex: - return "flex" - case StyleFixed: - return "fixed" - default: - return "unknown" - } -} - // First-party config conventions. The native format and directory of any CLI // come from its config (see Vendor); these constants are the cross-brand // counterpart that a first-party build also recognizes. @@ -86,7 +74,7 @@ func (v Vendor) candidates() (flexDirs []string, fixed []fixedNames) { // CheckDir detects the configuration style in dir and lints it, returning the // detected style. Detection uses the vendor's name conventions and the files // present: Flex wins when present, else Fixed, else the native format. -func CheckDir(ctx context.Context, dir string, vendor Vendor) (*Result, Style, error) { +func CheckDir(dir string, vendor Vendor) (*Result, Style, error) { flexDirs, fixedSet := vendor.candidates() flexDir, flexOK := detectFlex(dir, flexDirs) fixedCfg, fixedOK := detectFixed(dir, fixedSet) @@ -97,7 +85,7 @@ func CheckDir(ctx context.Context, dir string, vendor Vendor) (*Result, Style, e if err != nil { return nil, StyleFlex, err } - result, err := CheckContent(ctx, content) + result, err := CheckContent(content) if err != nil { return nil, StyleFlex, err } @@ -109,7 +97,7 @@ func CheckDir(ctx context.Context, dir string, vendor Vendor) (*Result, Style, e } return result, StyleFlex, nil case fixedOK: - result, err := lintFixed(ctx, dir, fixedCfg) + result, err := lintFixed(dir, fixedCfg) if err != nil { return nil, StyleFixed, err } diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index 2ea5cc907..0443237df 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -1,7 +1,6 @@ package lint import ( - "context" "os" "path/filepath" "testing" @@ -43,7 +42,7 @@ func TestDetectStyle(t *testing.T) { for p, c := range tc.files { writeFile(t, filepath.Join(dir, p), c) } - _, style, err := CheckDir(context.Background(), dir, upsunVendor()) + _, style, err := CheckDir(dir, upsunVendor()) if tc.wantErr { require.Error(t, err) } else { @@ -89,7 +88,7 @@ func TestCheckDir_Vendors(t *testing.T) { for p, c := range tc.files { writeFile(t, filepath.Join(dir, p), c) } - _, style, err := CheckDir(context.Background(), dir, tc.vendor) + _, style, err := CheckDir(dir, tc.vendor) if tc.wantErr { require.Error(t, err) } else { @@ -122,7 +121,7 @@ func TestLintFixed_StrayPlatformDir(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app\ntype: \"php:8.3\"") writeFile(t, filepath.Join(dir, "legacy", ".platform", "routes.yaml"), "{}") - result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + result, style, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFixed, style) assert.Contains(t, result.String(), "legacy/.platform") @@ -135,7 +134,7 @@ func TestLintFixed_StrayUpsunDir(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app\ntype: \"php:8.3\"") writeFile(t, filepath.Join(dir, "sub", ".upsun", "config.yaml"), "applications: {}") - result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + result, style, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFixed, style) assert.Contains(t, result.String(), "sub/.upsun") @@ -156,7 +155,7 @@ routes: type: upstream upstream: "app:http" `) - result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + result, style, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.False(t, result.HasErrors(), "expected no errors, got: %s", result) @@ -164,7 +163,7 @@ routes: func TestLintDir_NoConfig(t *testing.T) { dir := t.TempDir() - _, style, err := CheckDir(context.Background(), dir, upsunVendor()) + _, style, err := CheckDir(dir, upsunVendor()) require.Error(t, err) assert.Equal(t, StyleUnknown, style) assert.Contains(t, err.Error(), "no configuration found") @@ -177,7 +176,7 @@ func TestLintDir_BothPresentPrefersFlex(t *testing.T) { type: "php:8.3" `) writeFile(t, filepath.Join(dir, ".platform.app.yaml"), "name: app") - result, style, err := CheckDir(context.Background(), dir, upsunVendor()) + result, style, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, StyleFlex, style) assert.True(t, result.HasWarnings()) From 842425724892403e1f5a1dda96d97c13ca638de2 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:20:34 +0100 Subject: [PATCH 19/36] chore(lint): refresh embedded registry Upstream now lists golang 1.27/1.26 (dropping 1.25), clickhouse 26.3, and mariadb/mysql 10.11. Tests move from golang:1.25 to golang:1.26. Co-Authored-By: Claude Opus 5.5 --- internal/lint/linter_test.go | 6 +++--- internal/lint/registry/registry.json | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index d4d873827..d60aba40c 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -38,7 +38,7 @@ services: {} content: ` applications: foo: - type: golang:1.25 + type: golang:1.26 relationships: database: web: @@ -54,7 +54,7 @@ services: content: ` applications: foo: - type: golang:1.25 + type: golang:1.26 relationships: database: web: @@ -71,7 +71,7 @@ services: content: ` applications: foo: - type: golang:1.25 + type: golang:1.26 relationships: database: web: diff --git a/internal/lint/registry/registry.json b/internal/lint/registry/registry.json index 971ad6502..d46fe5363 100644 --- a/internal/lint/registry/registry.json +++ b/internal/lint/registry/registry.json @@ -16,10 +16,10 @@ "runtime": false, "versions": { "supported": [ - "26.3", - "25.8" + "26.3" ], "legacy": [ + "25.8", "25.3", "24.3", "23.8" @@ -78,8 +78,8 @@ "runtime": true, "versions": { "supported": [ - "1.26", - "1.25" + "1.27", + "1.26" ] } }, @@ -138,7 +138,8 @@ "supported": [ "12.3", "11.8", - "11.4" + "11.4", + "10.11" ] } }, @@ -190,7 +191,8 @@ "supported": [ "12.3", "11.8", - "11.4" + "11.4", + "10.11" ] } }, From 7d4553c7ad34218099d075346e94dfcc475a3487 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:28:38 +0100 Subject: [PATCH 20/36] fix(lint): address review findings - Composable images (`stack`, `type: composable:*`) are checked the same way for Fixed config as for Flex, since Platform.sh supports them too. An app with `name` and `stack` but no `type` no longer fails with "type cannot be empty". The now-unused style parameter is removed from CheckTypes and runChecks. - An explicit path argument is linted as given. Only the default (no argument) ascends to the enclosing Git repository root. - registry.Parsed keeps its parse error for later calls instead of returning an empty registry with a nil error. - The --format and --stdin options are keyed by bare name in the command list, like other options. - The name length message says "no longer than", matching the check. Co-Authored-By: Claude Opus 5.5 --- commands/lint.go | 13 ++++++++++--- commands/list_models.go | 6 +++--- internal/lint/fixed.go | 2 +- internal/lint/fixed_test.go | 8 ++++++++ internal/lint/linter.go | 9 ++++----- internal/lint/names.go | 2 +- internal/lint/names_test.go | 4 ++-- internal/lint/registry/registry.go | 12 +++++++----- internal/lint/types.go | 7 +++---- internal/lint/types_test.go | 2 +- 10 files changed, 40 insertions(+), 25 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index 84a214d27..b49f59914 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "path/filepath" "strings" "unicode" @@ -72,11 +73,17 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res return result, format, err } - path := "." + // An explicit path is linted as given; by default the enclosing repository root is used. + var root string if len(args) == 1 { - path = args[0] + abs, err := filepath.Abs(args[0]) + if err != nil { + return nil, format, err + } + root = abs + } else { + root = lint.FindProjectRoot(".") } - root := lint.FindProjectRoot(path) if format == "text" { fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root)) } diff --git a/commands/list_models.go b/commands/list_models.go index 108a42122..0ecca98f9 100644 --- a/commands/list_models.go +++ b/commands/list_models.go @@ -138,13 +138,13 @@ func innerAppConfigValidateCommand(cnf *config.Config) Command { Name: "path", IsRequired: false, IsArray: false, - Description: "The path to a project directory to validate (default: the current directory)", + Description: "The project directory to validate (default: the enclosing Git repository root)", }, }, )), Options: orderedmap.New[string, Option](orderedmap.WithInitialData[string, Option]( orderedmap.Pair[string, Option]{ - Key: "--format", + Key: "format", Value: Option{ Name: "--format", AcceptValue: true, @@ -154,7 +154,7 @@ func innerAppConfigValidateCommand(cnf *config.Config) Command { }, }, orderedmap.Pair[string, Option]{ - Key: "--stdin", + Key: "stdin", Value: Option{ Name: "--stdin", Description: "Read merged Flex configuration from standard input", diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index 8754ebb7b..17bf51980 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -68,7 +68,7 @@ func lintFixed(dir string, cfg fixedNames) (*Result, error) { return nil, err } - checks, err := runChecks(decoded, StyleFixed) + checks, err := runChecks(decoded) if err != nil { return nil, err } diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index c862cd053..9fae501c2 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -30,6 +30,14 @@ relationships: }, wantNoErr: true, }, + { + name: "composable image with stack and no type", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +stack: ["php@8.3"]`, + }, + wantNoErr: true, + }, { name: "invalid type and bad upstream", files: map[string]string{ diff --git a/internal/lint/linter.go b/internal/lint/linter.go index 19ff8dd44..5119f81d8 100644 --- a/internal/lint/linter.go +++ b/internal/lint/linter.go @@ -31,12 +31,11 @@ func CheckContent(content string) (*Result, error) { return nil, err } - return runChecks(cfg, StyleFlex) + return runChecks(cfg) } -// runChecks runs the semantic checks over a decoded config, adapting some -// checks to the configuration style. -func runChecks(cfg *Config, style Style) (*Result, error) { +// runChecks runs the semantic checks over a decoded config. +func runChecks(cfg *Config) (*Result, error) { reg, err := registry.Parsed() if err != nil { return nil, fmt.Errorf("failed to load registry: %w", err) @@ -45,7 +44,7 @@ func runChecks(cfg *Config, style Style) (*Result, error) { return Combine( CheckRelationships(cfg), CheckNames(cfg), - CheckTypes(cfg, reg, style), + CheckTypes(cfg, reg), CheckScripts(cfg), CheckWebConfig(cfg), CheckDependencies(cfg), diff --git a/internal/lint/names.go b/internal/lint/names.go index 1e93d30cd..b99590efd 100644 --- a/internal/lint/names.go +++ b/internal/lint/names.go @@ -45,7 +45,7 @@ func validateServiceName(value, nameType string) string { } if len(value) > maxServiceNameLength { - return fmt.Sprintf("%q is not a valid %s name, it should be shorter than %d characters.", + return fmt.Sprintf("%q is not a valid %s name, it should be no longer than %d characters.", value, nameType, maxServiceNameLength) } diff --git a/internal/lint/names_test.go b/internal/lint/names_test.go index db927273a..295582ccb 100644 --- a/internal/lint/names_test.go +++ b/internal/lint/names_test.go @@ -126,8 +126,8 @@ services: averylongservicenamethatexceedsthirtytwocharacters: type: valkey:8.0`, expectErrorValues: []string{ - `applications.averylongapplicationnamethatexceedsthirtytwocharacters: "averylongapplicationnamethatexceedsthirtytwocharacters" is not a valid application name, it should be shorter than 32 characters.`, //nolint:lll - `services.averylongservicenamethatexceedsthirtytwocharacters: "averylongservicenamethatexceedsthirtytwocharacters" is not a valid service name, it should be shorter than 32 characters.`, //nolint:lll + `applications.averylongapplicationnamethatexceedsthirtytwocharacters: "averylongapplicationnamethatexceedsthirtytwocharacters" is not a valid application name, it should be no longer than 32 characters.`, //nolint:lll + `services.averylongservicenamethatexceedsthirtytwocharacters: "averylongservicenamethatexceedsthirtytwocharacters" is not a valid service name, it should be no longer than 32 characters.`, //nolint:lll }, }, } diff --git a/internal/lint/registry/registry.go b/internal/lint/registry/registry.go index 7e775797c..8716d217a 100644 --- a/internal/lint/registry/registry.go +++ b/internal/lint/registry/registry.go @@ -15,8 +15,11 @@ const ChannelStable = "25.11" //go:embed registry.json var Data []byte -var parsedRegistry Registry -var parsedOnce sync.Once +var ( + parsedRegistry Registry + parsedErr error + parsedOnce sync.Once +) func Parse(b []byte) (reg Registry, err error) { err = json.Unmarshal(b, ®) @@ -27,11 +30,10 @@ func Parse(b []byte) (reg Registry, err error) { } func Parsed() (Registry, error) { - var err error parsedOnce.Do(func() { - parsedRegistry, err = Parse(Data) + parsedRegistry, parsedErr = Parse(Data) }) - return parsedRegistry, err + return parsedRegistry, parsedErr } // clean reduces irrelevant information in a registry, for the purposes of this project. diff --git a/internal/lint/types.go b/internal/lint/types.go index 5f749e8f1..c8feb0591 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -9,15 +9,14 @@ import ( ) // CheckTypes checks that application, service and worker types are supported images and versions. -func CheckTypes(cfg *Config, reg registry.Registry, style Style) *Result { +func CheckTypes(cfg *Config, reg registry.Registry) *Result { result := &Result{} check := func(t string, runtime bool) error { return checkType(t, reg, runtime) } for appName := range cfg.Applications { app := cfg.Applications[appName] - // Composable images and the 'stack' key are a Flex-only feature. - if style == StyleFlex && app.Type == "" && !isStackEmpty(app.Stack) { + if app.Type == "" && !isStackEmpty(app.Stack) { // For backwards compatibility, we allow 'stack' to be specified without 'type'. result.AddWarning("applications."+appName, "'type' should be specified (as a composable image) when using 'stack'") @@ -26,7 +25,7 @@ func CheckTypes(cfg *Config, reg registry.Registry, style Style) *Result { if err := check(app.Type, true); err != nil { result.AddError("applications."+appName+".type", err.Error()) } - if style == StyleFlex && strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { + if strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") } } diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 3bcf3953f..1afb4e00b 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -106,7 +106,7 @@ applications: if err != nil { assert.FailNow(t, "decodeConfig failed", err) } - result := lint.CheckTypes(cfg, testRegistry, lint.StyleFlex) + result := lint.CheckTypes(cfg, testRegistry) if c.expectErrorMessage != "" { assert.True(t, result.HasErrors() || result.HasWarnings()) assert.Equal(t, c.expectErrorMessage, result.Error()) From 9678f317d771e2d11c292f1d90beff18dfcdf653 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:33:20 +0100 Subject: [PATCH 21/36] fix(lint): accept type alongside stack in Fixed app config The Fixed application schema (copied from platformify) required exactly one of `type` or `stack` via `oneOf`, so the documented Platform.sh form of a composable image, `type: "composable:..."` with `stack`, failed validation, and the stack-without-type warning pointed users at an error. Use `anyOf` instead. The Fixed schemas are now maintained here, so `make lint-assets` no longer overwrites them from platformify. Also report a missing or non-directory path argument directly instead of "no configuration found". Co-Authored-By: Claude Opus 5.5 --- Makefile | 5 +---- commands/lint.go | 6 ++++++ internal/lint/fixed_test.go | 9 +++++++++ internal/lint/schema/platformsh.application.json | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index b68a1a2fa..100f1e089 100644 --- a/Makefile +++ b/Makefile @@ -144,16 +144,13 @@ lint-golangci: # Embedded lint assets, refreshed from upstream. # - registry.json: transformed from https://meta.upsun.com/images by gen.go. -# - upsun-config-schema.json and the platformsh.*.json schemas: from platformify. +# - upsun-config-schema.json: from platformify. The platformsh.*.json (Fixed) schemas are maintained here. PLATFORMIFY_SCHEMA_URL = https://raw.githubusercontent.com/platformsh/platformify/refs/heads/main/validator/schema .PHONY: lint-assets lint-assets: ## Refresh the embedded lint registry and schemas from upstream cd internal/lint/registry && go run gen.go curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json - curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.application.json -o internal/lint/schema/platformsh.application.json - curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.routes.json -o internal/lint/schema/platformsh.routes.json - curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.services.json -o internal/lint/schema/platformsh.services.json .PHONY: lint-assets-check lint-assets-check: lint-assets ## Fail if the embedded lint assets are stale diff --git a/commands/lint.go b/commands/lint.go index b49f59914..57bbc8ce7 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" "path/filepath" "strings" "unicode" @@ -80,6 +81,11 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res if err != nil { return nil, format, err } + if fi, err := os.Stat(abs); err != nil { + return nil, format, err + } else if !fi.IsDir() { + return nil, format, fmt.Errorf("not a directory: %s", args[0]) + } root = abs } else { root = lint.FindProjectRoot(".") diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index 9fae501c2..b1eb4d9bb 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -30,6 +30,15 @@ relationships: }, wantNoErr: true, }, + { + name: "composable image with type and stack", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "composable:25.11" +stack: ["php@8.3"]`, + }, + wantNoErr: true, + }, { name: "composable image with stack and no type", files: map[string]string{ diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index c9167ed21..518ff85b5 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -917,7 +917,7 @@ ] } }, - "oneOf": [ + "anyOf": [ { "required": [ "name", From ef8e33aa1a8ca3b6872cb53e5f2c20628c4d21d1 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:37:37 +0100 Subject: [PATCH 22/36] feat(lint): warn when stack is set with a non-composable type The Fixed schema now allows `type` and `stack` together, so a single-runtime type such as `php:8.3` with a `stack` key would lint clean. Warn instead, in both styles, since `stack` only applies to the composable image. Co-Authored-By: Claude Opus 5.5 --- internal/lint/types.go | 5 ++++- internal/lint/types_test.go | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/lint/types.go b/internal/lint/types.go index c8feb0591..ecad66809 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -25,8 +25,11 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { if err := check(app.Type, true); err != nil { result.AddError("applications."+appName+".type", err.Error()) } - if strings.HasPrefix(app.Type, "composable") && isStackEmpty(app.Stack) { + isComposable := strings.HasPrefix(app.Type, "composable") + if isComposable && isStackEmpty(app.Stack) { result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") + } else if !isComposable && !isStackEmpty(app.Stack) { + result.AddWarning("applications."+appName+".stack", "'stack' is only used with a composable image type") } } for appName := range cfg.Applications { diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 1afb4e00b..03667cdf4 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -98,6 +98,16 @@ applications: expectErrorMessage: `linter warnings: - applications.foo: 'stack' should be specified when using a composable image`, }, + { + name: "stack_with_non_composable_type", + content: ` +applications: + foo: + type: php:8.4 + stack: ["php@8.4"]`, + expectErrorMessage: `linter warnings: + - applications.foo.stack: 'stack' is only used with a composable image type`, + }, } for _, c := range cases { From d5fa7eea3e3d59cb0ccffe1494f93a4cde37e13c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:49:23 +0100 Subject: [PATCH 23/36] feat(lint): allow retired image versions with a warning Retired versions (e.g. golang:1.25 once 1.27 is out) were omitted from the embedded registry, so configs using them failed linting. They are now listed under `retired` and reported as a warning naming the supported versions. Decommissioned versions are still errors. The `lint-assets` CI job is now non-blocking (`continue-on-error`), since upstream registry changes are unrelated to the pull request, and uses the same action versions as the other jobs. Also remove the unused VersionInfo.LatestVersion. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- internal/lint/registry/gen.go | 10 +- internal/lint/registry/model.go | 12 +- internal/lint/registry/model_test.go | 65 -------- internal/lint/registry/registry.json | 237 +++++++++++++++++++++++++++ internal/lint/types.go | 52 +++--- internal/lint/types_test.go | 19 +++ 7 files changed, 300 insertions(+), 101 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc1d61a99..9197051f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,15 +52,17 @@ jobs: # Checks that the embedded lint registry and schemas match upstream. # Fails when upstream publishes changes; run `make lint-assets` to refresh. + # Non-blocking, since upstream changes are unrelated to the pull request. lint-assets: runs-on: ubuntu-latest + continue-on-error: true steps: - name: Check out repository code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod diff --git a/internal/lint/registry/gen.go b/internal/lint/registry/gen.go index 10d6f800e..c6da174c9 100644 --- a/internal/lint/registry/gen.go +++ b/internal/lint/registry/gen.go @@ -57,7 +57,7 @@ func run() error { reg := make(registry.Registry, len(images)) for typeName, img := range images { - var supported, legacy []string + var supported, legacy, retired []string for version, info := range img.Versions { switch info.Upsun.Status { case "supported": @@ -65,16 +65,20 @@ func run() error { case "deprecated": // Deprecated versions still deploy, so treat them as legacy (allowed). legacy = append(legacy, version) + case "retired": + // Retired versions are allowed with a warning. + retired = append(retired, version) } - // "retired" and "decommissioned" versions are omitted, so they fail linting. + // "decommissioned" versions are omitted, so they fail linting. } sortVersionsDescending(supported) sortVersionsDescending(legacy) + sortVersionsDescending(retired) reg[typeName] = registry.Image{ Name: img.Name, Type: typeName, IsRuntime: !img.Service, - Versions: registry.VersionInfo{Supported: supported, Legacy: legacy}, + Versions: registry.VersionInfo{Supported: supported, Legacy: legacy, Retired: retired}, } } diff --git a/internal/lint/registry/model.go b/internal/lint/registry/model.go index 8b10a3972..8d985970e 100644 --- a/internal/lint/registry/model.go +++ b/internal/lint/registry/model.go @@ -78,12 +78,13 @@ type BuildConfig struct { // Dependency version map under Docs. type Dependency map[string]string -// VersionInfo lists deprecated, supported, and optional legacy versions. +// VersionInfo lists deprecated, supported, and optional legacy and retired versions. type VersionInfo struct { Supported []string `json:"supported"` Deprecated []string `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` Legacy []string `json:"legacy,omitempty" yaml:"legacy,omitempty"` + Retired []string `json:"retired,omitempty" yaml:"retired,omitempty"` } // UnmarshalJSON handles both string and object formats for versions field. @@ -106,12 +107,3 @@ func (v *VersionInfo) UnmarshalJSON(data []byte) error { *v = VersionInfo(obj) return nil } - -// LatestVersion returns the most recent supported version. -// Returns empty string if no supported versions exist. -func (v VersionInfo) LatestVersion() string { - if len(v.Supported) == 0 { - return "" - } - return v.Supported[0] -} diff --git a/internal/lint/registry/model_test.go b/internal/lint/registry/model_test.go index b7e67feb2..435903290 100644 --- a/internal/lint/registry/model_test.go +++ b/internal/lint/registry/model_test.go @@ -50,68 +50,3 @@ func TestVersionInfo_UnmarshalJSON(t *testing.T) { }) } } - -func TestVersionInfo_LatestVersion(t *testing.T) { - cases := []struct { - name string - versions registry.VersionInfo - expected string - }{ - { - name: "returns_first_supported_version", - versions: registry.VersionInfo{ - Supported: []string{"8.4", "8.3", "8.2"}, - }, - expected: "8.4", - }, - { - name: "returns_single_version", - versions: registry.VersionInfo{ - Supported: []string{"22"}, - }, - expected: "22", - }, - { - name: "returns_empty_when_no_versions", - versions: registry.VersionInfo{ - Supported: []string{}, - }, - expected: "", - }, - { - name: "returns_empty_when_nil", - versions: registry.VersionInfo{ - Supported: nil, - }, - expected: "", - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - result := c.versions.LatestVersion() - assert.Equal(t, c.expected, result) - }) - } -} - -func TestVersionInfo_LatestVersion_WithRealRegistry(t *testing.T) { - reg, err := registry.Parsed() - assert.NoError(t, err) - assert.NotEmpty(t, reg) - - // Test with real registry data. - if img, ok := reg["php"]; ok { - latest := img.Versions.LatestVersion() - assert.NotEmpty(t, latest, "PHP should have a latest version") - assert.Contains(t, img.Versions.Supported, latest, "Latest version should be in supported list") - if len(img.Versions.Supported) > 0 { - assert.Equal(t, img.Versions.Supported[0], latest, "Latest version should be first in supported list") - } - } - - if img, ok := reg["postgresql"]; ok { - latest := img.Versions.LatestVersion() - assert.NotEmpty(t, latest, "PostgreSQL should have a latest version") - } -} diff --git a/internal/lint/registry/registry.json b/internal/lint/registry/registry.json index d46fe5363..660f8cb1c 100644 --- a/internal/lint/registry/registry.json +++ b/internal/lint/registry/registry.json @@ -7,6 +7,16 @@ "supported": [ "120", "113" + ], + "retired": [ + "95", + "91", + "86", + "84", + "83", + "81", + "80", + "73" ] } }, @@ -34,6 +44,15 @@ "supported": [ "10.0", "8.0" + ], + "retired": [ + "7.0", + "6.0", + "5.0", + "3.1", + "2.2", + "2.1", + "2.0" ] } }, @@ -44,6 +63,15 @@ "versions": { "supported": [ "7.10" + ], + "retired": [ + "7.9", + "7.7", + "7.6", + "7.5", + "7.2", + "6.8", + "6.5" ] } }, @@ -55,6 +83,9 @@ "supported": [ "9.3", "8.19" + ], + "retired": [ + "7.17" ] } }, @@ -69,6 +100,15 @@ "legacy": [ "1.19", "1.18" + ], + "retired": [ + "1.15", + "1.14", + "1.13", + "1.12", + "1.11", + "1.10", + "1.9" ] } }, @@ -80,6 +120,24 @@ "supported": [ "1.27", "1.26" + ], + "retired": [ + "1.25", + "1.24", + "1.23", + "1.22", + "1.21", + "1.20", + "1.19", + "1.18", + "1.17", + "1.16", + "1.15", + "1.14", + "1.13", + "1.12", + "1.11", + "1.10" ] } }, @@ -102,6 +160,14 @@ "3", "2.7", "2.3" + ], + "retired": [ + "2.2", + "2.0", + "1.8", + "1.7", + "1.3", + "1.2" ] } }, @@ -114,6 +180,15 @@ "25", "21", "17" + ], + "retired": [ + "19", + "18", + "14", + "13", + "12", + "11", + "8" ] } }, @@ -127,6 +202,17 @@ "4.1", "3.7", "3.6" + ], + "retired": [ + "3.4", + "3.2", + "2.7", + "2.6", + "2.5", + "2.4", + "2.3", + "2.2", + "2.1" ] } }, @@ -140,6 +226,18 @@ "11.8", "11.4", "10.11" + ], + "retired": [ + "11.2", + "11.0", + "10.6", + "10.5", + "10.4", + "10.3", + "10.2", + "10.1", + "10.0", + "5.5" ] } }, @@ -150,6 +248,10 @@ "versions": { "supported": [ "1.6" + ], + "retired": [ + "1.5", + "1.4" ] } }, @@ -170,6 +272,12 @@ "versions": { "supported": [ "4.0" + ], + "retired": [ + "3.6", + "3.4", + "3.2", + "3.0" ] } }, @@ -180,6 +288,13 @@ "versions": { "supported": [ "7.0" + ], + "retired": [ + "6.0", + "5.0", + "4.4", + "4.2", + "4.0" ] } }, @@ -193,6 +308,18 @@ "11.8", "11.4", "10.11" + ], + "retired": [ + "11.2", + "11.0", + "10.6", + "10.5", + "10.4", + "10.3", + "10.2", + "10.1", + "10.0", + "5.5" ] } }, @@ -220,6 +347,31 @@ ], "legacy": [ "22" + ], + "retired": [ + "20", + "18", + "16", + "15", + "14", + "12", + "10", + "8.9", + "8", + "6.10", + "6.9", + "6.8", + "6.6", + "6.3", + "6.2", + "6", + "4.7", + "4.6", + "4.5", + "4.4", + "4.3", + "4.2", + "4" ] } }, @@ -233,6 +385,11 @@ ], "legacy": [ "2" + ], + "retired": [ + "1.2", + "1.1", + "1" ] } }, @@ -243,6 +400,10 @@ "versions": { "supported": [ "8.4" + ], + "retired": [ + "8.0", + "5.7" ] } }, @@ -258,6 +419,18 @@ "legacy": [ "8.3", "8.2" + ], + "retired": [ + "8.1", + "8.0", + "7.4", + "7.3", + "7.2", + "7.1", + "7.0", + "5.6", + "5.5", + "5.4" ] } }, @@ -272,6 +445,15 @@ "16", "15", "14" + ], + "retired": [ + "13", + "12", + "11", + "10", + "9.6", + "9.5", + "9.3" ] } }, @@ -288,6 +470,14 @@ "3.12", "3.11", "3.10" + ], + "retired": [ + "3.9", + "3.8", + "3.7", + "3.6", + "3.5", + "2.7" ] } }, @@ -298,6 +488,20 @@ "versions": { "supported": [ "4.3" + ], + "retired": [ + "4.2", + "4.1", + "4.0", + "3.13", + "3.12", + "3.11", + "3.10", + "3.9", + "3.8", + "3.7", + "3.6", + "3.5" ] } }, @@ -313,6 +517,11 @@ ], "legacy": [ "6.2" + ], + "retired": [ + "7.0", + "6.0", + "5.0" ] } }, @@ -325,6 +534,16 @@ "4.0", "3.4", "3.3" + ], + "retired": [ + "3.2", + "3.1", + "3.0", + "2.7", + "2.6", + "2.5", + "2.4", + "2.3" ] } }, @@ -350,6 +569,17 @@ "9.4", "9.2", "9.1" + ], + "retired": [ + "9.0", + "8.11", + "8.0", + "7.7", + "7.6", + "6.6", + "6.3", + "4.10", + "3.6" ] } }, @@ -373,6 +603,13 @@ "supported": [ "9.0", "6.0" + ], + "retired": [ + "7.6", + "7.3", + "7.2", + "7.1", + "6.3" ] } }, diff --git a/internal/lint/types.go b/internal/lint/types.go index ecad66809..3501e5b7d 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -12,7 +12,15 @@ import ( func CheckTypes(cfg *Config, reg registry.Registry) *Result { result := &Result{} - check := func(t string, runtime bool) error { return checkType(t, reg, runtime) } + // check reports an error, or a warning for a retired version, at path. + check := func(path, t string, runtime bool) { + warning, err := checkType(t, reg, runtime) + if err != nil { + result.AddError(path, err.Error()) + } else if warning != "" { + result.AddWarning(path, warning) + } + } for appName := range cfg.Applications { app := cfg.Applications[appName] @@ -22,9 +30,7 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { "'type' should be specified (as a composable image) when using 'stack'") continue } - if err := check(app.Type, true); err != nil { - result.AddError("applications."+appName+".type", err.Error()) - } + check("applications."+appName+".type", app.Type, true) isComposable := strings.HasPrefix(app.Type, "composable") if isComposable && isStackEmpty(app.Stack) { result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") @@ -36,24 +42,22 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { app := cfg.Applications[appName] for workerName, w := range app.Workers { if w.Type != "" { - if err := check(w.Type, true); err != nil { - result.AddError("applications."+appName+".workers."+workerName+".type", err.Error()) - } + check("applications."+appName+".workers."+workerName+".type", w.Type, true) } } } for serviceName, service := range cfg.Services { - if err := check(service.Type, false); err != nil { - result.AddError("services."+serviceName+".type", err.Error()) - } + check("services."+serviceName+".type", service.Type, false) } return result } -func checkType(t string, reg registry.Registry, runtime bool) error { +// 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) { if t == "" { - return fmt.Errorf("type cannot be empty") + return "", fmt.Errorf("type cannot be empty") } parts := strings.SplitN(t, ":", 2) @@ -65,27 +69,33 @@ func checkType(t string, reg registry.Registry, runtime bool) error { if img, ok := reg[imageType]; ok { if img.IsRuntime && !runtime { - return fmt.Errorf("type '%s' is a runtime type, not a service type", imageType) + return "", fmt.Errorf("type '%s' is a runtime type, not a service type", imageType) } else if !img.IsRuntime && runtime { - return fmt.Errorf("type '%s' is a service type, not a runtime type", imageType) + return "", fmt.Errorf("type '%s' is a service type, not a runtime type", imageType) + } + + supported := strings.Join(img.Versions.Supported, ", ") + if slices.Contains(img.Versions.Retired, version) { + return fmt.Sprintf("version '%s' of type '%s' is retired; use one of: %s", + version, imageType, supported), nil } // Allow supported or legacy versions, but only mention supported ones in the error. - allVersions := append(img.Versions.Supported, img.Versions.Legacy...) //nolint:gocritic + allVersions := slices.Concat(img.Versions.Supported, img.Versions.Legacy, img.Versions.Retired) if !slices.Contains(allVersions, version) { if hasMajorVersion(allVersions, version) { - return fmt.Errorf( + return "", fmt.Errorf( "version '%s' is not precise enough for type '%s'; it must be exactly one of: %s", - version, imageType, strings.Join(img.Versions.Supported, ", ")) + version, imageType, supported) } - return fmt.Errorf( + return "", fmt.Errorf( "version '%s' is not supported for type '%s'; it must be exactly one of: %s", - version, imageType, strings.Join(img.Versions.Supported, ", ")) + version, imageType, supported) } - return nil + return "", nil } - return fmt.Errorf("type not found: '%s'; it must be one of: %s "+ + return "", fmt.Errorf("type not found: '%s'; it must be one of: %s "+ "(check the Registry for supported types, or make an application using a composable image)", imageType, strings.Join(reg.AllTypes(runtime), ", ")) } diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 03667cdf4..4168f26ed 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -126,3 +126,22 @@ applications: }) } } + +func TestCheckTypes_RetiredVersion(t *testing.T) { + reg := registry.Registry{ + "golang": {Type: "golang", IsRuntime: true, Versions: registry.VersionInfo{ + Supported: []string{"1.27", "1.26"}, + Retired: []string{"1.25"}, + }}, + } + cfg, err := lint.DecodeConfig(` +applications: + foo: + type: golang:1.25`) + require.NoError(t, err) + + result := lint.CheckTypes(cfg, reg) + assert.False(t, result.HasErrors()) + assert.Equal(t, `linter warnings: + - applications.foo.type: version '1.25' of type 'golang' is retired; use one of: 1.27, 1.26`, result.Error()) +} From 5a3d9efcdb0af482c6b1452f7ff7c0da81af7363 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 24 Sep 2026 12:53:38 +0100 Subject: [PATCH 24/36] fix(lint): omit the version list when a type has no supported versions If every version of an image is retired upstream, the retired warning and the version errors ended with an empty "use one of:" list. Only suggest versions when there are some. Co-Authored-By: Claude Opus 5.5 --- internal/lint/types.go | 21 ++++++++++++--------- internal/lint/types_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/internal/lint/types.go b/internal/lint/types.go index 3501e5b7d..b5d218624 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -74,23 +74,26 @@ func checkType(t string, reg registry.Registry, runtime bool) (string, error) { return "", fmt.Errorf("type '%s' is a service type, not a runtime type", imageType) } - supported := strings.Join(img.Versions.Supported, ", ") + // Suggest supported versions, if there are any. + var useOneOf, mustBeOneOf string + if len(img.Versions.Supported) > 0 { + supported := strings.Join(img.Versions.Supported, ", ") + useOneOf = "; use one of: " + supported + mustBeOneOf = "; it must be exactly one of: " + supported + } if slices.Contains(img.Versions.Retired, version) { - return fmt.Sprintf("version '%s' of type '%s' is retired; use one of: %s", - version, imageType, supported), nil + return fmt.Sprintf("version '%s' of type '%s' is retired%s", version, imageType, useOneOf), nil } // Allow supported or legacy versions, but only mention supported ones in the error. allVersions := slices.Concat(img.Versions.Supported, img.Versions.Legacy, img.Versions.Retired) if !slices.Contains(allVersions, version) { if hasMajorVersion(allVersions, version) { - return "", fmt.Errorf( - "version '%s' is not precise enough for type '%s'; it must be exactly one of: %s", - version, imageType, supported) + return "", fmt.Errorf("version '%s' is not precise enough for type '%s'%s", + version, imageType, mustBeOneOf) } - return "", fmt.Errorf( - "version '%s' is not supported for type '%s'; it must be exactly one of: %s", - version, imageType, supported) + return "", fmt.Errorf("version '%s' is not supported for type '%s'%s", + version, imageType, mustBeOneOf) } return "", nil } diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 4168f26ed..41a0a04f8 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -145,3 +145,27 @@ applications: assert.Equal(t, `linter warnings: - applications.foo.type: version '1.25' of type 'golang' is retired; use one of: 1.27, 1.26`, result.Error()) } + +func TestCheckTypes_NoSupportedVersions(t *testing.T) { + reg := registry.Registry{ + "elasticsearch": {Type: "elasticsearch", Versions: registry.VersionInfo{Retired: []string{"7.2"}}}, + } + cfg, err := lint.DecodeConfig(` +applications: + app: + type: golang:1.26 +services: + retired: + type: elasticsearch:7.2 + unknown: + type: elasticsearch:9.0`) + require.NoError(t, err) + + result := lint.CheckTypes(cfg, reg) + assert.Contains(t, result.Warnings, lint.Issue{ + Path: "services.retired.type", Message: "version '7.2' of type 'elasticsearch' is retired", + }) + assert.Contains(t, result.Errors, lint.Issue{ + Path: "services.unknown.type", Message: "version '9.0' is not supported for type 'elasticsearch'", + }) +} From 645c99b63d7c42319c663ba876c97363ad56ef95 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:06:47 +0100 Subject: [PATCH 25/36] style(lint): normalize the Flex schema's JSON formatting The schema is now maintained in this repository, so it is formatted consistently to keep later edits reviewable. Co-Authored-By: Claude Opus 5.5 --- internal/lint/schema/upsun-config-schema.json | 183 +++++++++++++----- 1 file changed, 139 insertions(+), 44 deletions(-) diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index 3e8557eba..48eef0c1b 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -75,9 +75,15 @@ "root": null } }, - "resources": {"$ref": "#/definitions/deprecated/resources"}, - "relationships": {"$ref": "#/definitions/relationships"}, - "mounts": {"$ref": "#/definitions/mounts"}, + "resources": { + "$ref": "#/definitions/deprecated/resources" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, + "mounts": { + "$ref": "#/definitions/mounts" + }, "web": { "type": "object", "title": "How the web application is served", @@ -97,8 +103,12 @@ "title": "The timezone of the application", "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified." }, - "mounts": {"$ref": "#/definitions/mounts"}, - "relationships": {"$ref": "#/definitions/relationships"}, + "mounts": { + "$ref": "#/definitions/mounts" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, "access": { "type": "object", "additionalProperties": { @@ -107,8 +117,12 @@ "title": "Access information, a mapping between access type and roles", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/access/" }, - "size": {"$ref": "#/definitions/deprecated/size"}, - "resources": {"$ref": "#/definitions/deprecated/resources"}, + "size": { + "$ref": "#/definitions/deprecated/size" + }, + "resources": { + "$ref": "#/definitions/deprecated/resources" + }, "locations": { "type": "object", "additionalProperties": { @@ -121,7 +135,10 @@ "default": "public" }, "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets", "description": "How long static assets are cached. The default means no caching. Setting it to a value enables the `Cache-Control` and `Expires` headers. Times can be suffixed with `ms` = milliseconds, `s` = seconds, `m` = minutes, `h` = hours, `d` = days, `w` = weeks, `M` = months/30d, or `y` = years/365d. If a `Cache-Control` appears on the `headers` configuration, `expires`, if set, will be ignored. Thus, make sure to set the `Cache-Control`’s `max-age` value when specifying a the header.", "default": -1 @@ -169,7 +186,10 @@ "type": "object", "properties": { "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets", "description": "How long static assets are cached. The default means no caching. Setting it to a value enables the `Cache-Control` and `Expires` headers. Times can be suffixed with `ms` = milliseconds, `s` = seconds, `m` = minutes, `h` = hours, `d` = days, `w` = weeks, `M` = months/30d, or `y` = years/365d. If a `Cache-Control` appears on the `headers` configuration, `expires`, if set, will be ignored. Thus, make sure to set the `Cache-Control`’s `max-age` value when specifying a the header.", "default": -1 @@ -312,7 +332,10 @@ "deprecationMessage": "Deprecated" }, "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets", "deprecationMessage": "Deprecated" }, @@ -333,7 +356,9 @@ "additionalProperties": { "type": "object", "properties": { - "firewall": {"$ref": "#/definitions/firewall"}, + "firewall": { + "$ref": "#/definitions/firewall" + }, "variables": { "type": "object", "additionalProperties": { @@ -387,7 +412,9 @@ "description": "Directories that are writable even after the app is built. Allocated disk for mounts is defined with a separate resource configuration call using `upsun resources:set`. \nContains a dictionary of mounts: \nhttps://docs.upsun.com/anchors/app/reference/mounts/", "default": {} }, - "relationships": {"$ref": "#/definitions/relationships"}, + "relationships": { + "$ref": "#/definitions/relationships" + }, "access": { "type": "object", "additionalProperties": { @@ -399,9 +426,15 @@ "ssh": "contributor" } }, - "size": {"$ref": "#/definitions/deprecated/size"}, - "resources": {"$ref": "#/definitions/deprecated/resources"}, - "container_profile": {"$ref": "#/definitions/container_profile"}, + "size": { + "$ref": "#/definitions/deprecated/size" + }, + "resources": { + "$ref": "#/definitions/deprecated/resources" + }, + "container_profile": { + "$ref": "#/definitions/container_profile" + }, "commands": { "type": "object", "properties": { @@ -459,7 +492,9 @@ "description": "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. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/variables/", "default": {} }, - "firewall": {"$ref": "#/definitions/firewall"}, + "firewall": { + "$ref": "#/definitions/firewall" + }, "build": { "type": "object", "properties": { @@ -634,7 +669,9 @@ } } }, - "container_profile": {"$ref": "#/definitions/container_profile"}, + "container_profile": { + "$ref": "#/definitions/container_profile" + }, "additional_hosts": { "type": "object", "additionalProperties": { @@ -768,13 +805,27 @@ "title": "Route identifier", "description": "More information: \nhttps://docs.upsun.com/anchors/routes/identifiers/", "properties": { - "primary": {"$ref": "#/definitions/routes/primary"}, - "id": {"$ref": "#/definitions/routes/id"}, - "attributes": {"$ref": "#/definitions/routes/attributes"}, - "type": {"$ref": "#/definitions/routes/route_type"}, - "redirects": {"$ref": "#/definitions/routes/redirects"}, - "tls": {"$ref": "#/definitions/routes/tls"}, - "to": {"$ref": "#/definitions/routes/to"} + "primary": { + "$ref": "#/definitions/routes/primary" + }, + "id": { + "$ref": "#/definitions/routes/id" + }, + "attributes": { + "$ref": "#/definitions/routes/attributes" + }, + "type": { + "$ref": "#/definitions/routes/route_type" + }, + "redirects": { + "$ref": "#/definitions/routes/redirects" + }, + "tls": { + "$ref": "#/definitions/routes/tls" + }, + "to": { + "$ref": "#/definitions/routes/to" + } }, "required": [ "type", @@ -785,15 +836,33 @@ { "type": "object", "properties": { - "primary": {"$ref": "#/definitions/routes/primary"}, - "id": {"$ref": "#/definitions/routes/id"}, - "attributes": {"$ref": "#/definitions/routes/attributes"}, - "type": {"$ref": "#/definitions/routes/route_type"}, - "redirects": {"$ref": "#/definitions/routes/redirects"}, - "tls": {"$ref": "#/definitions/routes/tls"}, - "cache": {"$ref": "#/definitions/routes/cache"}, - "ssi": {"$ref": "#/definitions/routes/ssi"}, - "upstream": {"$ref": "#/definitions/routes/upstream"} + "primary": { + "$ref": "#/definitions/routes/primary" + }, + "id": { + "$ref": "#/definitions/routes/id" + }, + "attributes": { + "$ref": "#/definitions/routes/attributes" + }, + "type": { + "$ref": "#/definitions/routes/route_type" + }, + "redirects": { + "$ref": "#/definitions/routes/redirects" + }, + "tls": { + "$ref": "#/definitions/routes/tls" + }, + "cache": { + "$ref": "#/definitions/routes/cache" + }, + "ssi": { + "$ref": "#/definitions/routes/ssi" + }, + "upstream": { + "$ref": "#/definitions/routes/upstream" + } }, "required": [ "type", @@ -820,7 +889,9 @@ "description": "One of the available services in the format `type:version`. \nMore information: \nhttps://docs.upsun.com/anchors/services/available/", "default": {} }, - "size": {"$ref": "#/definitions/deprecated/size"}, + "size": { + "$ref": "#/definitions/deprecated/size" + }, "access": { "type": "object", "title": "The configuration of the service", @@ -833,10 +904,18 @@ "description": "Some services have additional specific configuration options that can be defined here, such as specific endpoints. \nSee the given service page for more details: \nhttps://docs.upsun.com/anchors/services/available/.", "default": {} }, - "relationships": {"$ref": "#/definitions/relationships"}, - "firewall": {"$ref": "#/definitions/firewall"}, - "resources": {"$ref": "#/definitions/deprecated/resources"}, - "container_profile": {"$ref": "#/definitions/container_profile"} + "relationships": { + "$ref": "#/definitions/relationships" + }, + "firewall": { + "$ref": "#/definitions/firewall" + }, + "resources": { + "$ref": "#/definitions/deprecated/resources" + }, + "container_profile": { + "$ref": "#/definitions/container_profile" + } }, "required": [ "type" @@ -915,7 +994,10 @@ "description": "Defines redirects for partial routes. For definition and options, see the redirect rules: \nhttps://docs.upsun.com/anchors/routes/redirects/", "properties": { "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "The duration the redirect is cached", "description": "Examples of valid values include 3600s, 1d, 2w, 3m. \nTo disable caching for all your redirects, set expires to 0. You can also disable caching on a specific redirect: \nhttps://docs.upsun.com/anchors/routes/redirects/caching/disable/", "default": -1 @@ -957,7 +1039,10 @@ "default": 302 }, "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "The amount of time, in seconds, to cache the redirects", "description": "The duration the redirect is cached for. \nMore information: \nhttps://docs.upsun.com/anchors/routes/redirects/caching/manage/", "default": null @@ -1013,7 +1098,10 @@ } }, "min_version": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "enum": [ "TLSv1.0", "TLSv1.1", @@ -1026,8 +1114,15 @@ "default": null }, "client_authentication": { - "type": ["string", "null"], - "enum": ["request", "require", null], + "type": [ + "string", + "null" + ], + "enum": [ + "request", + "require", + null + ], "description": "The type of client authentication to request.", "default": null }, @@ -1241,7 +1336,7 @@ "service": { "type": "string", "title": "The name of the service that the volume will be mounted from", - "description":" Must be a service in `services.yaml` of type `network-storage`. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/mounts/" + "description": " Must be a service in `services.yaml` of type `network-storage`. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/mounts/" } }, "required": [ From acd0dfb3b09dec2a1193cab42871dfd653ae8c94 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:10:35 +0100 Subject: [PATCH 26/36] style(lint): normalize the Fixed application schema's JSON formatting Co-Authored-By: Claude Opus 5.5 --- internal/lint/schema/platformsh.application.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index 518ff85b5..831c22272 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -458,7 +458,10 @@ "default": null }, "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets.", "default": -1 }, @@ -501,7 +504,10 @@ "type": "object", "properties": { "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets." }, "passthru": { @@ -621,7 +627,10 @@ "title": "Blacklisted entries." }, "expires": { - "type": ["integer", "string"], + "type": [ + "integer", + "string" + ], "title": "Amount of time to cache static assets." }, "move_to_root": { From 9cebf68e2bfe59425a653bda03a74b3a6a27fea7 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:16:07 +0100 Subject: [PATCH 27/36] feat(lint): support tasks and workload authorizations Tasks and the `authorizations` key are new Upsun features that neither upstream schema (platformify or meta.upsun.com) describes, so valid configuration was rejected. The rules follow the platform's own config validator. - Tasks (Flex only): the top-level `tasks` section is merged and schema-checked (type, base, stack, source, hooks, run, relationships, mounts, variables, authorizations and the other shared container keys; run.timeout is at most 86400). Semantic checks cover task names, image types, script syntax of run.command and hooks, `base` used alone, a required run command, relationships from tasks, and task mounts (a storage mount must name an application). Task names share a namespace with applications and services, and a task cannot be a relationship target. - Authorizations on applications, their web and worker containers, and tasks, in both Flex and Fixed config: `task` allows only `operate` and needs a resource naming an existing task; `env` allows only `view`. - The `local` mount source (an alias of `instance`) is accepted. - Unknown top-level keys in Flex files are rejected, as the platform does, except `.`-prefixed ones. - Flex issues are prefixed with the `.upsun/*.yaml` file that defines the application, service, route or task, as Fixed issues already are. - Linked Git worktrees and separate clones below the project root are skipped (submodules are kept), so they no longer produce stray config warnings or duplicate Fixed applications. - A non-PHP application that only serves static files (no location passes requests through) is no longer warned about a missing start command. The Flex schema is now maintained in this repository, like the Fixed schemas, so `make lint-assets` only refreshes the image registry. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- Makefile | 14 +- internal/lint/config_schema.go | 66 ++++- internal/lint/fixed.go | 5 +- internal/lint/fixed_test.go | 25 ++ internal/lint/linter.go | 1 + internal/lint/linter_test.go | 136 +++++++++ internal/lint/merge.go | 70 +++-- internal/lint/merge_test.go | 55 +++- internal/lint/names.go | 7 +- internal/lint/names_test.go | 14 + internal/lint/normalize.go | 58 +++- internal/lint/normalize_test.go | 75 +++++ internal/lint/relationships.go | 123 ++++---- internal/lint/relationships_test.go | 75 ++++- .../lint/schema/platformsh.application.json | 105 +++++++ internal/lint/schema/schema.go | 7 +- internal/lint/schema/upsun-config-schema.json | 265 +++++++++++++++++- internal/lint/scripts.go | 34 ++- internal/lint/scripts_test.go | 48 ++++ internal/lint/tasks.go | 92 ++++++ internal/lint/tasks_test.go | 213 ++++++++++++++ internal/lint/types.go | 25 +- internal/lint/types_test.go | 31 ++ internal/lint/yaml.go | 6 +- 25 files changed, 1419 insertions(+), 133 deletions(-) create mode 100644 internal/lint/tasks.go create mode 100644 internal/lint/tasks_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9197051f6..7e48df8b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: - name: Check goreleaser config run: make goreleaser-check - # Checks that the embedded lint registry and schemas match upstream. + # Checks that the embedded lint registry matches upstream. # Fails when upstream publishes changes; run `make lint-assets` to refresh. # Non-blocking, since upstream changes are unrelated to the pull request. lint-assets: diff --git a/Makefile b/Makefile index 100f1e089..36788a99f 100644 --- a/Makefile +++ b/Makefile @@ -142,19 +142,15 @@ lint-gomod: lint-golangci: golangci-lint run --timeout=2m -# Embedded lint assets, refreshed from upstream. -# - registry.json: transformed from https://meta.upsun.com/images by gen.go. -# - upsun-config-schema.json: from platformify. The platformsh.*.json (Fixed) schemas are maintained here. -PLATFORMIFY_SCHEMA_URL = https://raw.githubusercontent.com/platformsh/platformify/refs/heads/main/validator/schema - +# The embedded lint registry is transformed from https://meta.upsun.com/images by gen.go. +# The schemas in internal/lint/schema are maintained in this repository. .PHONY: lint-assets -lint-assets: ## Refresh the embedded lint registry and schemas from upstream +lint-assets: ## Refresh the embedded lint registry from upstream cd internal/lint/registry && go run gen.go - curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json .PHONY: lint-assets-check -lint-assets-check: lint-assets ## Fail if the embedded lint assets are stale - git diff --exit-code -- internal/lint/registry/registry.json internal/lint/schema +lint-assets-check: lint-assets ## Fail if the embedded lint registry is stale + git diff --exit-code -- internal/lint/registry/registry.json .goreleaser.vendor.yaml: check-vendor ## Generate the goreleaser vendor config cat .goreleaser.vendor.yaml.tpl | envsubst > .goreleaser.vendor.yaml diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index e20b91346..035a2162b 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -18,16 +18,17 @@ type Config struct { PostDeploy string `yaml:"post_deploy,omitempty"` } `yaml:"hooks,omitempty"` + Authorizations []Authorization `yaml:"authorizations,omitempty"` + Web struct { + Authorizations []Authorization `yaml:"authorizations,omitempty"` + Commands struct { Start string `yaml:"start,omitempty"` PostStart string `yaml:"post_start,omitempty"` } `yaml:"commands,omitempty"` - Locations map[string]struct { - Root string `yaml:"root,omitempty"` - Rules map[string]any `yaml:"rules,omitempty"` - } `yaml:"locations,omitempty"` + Locations map[string]WebLocation `yaml:"locations,omitempty"` } `yaml:"web,omitempty"` Relationships map[string]any `yaml:"relationships,omitempty"` @@ -40,7 +41,8 @@ type Config struct { } `yaml:"crons,omitempty"` Workers map[string]struct { - Type string `yaml:"type,omitempty"` + Type string `yaml:"type,omitempty"` + Authorizations []Authorization `yaml:"authorizations,omitempty"` Commands struct { PreStart string `yaml:"pre_start,omitempty"` @@ -61,6 +63,60 @@ type Config struct { Upstream string `yaml:"upstream,omitempty"` To string `yaml:"to,omitempty"` } `yaml:"routes,omitempty"` + + Tasks map[string]Task `yaml:"tasks,omitempty"` +} + +// WebLocation configures how requests under a web location are served. +type WebLocation struct { + Root string `yaml:"root,omitempty"` + Passthru any `yaml:"passthru,omitempty"` // A boolean or a URL path. + Rules map[string]any `yaml:"rules,omitempty"` +} + +// Task is an on-demand, run-to-completion workload (Flex only). +type Task struct { + Type string `yaml:"type,omitempty"` + Base string `yaml:"base,omitempty"` + Stack any `yaml:"stack,omitempty"` + + Run struct { + Command string `yaml:"command,omitempty"` + } `yaml:"run,omitempty"` + + Hooks struct { + Build string `yaml:"build,omitempty"` + Deploy string `yaml:"deploy,omitempty"` + } `yaml:"hooks,omitempty"` + + Relationships map[string]any `yaml:"relationships,omitempty"` + Mounts map[string]struct { + Source string `yaml:"source,omitempty"` + Service string `yaml:"service,omitempty"` + } `yaml:"mounts,omitempty"` + Authorizations []Authorization `yaml:"authorizations,omitempty"` + + // fields lists the keys set on the task, to check that "base" is used alone. + fields []string +} + +// UnmarshalYAML decodes a task and records which keys it sets. +func (t *Task) UnmarshalYAML(node *yaml.Node) error { + type plain Task + if err := node.Decode((*plain)(t)); err != nil { + return err + } + for i := 0; i+1 < len(node.Content); i += 2 { + t.fields = append(t.fields, node.Content[i].Value) + } + return nil +} + +// Authorization grants a workload access to the Upsun API at runtime. +type Authorization struct { + Type string `yaml:"type"` + Action string `yaml:"action"` + Resource string `yaml:"resource,omitempty"` } func DecodeConfig(content string) (*Config, error) { diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index 17bf51980..43e00fe2e 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -17,10 +17,11 @@ const ( keyApplications = "applications" keyServices = "services" keyRoutes = "routes" + keyTasks = "tasks" ) -// flexTopKeys are the top-level keys that indicate Flex-style configuration. -var flexTopKeys = []string{keyApplications, keyServices, keyRoutes} +// flexTopKeys are the top-level keys of Flex-style configuration. +var flexTopKeys = []string{keyApplications, keyServices, keyRoutes, keyTasks} // lintFixed lints Fixed-style configuration in dir using the resolved names in // cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index b1eb4d9bb..0b21744f5 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" ) +//nolint:lll func TestLintDir_Fixed(t *testing.T) { tests := []struct { name string @@ -30,6 +31,30 @@ relationships: }, wantNoErr: true, }, + { + name: "app with env authorization", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +authorizations: + - type: env + action: view`, + }, + wantNoErr: true, + }, + { + // Tasks are only available with Flex-style configuration. + name: "app authorizing a task", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +authorizations: + - type: task + resource: myagent + action: operate`, + }, + wantErrors: []string{"applications.myapp.authorizations.0.resource: task 'myagent' is not found (no tasks are defined)"}, + }, { name: "composable image with type and stack", files: map[string]string{ diff --git a/internal/lint/linter.go b/internal/lint/linter.go index 5119f81d8..27e09cd05 100644 --- a/internal/lint/linter.go +++ b/internal/lint/linter.go @@ -49,5 +49,6 @@ func runChecks(cfg *Config) (*Result, error) { CheckWebConfig(cfg), CheckDependencies(cfg), CheckRoutes(cfg), + CheckTasks(cfg), ), nil } diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index d60aba40c..2476f78a2 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) //nolint:lll @@ -105,3 +106,138 @@ services: }) } } + +//nolint:lll +func TestLint_Schema(t *testing.T) { + cases := []struct { + name string + // content is merged Flex configuration. + content string + // wantErrors is the formatted error output, empty when none is expected. + wantErrors string + }{ + { + // Based on the documented examples for tasks and authorizations. + name: "tasks and authorizations", + content: ` +applications: + myapp: + type: python:3.14 + web: + commands: + start: python app.py + relationships: + database: "db:postgresql" + mounts: + config: + source: local + source_path: config + authorizations: + - type: task + resource: myagent + action: operate + - type: env + action: view + workers: + queue: + commands: + start: python queue.py + authorizations: + - type: env + action: view +services: + db: + type: postgresql:18 +tasks: + myagent: + type: python:3.14 + source: + root: /agent + hooks: + build: pip install -r requirements.txt + run: + command: python setup.py && python agent.py + timeout: 1200 + relationships: + database: "db:postgresql" + app: "myapp:http" + mounts: + cache: + source: tmp + shared: + source: storage + service: myapp + variables: + env: + BATCH_SIZE: "100" + authorizations: + - type: env + action: view + composed: + type: composable:25.11 + stack: ["python@3.14"] + run: + command: python run.py + perf: + base: performance-agent +routes: + "https://{default}/": + type: upstream + upstream: "myapp:http" +`, + }, + { + name: "invalid authorization and task properties", + content: ` +applications: + myapp: + type: python:3.14 + web: + commands: + start: python app.py + authorizations: + - type: project + action: view + scope: all +tasks: + myagent: + type: python:3.14 + run: + command: python agent.py + timeout: 100000 + web: + commands: + start: python agent.py +`, + wantErrors: `linter errors: + - applications.myapp.authorizations.0.type: applications.myapp.authorizations.0.type must be one of the following: "env", "task" + - applications.myapp.authorizations.0: Additional property scope is not allowed + - tasks.myagent.run.timeout: Must be less than or equal to 86400 + - tasks.myagent: Additional property web is not allowed`, + }, + { + name: "unknown top-level key", + content: ` +applications: + myapp: + type: php:8.4 +workers: {} +`, + wantErrors: `linter errors: + - Additional property workers is not allowed`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result, err := CheckContent(c.content) + require.NoError(t, err) + if c.wantErrors == "" { + assert.False(t, result.HasErrors(), "unexpected errors: %s", result) + assert.False(t, result.HasWarnings(), "unexpected warnings: %s", result) + return + } + assert.Equal(t, c.wantErrors, result.Error()) + }) + } +} diff --git a/internal/lint/merge.go b/internal/lint/merge.go index e6511f440..4c4193d47 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -4,6 +4,8 @@ import ( "fmt" "io/fs" "path" + "slices" + "strings" "gopkg.in/yaml.v3" ) @@ -33,57 +35,69 @@ func findFlexConfigFiles(fsys fs.FS, dir, configDir string) ([]string, error) { return allMatches, nil } -// mergeConfigFiles merges the given YAML files, combining top-level 'applications', 'routes', and 'services' maps. -// If a key is duplicated across files, it returns an error. Returns the merged YAML as a string. -func mergeConfigFiles(fsys fs.FS, files []string) (string, error) { - merged := map[string]map[string]any{ - keyApplications: {}, - keyRoutes: {}, - keyServices: {}, - } +// mergeConfigFiles merges the given YAML files, combining the top-level sections +// (flexTopKeys). A key duplicated across files, or an unknown top-level key, is +// an error. It returns the merged YAML and the file that defines each entry, +// keyed by "
." (e.g. "applications.app"). +func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources map[string]string, err error) { + sections := map[string]map[string]any{} + sources = map[string]string{} for _, file := range files { b, err := fs.ReadFile(fsys, file) if err != nil { - return "", fmt.Errorf("failed to read %s: %w", file, err) + return "", nil, fmt.Errorf("failed to read %s: %w", file, err) } var doc map[string]any if err := yaml.Unmarshal(b, &doc); err != nil { - return "", fmt.Errorf("failed to parse YAML in %s: %w", file, err) + return "", nil, fmt.Errorf("failed to parse YAML in %s: %w", file, err) } - for _, key := range []string{keyApplications, keyRoutes, keyServices} { - if section, ok := doc[key]; ok && section != nil { - sectionMap, ok := section.(map[string]any) - if !ok { - return "", fmt.Errorf("%s in %s is not a map", key, file) - } - for k, v := range sectionMap { - if _, exists := merged[key][k]; exists { - return "", fmt.Errorf("duplicate key '%s' in section '%s' found in file %s", k, key, file) - } - merged[key][k] = v + for key, section := range doc { + if strings.HasPrefix(key, ".") { + continue + } + if !slices.Contains(flexTopKeys, key) { + return "", nil, fmt.Errorf("unknown top-level key '%s' in %s: it must be one of: %s", + key, file, strings.Join(flexTopKeys, ", ")) + } + if section == nil { + continue + } + sectionMap, ok := section.(map[string]any) + if !ok { + return "", nil, fmt.Errorf("%s in %s is not a map", key, file) + } + if sections[key] == nil { + sections[key] = map[string]any{} + } + for k, v := range sectionMap { + if _, exists := sections[key][k]; exists { + return "", nil, fmt.Errorf("duplicate key '%s' in section '%s' found in file %s (already defined in %s)", + k, key, file, sources[key+"."+k]) } + sections[key][k] = v + sources[key+"."+k] = file } } } out := map[string]any{} - for _, key := range []string{keyApplications, keyRoutes, keyServices} { - if len(merged[key]) > 0 { - out[key] = merged[key] + for key, section := range sections { + if len(section) > 0 { + out[key] = section } } buf, err := yaml.Marshal(out) if err != nil { - return "", fmt.Errorf("failed to marshal merged YAML: %w", err) + return "", nil, fmt.Errorf("failed to marshal merged YAML: %w", err) } - return string(buf), nil + return string(buf), sources, nil } // getMergedConfigFiles merges all Flex config files in the given directory. // It is a convenience wrapper for findFlexConfigFiles + mergeConfigFiles. -func getMergedConfigFiles(fsys fs.FS, dir, configDir string) (string, error) { +func getMergedConfigFiles(fsys fs.FS, dir, configDir string) (merged string, sources map[string]string, err error) { files, err := findFlexConfigFiles(fsys, dir, configDir) if err != nil { - return "", err + return "", nil, err } return mergeConfigFiles(fsys, files) } diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go index 06c9ca731..cb8ec59cd 100644 --- a/internal/lint/merge_test.go +++ b/internal/lint/merge_test.go @@ -4,6 +4,7 @@ import ( "testing" "testing/fstest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,13 +45,55 @@ routes: "b.yml": &fstest.MapFile{Data: []byte(`services: db: {type: mariadb}`)}, } - merged, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) + merged, sources, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) require.NoError(t, err) require.Contains(t, merged, "foo") require.Contains(t, merged, "db") require.Contains(t, merged, "/:") + assert.Equal(t, map[string]string{ + "applications.foo": "a.yaml", + "routes./": "a.yaml", + "services.db": "b.yml", + }, sources) } +func TestMergeConfigFiles_Tasks(t *testing.T) { + fsys := fstest.MapFS{ + "a.yaml": &fstest.MapFile{Data: []byte(`applications: + foo: {type: go}`)}, + "b.yaml": &fstest.MapFile{Data: []byte(`tasks: + agent: {type: "python:3.14", run: {command: ./run}}`)}, + } + merged, sources, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yaml"}) + require.NoError(t, err) + require.Contains(t, merged, "agent") + assert.Equal(t, "b.yaml", sources["tasks.agent"]) +} + +func TestMergeConfigFiles_TopLevelKeys(t *testing.T) { + cases := []struct { + name string + content string + wantErr string + }{ + {"dot-prefixed key is ignored", ".anchors: {a: 1}\napplications:\n foo: {type: go}", ""}, + {"unknown key", "applications:\n foo: {type: go}\nworkers: {}", "unknown top-level key 'workers' in a.yaml"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fsys := fstest.MapFS{"a.yaml": &fstest.MapFile{Data: []byte(c.content)}} + merged, _, err := mergeConfigFiles(fsys, []string{"a.yaml"}) + if c.wantErr != "" { + require.ErrorContains(t, err, c.wantErr) + return + } + require.NoError(t, err) + assert.NotContains(t, merged, "anchors") + }) + } +} + +//nolint:lll func TestMergeConfigFiles_DuplicateKey(t *testing.T) { fsys := fstest.MapFS{ "a.yaml": &fstest.MapFile{Data: []byte(`applications: @@ -58,9 +101,9 @@ func TestMergeConfigFiles_DuplicateKey(t *testing.T) { "b.yml": &fstest.MapFile{Data: []byte(`applications: foo: {type: node}`)}, } - _, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) + _, _, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) require.Error(t, err) - require.Contains(t, err.Error(), "duplicate key 'foo'") + require.Contains(t, err.Error(), "duplicate key 'foo' in section 'applications' found in file b.yml (already defined in a.yaml)") } func TestGetMergedConfigFiles_Success(t *testing.T) { @@ -70,7 +113,7 @@ func TestGetMergedConfigFiles_Success(t *testing.T) { ".upsun/b.yml": &fstest.MapFile{Data: []byte(`services: db: {type: mariadb}`)}, } - merged, err := getMergedConfigFiles(fsys, ".", ".upsun") + merged, _, err := getMergedConfigFiles(fsys, ".", ".upsun") require.NoError(t, err) require.Contains(t, merged, "foo") require.Contains(t, merged, "db") @@ -78,7 +121,7 @@ func TestGetMergedConfigFiles_Success(t *testing.T) { func TestGetMergedConfigFiles_NoUpsunDir(t *testing.T) { fsys := fstest.MapFS{} - _, err := getMergedConfigFiles(fsys, ".", ".upsun") + _, _, err := getMergedConfigFiles(fsys, ".", ".upsun") require.Error(t, err) require.Contains(t, err.Error(), ".upsun") } @@ -87,7 +130,7 @@ func TestGetMergedConfigFiles_NoYamlFiles(t *testing.T) { fsys := fstest.MapFS{ ".upsun/notyaml.txt": &fstest.MapFile{Data: []byte("not yaml")}, } - _, err := getMergedConfigFiles(fsys, ".", ".upsun") + _, _, err := getMergedConfigFiles(fsys, ".", ".upsun") require.Error(t, err) require.Contains(t, err.Error(), "no configuration files found") } diff --git a/internal/lint/names.go b/internal/lint/names.go index b99590efd..a5f0bb34d 100644 --- a/internal/lint/names.go +++ b/internal/lint/names.go @@ -14,7 +14,7 @@ var ( isAlphanumeric = regexp.MustCompile(`^[a-z0-9]+$`).MatchString ) -// CheckNames checks that application, service and worker names are correct. +// CheckNames checks that application, service, worker and task names are correct. func CheckNames(cfg *Config) *Result { result := &Result{} @@ -34,6 +34,11 @@ func CheckNames(cfg *Config) *Result { result.AddError("services."+name, err) } } + for name := range cfg.Tasks { + if err := validateServiceName(name, "task"); err != "" { + result.AddError("tasks."+name, err) + } + } return result } diff --git a/internal/lint/names_test.go b/internal/lint/names_test.go index 295582ccb..980304c37 100644 --- a/internal/lint/names_test.go +++ b/internal/lint/names_test.go @@ -130,6 +130,20 @@ services: `services.averylongservicenamethatexceedsthirtytwocharacters: "averylongservicenamethatexceedsthirtytwocharacters" is not a valid service name, it should be no longer than 32 characters.`, //nolint:lll }, }, + { + name: "task_names", + content: ` +applications: + foo: +tasks: + my-agent: + type: python:3.14 + Bad_Task: + type: python:3.14`, + expectErrorValues: []string{ + `tasks.Bad_Task: "Bad_Task" is not a valid task name, it can only contain lowercase alphanumeric characters, dashes, or underscores.`, //nolint:lll + }, + }, } for _, c := range cases { diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 5bae10143..6d881e468 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -81,7 +81,7 @@ func CheckDir(dir string, vendor Vendor) (*Result, Style, error) { switch { case flexOK: - content, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) + content, sources, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) if err != nil { return nil, StyleFlex, err } @@ -89,6 +89,7 @@ func CheckDir(dir string, vendor Vendor) (*Result, Style, error) { if err != nil { return nil, StyleFlex, err } + attributeSources(result, sources) addStrayWarnings(result, dir, flexDirs, fixedSet) if fixedOK { result.AddWarning("", fmt.Sprintf( @@ -195,8 +196,9 @@ func addStrayWarnings(result *Result, root string, flexDirs []string, fixed []fi // files. It mirrors the legacy CLI's safeguard against slow, over-broad searches. const maxFixedAppDepth = 5 -// walkProject walks root, pruning VCS/dependency directories and limiting depth, -// invoking visit for each remaining entry (files and directories). +// walkProject walks root, pruning VCS/dependency directories, separate Git +// checkouts, and limiting depth, invoking visit for each remaining entry (files +// and directories). func walkProject(root string, visit func(path string, d fs.DirEntry)) { _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { @@ -211,6 +213,9 @@ func walkProject(root string, visit func(path string, d fs.DirEntry)) { len(strings.Split(filepath.ToSlash(rel), "/")) >= maxFixedAppDepth { return fs.SkipDir } + if isSeparateCheckout(path) { + return fs.SkipDir + } } visit(path, d) return nil @@ -254,3 +259,50 @@ func yamlVariants(base string) []string { base = strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml") return []string{base + ".yaml", base + ".yml"} } + +// attributeSources prefixes each issue path with the file that defines it, given +// the files keyed by "
." (see mergeConfigFiles). +func attributeSources(result *Result, sources map[string]string) { + attribute := func(issues []Issue) { + for i, issue := range issues { + // Route keys are URLs that may contain dots, so the longest match wins. + // Route issues may also use the form routes[""]. + var match string + for key := range sources { + section, name, _ := strings.Cut(key, ".") + for _, prefix := range []string{key, fmt.Sprintf("%s[%q]", section, name)} { + if (issue.Path == prefix || strings.HasPrefix(issue.Path, prefix+".")) && len(key) > len(match) { + match = key + } + } + } + if match != "" { + issues[i].Path = scopePath(sources[match], issue.Path) + } + } + } + attribute(result.Errors) + attribute(result.Warnings) +} + +// isSeparateCheckout reports whether dir is a Git checkout other than a +// submodule: a separate clone (a .git directory) or a linked worktree (a .git +// file pointing into another repository's "worktrees" directory). These are not +// part of the project. Submodules also have a .git file, pointing into +// "modules", and are kept since they are deployed with the project. +func isSeparateCheckout(dir string) bool { + gitPath := filepath.Join(dir, ".git") + fi, err := os.Lstat(gitPath) + if err != nil { + return false + } + if fi.IsDir() { + return true + } + b, err := os.ReadFile(gitPath) + if err != nil { + return false + } + gitDir, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:") + return ok && strings.Contains(filepath.ToSlash(strings.TrimSpace(gitDir)), "/worktrees/") +} diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index 0443237df..23a626473 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -182,3 +182,78 @@ func TestLintDir_BothPresentPrefersFlex(t *testing.T) { assert.True(t, result.HasWarnings()) assert.Contains(t, result.String(), "both .upsun and .platform") } + +//nolint:lll +func TestLintDir_FlexSourceFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "apps.yaml"), `applications: + app: + type: "php:8.4" + authorizations: + - type: task + resource: missing + action: operate +`) + writeFile(t, filepath.Join(dir, ".upsun", "tasks.yaml"), `tasks: + agent: + type: "python:3.14" + run: + command: ./run + extra: true +`) + writeFile(t, filepath.Join(dir, ".upsun", "routes.yaml"), `routes: + "https://{default}/api": + type: upstream + upstream: "missing:http" +`) + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, `Linter errors: + - .upsun/tasks.yaml: tasks.agent: Additional property extra is not allowed`, result.String()) + + // Semantic checks run once the schema passes. + writeFile(t, filepath.Join(dir, ".upsun", "tasks.yaml"), `tasks: + agent: + type: "python:3.14" + run: + command: ./run +`) + result, _, err = CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Contains(t, result.String(), ".upsun/apps.yaml: applications.app.authorizations.0.resource: task 'missing' is not found") + assert.Contains(t, result.String(), `.upsun/routes.yaml: routes["https://{default}/api"].upstream: upstream target 'missing' does not exist`) +} + +func TestLintDir_SkipsSeparateCheckouts(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), "applications:\n app:\n type: \"php:8.4\"\n") + // A linked worktree, a separate clone, and a submodule, each with its own config. + writeFile(t, filepath.Join(dir, ".claude", "worktrees", "wt", ".git"), + "gitdir: "+filepath.Join(dir, ".git", "worktrees", "wt")+"\n") + writeFile(t, filepath.Join(dir, ".claude", "worktrees", "wt", ".upsun", "config.yaml"), "applications: {}") + writeFile(t, filepath.Join(dir, "clone", ".git", "HEAD"), "ref: refs/heads/main") + writeFile(t, filepath.Join(dir, "clone", ".upsun", "config.yaml"), "applications: {}") + writeFile(t, filepath.Join(dir, "sub", ".git"), "gitdir: ../.git/modules/sub\n") + writeFile(t, filepath.Join(dir, "sub", ".upsun", "config.yaml"), "applications: {}") + + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, []Issue{{ + Path: filepath.Join("sub", ".upsun"), + Message: "this .upsun directory is not at the project root and will be ignored", + }}, result.Warnings) +} + +func TestLintFixed_WorktreeAppFileIgnored(t *testing.T) { + dir := t.TempDir() + app := "name: app\ntype: \"php:8.4\"\n" + writeFile(t, filepath.Join(dir, ".platform.app.yaml"), app) + // A worktree of the same repository would otherwise be read as a duplicate app. + writeFile(t, filepath.Join(dir, "wt", ".git"), "gitdir: /elsewhere/.git/worktrees/wt\n") + writeFile(t, filepath.Join(dir, "wt", ".platform.app.yaml"), app) + + result, style, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, StyleFixed, style) + assert.False(t, result.HasErrors(), "unexpected errors: %s", result) +} diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go index f0af07f8e..09d6e3d79 100644 --- a/internal/lint/relationships.go +++ b/internal/lint/relationships.go @@ -5,90 +5,97 @@ import ( "strings" ) -// CheckRelationships checks that application relationships correspond to existing services. +// CheckRelationships checks that application, service and task names are unique, +// and that application and task relationships correspond to existing services or +// applications. func CheckRelationships(cfg *Config) *Result { result := &Result{} - var serviceNames = make(map[string]string) - addService := func(name, source string) string { - if prevSource, exists := serviceNames[name]; exists { - return fmt.Sprintf("duplicate name found: '%s' in '%s' (previous in '%s')", name, source, prevSource) + // Applications, services and tasks share a namespace. + sections := make(map[string]string) + addName := func(name, section string) { + if prev, exists := sections[name]; exists { + result.AddError(section+"."+name, + fmt.Sprintf("duplicate name found: '%s' in '%s' (previous in '%s')", name, section, prev)) + return } - serviceNames[name] = source - return "" + sections[name] = section } + for name := range cfg.Applications { + addName(name, keyApplications) + } + for name := range cfg.Services { + addName(name, keyServices) + } + for name := range cfg.Tasks { + addName(name, keyTasks) + } + // Worker names are scoped to their application, so they are tracked // separately: two applications may each define a worker with the same name, // and a worker may share a name with an application or service. - var workerNames = make(map[string]struct{}) + workerNames := make(map[string]struct{}) for appName := range cfg.Applications { - app := cfg.Applications[appName] - if msg := addService(appName, "applications"); msg != "" { - result.AddError("applications."+appName, msg) - } - for name := range app.Workers { + for name := range cfg.Applications[appName].Workers { workerNames[name] = struct{}{} } } - for name := range cfg.Services { - if msg := addService(name, "services"); msg != "" { - result.AddError("services."+name, msg) - } - } - - var linkedServices = make(map[string]struct{}) - for appName := range cfg.Applications { - appConfig := cfg.Applications[appName] - for relName, value := range appConfig.Relationships { - // By default, the relationship links to the service with the same name. - var relationshipService = relName - var explicit bool - // The service name can also be specified explicitly, via a map or a string. - // TODO validate the endpoint - switch details := value.(type) { - case map[string]any: - if s, ok := details["service"].(string); ok { - relationshipService = s - explicit = true - } - case string: - relationshipService = strings.SplitN(details, ":", 2)[0] - explicit = true - } - - _, isNamed := serviceNames[relationshipService] - if _, isWorker := workerNames[relationshipService]; isNamed || isWorker { - linkedServices[relationshipService] = struct{}{} - } else { - var msg string + linkedServices := make(map[string]struct{}) + checkRelationships := func(kind, name, path string, relationships map[string]any) { + for relName, value := range relationships { + target, explicit := relationshipTarget(relName, value) + relPath := path + ".relationships." + relName + section, isNamed := sections[target] + _, isWorker := workerNames[target] + switch { + case section == keyTasks: + result.AddError(relPath, fmt.Sprintf( + "relationship '%s' in %s '%s' points to task '%s', but a task cannot be a relationship target", + relName, kind, name, target)) + case isNamed || isWorker: + linkedServices[target] = struct{}{} + default: + msg := fmt.Sprintf("relationship '%s' in %s '%s' does not match any service (or app)", relName, kind, name) if explicit { - msg = fmt.Sprintf( - "relationship '%s' in application '%s' points to a service (or app) named '%s' which is not found", - relName, - appName, - relationshipService, - ) - } else { - msg = fmt.Sprintf( - "relationship '%s' in application '%s' does not match any service (or app)", - relName, - appName, - ) + msg = fmt.Sprintf("relationship '%s' in %s '%s' points to a service (or app) named '%s' which is not found", + relName, kind, name, target) } if len(cfg.Services) == 0 { msg += " (did you forget to define services?)" } - result.AddError("applications."+appName+".relationships."+relName, msg) + result.AddError(relPath, msg) } } } + for appName := range cfg.Applications { + checkRelationships("application", appName, "applications."+appName, cfg.Applications[appName].Relationships) + } + for taskName := range cfg.Tasks { + checkRelationships("task", taskName, "tasks."+taskName, cfg.Tasks[taskName].Relationships) + } for name := range cfg.Services { if _, linked := linkedServices[name]; !linked { - result.AddError("services."+name, fmt.Sprintf("no application has a relationship to service '%s'", name)) + result.AddError("services."+name, fmt.Sprintf("no application or task has a relationship to service '%s'", name)) } } return result } + +// relationshipTarget returns the service (or application) that a relationship +// points to, and whether it is named explicitly. By default, the relationship +// links to the service with the same name. +// TODO validate the endpoint +func relationshipTarget(relName string, value any) (target string, explicit bool) { + switch details := value.(type) { + case map[string]any: + if s, ok := details["service"].(string); ok { + return s, true + } + case string: + return strings.SplitN(details, ":", 2)[0], true + } + return relName, false +} diff --git a/internal/lint/relationships_test.go b/internal/lint/relationships_test.go index 9887ec556..0c8fd021f 100644 --- a/internal/lint/relationships_test.go +++ b/internal/lint/relationships_test.go @@ -8,6 +8,7 @@ import ( "github.com/upsun/cli/internal/lint" ) +//nolint:lll func TestCheckRelationships(t *testing.T) { cases := []struct { name string @@ -58,7 +59,7 @@ services: foo: {}`, expectErrorMessage: "linter errors:" + "\n - services.foo: duplicate name found: 'foo' in 'services' (previous in 'applications')" + - "\n - services.foo: no application has a relationship to service 'foo'", + "\n - services.foo: no application or task has a relationship to service 'foo'", }, { name: "correct_multiapp", @@ -139,6 +140,78 @@ services: database: type: mariadb:11.4`, }, + { + name: "task_relationships", + // A service used only by a task is not reported as unused. + content: ` +applications: + app: + type: php:8.4 +services: + db: + type: mariadb:11.4 +tasks: + agent: + type: python:3.14 + relationships: + database: "db:mysql" + site: "app:http" + implicit: + service: db`, + }, + { + name: "task_relationship_not_found", + content: ` +applications: + app: + type: php:8.4 +tasks: + agent: + type: python:3.14 + relationships: + cache:`, + expectErrorMessage: `linter errors: + - tasks.agent.relationships.cache: relationship 'cache' in task 'agent' does not match any service (or app) (did you forget to define services?)`, //nolint:lll + }, + { + name: "relationship_to_task", + content: ` +applications: + app: + type: php:8.4 + relationships: + agent: "agent:http" +tasks: + agent: + type: python:3.14 + other: + type: python:3.14 + relationships: + agent:`, + expectErrorMessage: `linter errors: + - applications.app.relationships.agent: relationship 'agent' in application 'app' points to task 'agent', but a task cannot be a relationship target + - tasks.other.relationships.agent: relationship 'agent' in task 'other' points to task 'agent', but a task cannot be a relationship target`, //nolint:lll + }, + { + name: "task_name_collisions", + content: ` +applications: + app: + type: php:8.4 + relationships: + db: +services: + db: + type: mariadb:11.4 +tasks: + app: + type: python:3.14 + db: + type: python:3.14`, + expectErrorMessage: `linter errors: + - tasks.app: duplicate name found: 'app' in 'tasks' (previous in 'applications') + - tasks.db: duplicate name found: 'db' in 'tasks' (previous in 'services')`, + }, } for _, c := range cases { diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index 831c22272..81f0d531f 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -637,6 +637,41 @@ "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 + } } }, "additionalProperties": false, @@ -885,6 +920,41 @@ ], "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 + } } }, "required": [ @@ -924,6 +994,41 @@ } } ] + }, + "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 + } } }, "anyOf": [ diff --git a/internal/lint/schema/schema.go b/internal/lint/schema/schema.go index 464bcfaf9..085a14e6e 100644 --- a/internal/lint/schema/schema.go +++ b/internal/lint/schema/schema.go @@ -8,10 +8,9 @@ import ( ) var ( - // upsun-config-schema.json is the self-contained Flex schema from platformify, - // refreshed by `make lint-assets`. The meta.upsun.com schema is not used here: - // it resolves type/version via remote $refs (fetched at runtime) and duplicates - // the registry-based type check. Type/version validation is done by CheckTypes. + // 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. //go:embed upsun-config-schema.json configSchema []byte parsedSchema *gojsonschema.Schema diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index 48eef0c1b..587ec461a 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -344,6 +344,9 @@ "title": "Whether to move the whole root of the app to the document root", "default": false, "deprecationMessage": "Deprecated" + }, + "authorizations": { + "$ref": "#/definitions/authorizations" } }, "additionalProperties": false, @@ -383,9 +386,10 @@ "source": { "type": "string", "title": "The type of mount that will provide the data", - "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", + "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` (or `local`) mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", "enum": [ "instance", + "local", "service", "storage", "temporary", @@ -460,6 +464,9 @@ "additionalProperties": false, "title": "The commands to manage the worker", "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/workers/" + }, + "authorizations": { + "$ref": "#/definitions/authorizations" } }, "required": [ @@ -750,6 +757,9 @@ "title": "Runtime operations that can be executed in the application container", "description": "More information: \nhttps://docs.upsun.com/anchors/app/runtime-operations/", "default": {} + }, + "authorizations": { + "$ref": "#/definitions/authorizations" } }, "anyOf": [ @@ -922,12 +932,222 @@ ], "additionalProperties": false } + }, + "tasks": { + "title": "The tasks of the project", + "description": "On-demand, run-to-completion workloads.\nMore information:\nhttps://developer.upsun.com/docs/configure-apps/tasks", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "additional_hosts": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Maps of hostnames to IP addresses", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/additional-hosts/" + }, + "authorizations": { + "$ref": "#/definitions/authorizations" + }, + "base": { + "type": "string", + "title": "A built-in task definition to use", + "description": "When set, no other property is allowed." + }, + "container_profile": { + "$ref": "#/definitions/container_profile" + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "type": "object" + }, + "title": "External global dependencies of this application", + "description": "What global dependencies to install before the build `hook` is run. They will be downloaded by the language's package manager. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/dependencies/", + "default": {} + }, + "hooks": { + "type": "object", + "properties": { + "build": { + "type": [ + "string", + "null" + ], + "description": "Run during the environment build, producing a slug reused across runs." + }, + "deploy": { + "type": [ + "string", + "null" + ], + "description": "Run before the task's run command." + } + }, + "additionalProperties": false + }, + "mounts": { + "$ref": "#/definitions/mounts" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, + "resources": { + "$ref": "#/definitions/deprecated/resources" + }, + "run": { + "type": "object", + "properties": { + "command": { + "type": "string", + "minLength": 1, + "description": "The command to execute. It runs to completion, not as a daemon." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "The maximum duration in seconds. Defaults to 3600." + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "runtime": { + "type": "object", + "title": "Runtime-specific configuration", + "description": "Customizations to your PHP or Lisp runtime. \nContains a runtime dictionary: \nhttps://docs.upsun.com/anchors/app/reference/runtime/", + "properties": { + "extensions": { + "type": "array", + "title": "PHP extensions to enable", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", + "default": [] + }, + "disabled_extensions": { + "type": "array", + "title": "PHP extensions to disable", + "description": "More information: \nhttps://docs.upsun.com/anchors/app/reference/extensions/", + "default": [] + }, + "request_terminate_timeout": { + "type": "integer", + "title": "PHP timeout to terminate requests", + "description": "The timeout in seconds for serving a single request after which the PHP-FPM worker process is killed." + }, + "sizing_hints": { + "type": "object", + "title": "A sizing hints definition", + "description": "The assumptions for setting the number of workers in your PHP-FPM runtime. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/runtime/sizing-hints/", + "properties": { + "request_memory": { + "type": "integer", + "title": "The average memory consumed per request in MB", + "description": "Minimum to 10", + "default": 45, + "minimum": 10 + }, + "reserved_memory": { + "type": "integer", + "title": "The amount of memory reserved in MB", + "description": "Minimum to 70", + "default": 70, + "minimum": 70 + } + }, + "additionalProperties": false + }, + "xdebug": { + "type": "object", + "title": "An Xdebug definition", + "description": "The setting to turn on Xdebug. \nMore information: \nhttps://docs.upsun.com/anchors/languages/php/xdebug/", + "properties": { + "idekey": { + "type": "string", + "title": "Your Xdebug key" + } + } + }, + "quicklisp": { + "type": "object", + "title": "Distributions for QuickLisp to use", + "description": "More information: \nhttps://docs.upsun.com/anchors/languages/lisp/", + "deprecationMessage": "Lisp image no longer exists, please see \nhttps://devcenter.upsun.com/posts/deploying-with-lisp/ \n for more information" + } + } + }, + "source": { + "type": "object", + "properties": { + "root": { + "type": "string", + "description": "The subdirectory containing the task's code. Defaults to `/`." + } + }, + "additionalProperties": false + }, + "stack": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + } + ], + "title": "Composable Image definition", + "description": "A list of packages from the Upsun collection of supported runtimes and/or from NixPkgs. \nMore information: \nhttps://docs.upsun.com/anchors/app/composable/" + }, + "timezone": { + "type": "string", + "title": "The timezone of the application", + "description": "This primarily affects the timezone in which cron tasks will run. It will not affect the application itself. Defaults to UTC if not specified. \nSee also: \nhttps://docs.upsun.com/anchors/app/timezone/", + "default": null + }, + "type": { + "type": "string", + "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/" + }, + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + }, + "title": "Variables to control the environment", + "description": "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. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/variables/", + "default": {} + } + }, + "additionalProperties": false + } } }, "required": [ "applications" ], - "additionalProperties": true, + "additionalProperties": false, "definitions": { "container_profile": { "type": [ @@ -1319,9 +1539,10 @@ "source": { "type": "string", "title": "The type of mount that will provide the data", - "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", + "description": "- By design, `storage` mounts can be shared between instances of the same app \n- `instance` (or `local`) mounts are local mounts \n- `tmp` (or `temporary`) mounts are local ephemeral mounts \n- `service` mounts can be useful if you want to explicitly define and use a Network Storage service", "enum": [ "instance", + "local", "service", "storage", "temporary", @@ -1376,6 +1597,44 @@ "title": "Resources", "deprecationMessage": "Deprecated" } + }, + "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 + } } + }, + "patternProperties": { + "^\\.": {} } } diff --git a/internal/lint/scripts.go b/internal/lint/scripts.go index 9f17f2c42..4fbbcca17 100644 --- a/internal/lint/scripts.go +++ b/internal/lint/scripts.go @@ -15,10 +15,11 @@ func CheckScripts(cfg *Config) *Result { app := cfg.Applications[appName] keyPrefix := "applications." + appName + "." - // Warn if the start command is not set for non-PHP applications. + // Warn if the start command is not set for non-PHP applications, unless + // they only serve static files. // Skip composable applications for now as they could potentially also use a PHP-FPM default. // TODO check this if app.Web.Commands.Start == "" && !strings.HasPrefix(app.Type, "php:") && - !strings.HasPrefix(app.Type, "composable:") { + !strings.HasPrefix(app.Type, "composable:") && !servesStaticOnly(app.Web.Locations) { result.AddWarning(keyPrefix+"web.commands.start", "a start command is needed for non-PHP applications") } @@ -41,6 +42,14 @@ func CheckScripts(cfg *Config) *Result { } } + for taskName := range cfg.Tasks { + task := cfg.Tasks[taskName] + keyPrefix := "tasks." + taskName + "." + scripts[keyPrefix+"run.command"] = task.Run.Command + scripts[keyPrefix+"hooks.build"] = task.Hooks.Build + scripts[keyPrefix+"hooks.deploy"] = task.Hooks.Deploy + } + for k, v := range scripts { if v == "" { continue @@ -53,3 +62,24 @@ func CheckScripts(cfg *Config) *Result { return result } + +// servesStaticOnly reports whether web locations are defined and none passes +// requests through to the application. +func servesStaticOnly(locations map[string]WebLocation) bool { + if len(locations) == 0 { + return false + } + for _, l := range locations { + switch p := l.Passthru.(type) { + case bool: + if p { + return false + } + case string: + if p != "" { + return false + } + } + } + return true +} diff --git a/internal/lint/scripts_test.go b/internal/lint/scripts_test.go index cf405aee6..ce32d4f3b 100644 --- a/internal/lint/scripts_test.go +++ b/internal/lint/scripts_test.go @@ -130,6 +130,25 @@ applications: expectErrorMessage: "linter errors:\n - applications.app1.workers.queue.commands.pre_start: " + "invalid syntax: 1:6: reached EOF without closing quote `'`", }, + { + name: "invalid task scripts", + yamlContent: ` +applications: + app1: + type: "php:8.4" +tasks: + agent: + type: "python:3.14" + hooks: + build: "pip install -r requirements.txt" + deploy: "echo 'unterminated" + run: + command: "python agent.py && (" +`, + expectErrorMessage: "linter errors:\n" + + " - tasks.agent.hooks.deploy: invalid syntax: 1:6: reached EOF without closing quote `'`\n" + + " - tasks.agent.run.command: invalid syntax: 1:20: `(` must be followed by a statement list", + }, { name: "empty YAML", yamlContent: ` @@ -148,6 +167,35 @@ applications: type: "nodejs:20" hooks: build: "echo 'Building app1'" +`, + expectWarningMessage: "linter warnings:\n - applications.app1.web.commands.start: " + + "a start command is needed for non-PHP applications", + }, + { + name: "static site without start command - no warning", + yamlContent: ` +applications: + app1: + type: "nodejs:24" + web: + locations: + "/": + root: dist + scripts: false + passthru: false +`, + }, + { + name: "non-PHP application with passthru and no start command", + yamlContent: ` +applications: + app1: + type: "nodejs:24" + web: + locations: + "/": + root: dist + passthru: true `, expectWarningMessage: "linter warnings:\n - applications.app1.web.commands.start: " + "a start command is needed for non-PHP applications", diff --git a/internal/lint/tasks.go b/internal/lint/tasks.go new file mode 100644 index 000000000..bc10af35d --- /dev/null +++ b/internal/lint/tasks.go @@ -0,0 +1,92 @@ +package lint + +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +// authorizationActions lists the actions allowed for each authorization type. +var authorizationActions = map[string]string{ + "task": "operate", + "env": "view", +} + +// CheckTasks checks task definitions and the authorizations of applications, +// their web and worker containers, and tasks. +func CheckTasks(cfg *Config) *Result { + result := &Result{} + + for name := range cfg.Tasks { + task := cfg.Tasks[name] + path := "tasks." + name + if task.Base != "" { + if len(task.fields) > 1 { + result.AddError(path+".base", "when 'base' is set, no other property is allowed") + } + continue + } + if task.Run.Command == "" { + result.AddError(path+".run.command", "a run command is required") + } + for mountName, mount := range task.Mounts { + mountPath := path + ".mounts." + mountName + switch { + case mount.Source == "storage" && mount.Service == "": + result.AddError(mountPath, "a task has no storage of its own, so a 'storage' mount must "+ + "name an application in 'service'") + case mount.Source == "storage": + if _, ok := cfg.Applications[mount.Service]; !ok { + result.AddError(mountPath+".service", fmt.Sprintf("application '%s' is not found", mount.Service)) + } + case mount.Source == "service": + if _, ok := cfg.Services[mount.Service]; !ok { + result.AddError(mountPath+".service", fmt.Sprintf("service '%s' is not found", mount.Service)) + } + } + } + } + + checkAuthorizations := func(path string, authorizations []Authorization) { + for i, a := range authorizations { + itemPath := path + ".authorizations." + strconv.Itoa(i) + if want, ok := authorizationActions[a.Type]; ok && a.Action != want { + result.AddError(itemPath+".action", + fmt.Sprintf("authorization type '%s' only allows the action '%s'", a.Type, want)) + } + if a.Type != "task" { + continue + } + if a.Resource == "" { + result.AddError(itemPath+".resource", "a task authorization requires a resource (the task name)") + } else if _, ok := cfg.Tasks[a.Resource]; !ok { + result.AddError(itemPath+".resource", fmt.Sprintf("task '%s' is not found%s", + a.Resource, availableHint(cfg.Tasks))) + } + } + } + for appName := range cfg.Applications { + app := cfg.Applications[appName] + path := "applications." + appName + checkAuthorizations(path, app.Authorizations) + checkAuthorizations(path+".web", app.Web.Authorizations) + for workerName, worker := range app.Workers { + checkAuthorizations(path+".workers."+workerName, worker.Authorizations) + } + } + for name := range cfg.Tasks { + checkAuthorizations("tasks."+name, cfg.Tasks[name].Authorizations) + } + + return result +} + +// availableHint lists the defined task names for an error message. +func availableHint(tasks map[string]Task) string { + if len(tasks) == 0 { + return " (no tasks are defined)" + } + return " (defined tasks: " + strings.Join(slices.Sorted(maps.Keys(tasks)), ", ") + ")" +} diff --git a/internal/lint/tasks_test.go b/internal/lint/tasks_test.go new file mode 100644 index 000000000..72abc3b56 --- /dev/null +++ b/internal/lint/tasks_test.go @@ -0,0 +1,213 @@ +package lint_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/internal/lint" +) + +//nolint:lll +func TestCheckTasks(t *testing.T) { + cases := []struct { + name string + content string + // wantErrors is the formatted error output, empty when none is expected. + wantErrors string + }{ + { + name: "valid task with authorizations", + content: ` +applications: + myapp: + type: python:3.14 + authorizations: + - type: task + resource: myagent + action: operate + - type: env + action: view +tasks: + myagent: + type: python:3.14 + run: + command: python agent.py + authorizations: + - type: env + action: view`, + }, + { + name: "built-in task definition", + content: ` +applications: + myapp: + type: python:3.14 +tasks: + perf: + base: performance-agent`, + }, + { + name: "base with other properties", + content: ` +applications: + myapp: + type: python:3.14 +tasks: + perf: + base: performance-agent + run: + command: ./run`, + wantErrors: `linter errors: + - tasks.perf.base: when 'base' is set, no other property is allowed`, + }, + { + name: "missing run command", + content: ` +applications: + myapp: + type: python:3.14 +tasks: + myagent: + type: python:3.14`, + wantErrors: `linter errors: + - tasks.myagent.run.command: a run command is required`, + }, + { + name: "authorization actions must match their type", + content: ` +applications: + myapp: + type: python:3.14 + authorizations: + - type: env + action: operate +tasks: + myagent: + type: python:3.14 + run: + command: ./run + authorizations: + - type: task + resource: myagent + action: view`, + wantErrors: `linter errors: + - applications.myapp.authorizations.0.action: authorization type 'env' only allows the action 'view' + - tasks.myagent.authorizations.0.action: authorization type 'task' only allows the action 'operate'`, + }, + { + name: "task authorization without a resource", + content: ` +applications: + myapp: + type: python:3.14 + authorizations: + - type: task + action: operate`, + wantErrors: `linter errors: + - applications.myapp.authorizations.0.resource: a task authorization requires a resource (the task name)`, + }, + { + name: "task authorization for a missing task, on each container", + content: ` +applications: + myapp: + type: python:3.14 + authorizations: + - type: task + resource: missing + action: operate + web: + authorizations: + - type: task + resource: missing + action: operate + workers: + queue: + commands: + start: ./queue + authorizations: + - type: env + action: view + - type: task + resource: missing + action: operate +tasks: + a: + type: python:3.14 + run: + command: ./a + b: + type: python:3.14 + run: + command: ./b`, + wantErrors: `linter errors: + - applications.myapp.authorizations.0.resource: task 'missing' is not found (defined tasks: a, b) + - applications.myapp.web.authorizations.0.resource: task 'missing' is not found (defined tasks: a, b) + - applications.myapp.workers.queue.authorizations.1.resource: task 'missing' is not found (defined tasks: a, b)`, + }, + { + name: "task authorization with no tasks defined", + content: ` +applications: + myapp: + type: python:3.14 + authorizations: + - type: task + resource: myagent + action: operate`, + wantErrors: `linter errors: + - applications.myapp.authorizations.0.resource: task 'myagent' is not found (no tasks are defined)`, + }, + { + name: "task mounts", + content: ` +applications: + myapp: + type: python:3.14 +services: + files: + type: network-storage:2.0 +tasks: + myagent: + type: python:3.14 + run: + command: ./run + mounts: + cache: + source: instance + shared: + source: storage + service: myapp + network: + source: service + service: files + own: + source: storage + wrong_app: + source: storage + service: other + wrong_service: + source: service + service: other`, + wantErrors: `linter errors: + - tasks.myagent.mounts.own: a task has no storage of its own, so a 'storage' mount must name an application in 'service' + - tasks.myagent.mounts.wrong_app.service: application 'other' is not found + - tasks.myagent.mounts.wrong_service.service: service 'other' is not found`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg, err := lint.DecodeConfig(c.content) + require.NoError(t, err) + result := lint.CheckTasks(cfg) + if c.wantErrors == "" { + assert.False(t, result.HasErrors(), "unexpected errors: %s", result) + return + } + assert.Equal(t, c.wantErrors, result.Error()) + }) + } +} diff --git a/internal/lint/types.go b/internal/lint/types.go index b5d218624..57941391e 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -8,7 +8,7 @@ import ( "github.com/upsun/cli/internal/lint/registry" ) -// CheckTypes checks that application, service and worker types are supported images and versions. +// CheckTypes checks that application, worker, task and service types are supported images and versions. func CheckTypes(cfg *Config, reg registry.Registry) *Result { result := &Result{} @@ -22,6 +22,17 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { } } + // checkRuntime checks the type and stack of an application or task at path. + checkRuntime := func(path, t string, stack any) { + check(path+".type", t, true) + isComposable := strings.HasPrefix(t, "composable") + if isComposable && isStackEmpty(stack) { + result.AddWarning(path, "'stack' should be specified when using a composable image") + } else if !isComposable && !isStackEmpty(stack) { + result.AddWarning(path+".stack", "'stack' is only used with a composable image type") + } + } + for appName := range cfg.Applications { app := cfg.Applications[appName] if app.Type == "" && !isStackEmpty(app.Stack) { @@ -30,12 +41,12 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { "'type' should be specified (as a composable image) when using 'stack'") continue } - check("applications."+appName+".type", app.Type, true) - isComposable := strings.HasPrefix(app.Type, "composable") - if isComposable && isStackEmpty(app.Stack) { - result.AddWarning("applications."+appName, "'stack' should be specified when using a composable image") - } else if !isComposable && !isStackEmpty(app.Stack) { - result.AddWarning("applications."+appName+".stack", "'stack' is only used with a composable image type") + checkRuntime("applications."+appName, app.Type, app.Stack) + } + for taskName := range cfg.Tasks { + // A task using a built-in definition ('base') has no type of its own. + if task := cfg.Tasks[taskName]; task.Base == "" { + checkRuntime("tasks."+taskName, task.Type, task.Stack) } } for appName := range cfg.Applications { diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index 41a0a04f8..b5deabf88 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -97,6 +97,37 @@ applications: type: composable:%s`, registry.ChannelStable), expectErrorMessage: `linter warnings: - applications.foo: 'stack' should be specified when using a composable image`, + }, + { + name: "task_types", + // A task using a built-in definition ('base') has no type to check. + content: ` +applications: + foo: + type: php:8.4 +tasks: + good: + type: php:8.4 + perf: + base: performance-agent + bad: + type: php:1.0`, + expectErrorMessage: `linter errors: + - tasks.bad.type: version '1.0' is not supported for type 'php'; it must be exactly one of: 8.4, 8.3, 8.2, 8.1`, + }, + { + name: "task_without_type", + content: ` +applications: + foo: + type: php:8.4 +tasks: + bad: + stack: ["php@8.4"]`, + expectErrorMessage: `linter errors: + - tasks.bad.type: type cannot be empty +linter warnings: + - tasks.bad.stack: 'stack' is only used with a composable image type`, }, { name: "stack_with_non_composable_type", diff --git a/internal/lint/yaml.go b/internal/lint/yaml.go index fb641bb8f..017e4fa3c 100644 --- a/internal/lint/yaml.go +++ b/internal/lint/yaml.go @@ -25,7 +25,7 @@ func CheckYAMLSchema(content string, schema *gojsonschema.Schema) *Result { } if !schemaResult.Valid() { for _, e := range schemaResult.Errors() { - result.AddError(e.Field(), e.Description()) + result.AddError(scopePath("", e.Field()), e.Description()) } } @@ -54,10 +54,10 @@ func CheckSchemaScoped(data any, schema *gojsonschema.Schema, pathPrefix string) // scopePath joins a path prefix and a schema field path. func scopePath(prefix, field string) string { switch { - case prefix == "": - return field case field == "" || field == "(root)": return prefix + case prefix == "": + return field default: return prefix + ": " + field } From daf0268ef5b05ee192842359b6abd7bcd8cce7eb Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:20:01 +0100 Subject: [PATCH 28/36] fix(lint): count service mounts and accept PCRE named groups - A service used only through a `source: service` mount (e.g. network storage), on an application, worker or task, is no longer reported as having no relationship. - Web location rules are compiled in regexp2's RE2 mode, which adds the PCRE-style `(?P...)` group syntax that Nginx accepts. Found by codex review. Co-Authored-By: Claude Opus 5.5 --- internal/lint/config_schema.go | 23 ++++++++++++++--------- internal/lint/relationships.go | 19 +++++++++++++++++++ internal/lint/relationships_test.go | 24 ++++++++++++++++++++++++ internal/lint/web.go | 5 +++-- internal/lint/web_test.go | 14 ++++++++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index 035a2162b..4fa9218b7 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -31,7 +31,8 @@ type Config struct { Locations map[string]WebLocation `yaml:"locations,omitempty"` } `yaml:"web,omitempty"` - Relationships map[string]any `yaml:"relationships,omitempty"` + Relationships map[string]any `yaml:"relationships,omitempty"` + Mounts map[string]Mount `yaml:"mounts,omitempty"` Crons map[string]struct { Commands struct { @@ -41,8 +42,9 @@ type Config struct { } `yaml:"crons,omitempty"` Workers map[string]struct { - Type string `yaml:"type,omitempty"` - Authorizations []Authorization `yaml:"authorizations,omitempty"` + Type string `yaml:"type,omitempty"` + Authorizations []Authorization `yaml:"authorizations,omitempty"` + Mounts map[string]Mount `yaml:"mounts,omitempty"` Commands struct { PreStart string `yaml:"pre_start,omitempty"` @@ -89,12 +91,9 @@ type Task struct { Deploy string `yaml:"deploy,omitempty"` } `yaml:"hooks,omitempty"` - Relationships map[string]any `yaml:"relationships,omitempty"` - Mounts map[string]struct { - Source string `yaml:"source,omitempty"` - Service string `yaml:"service,omitempty"` - } `yaml:"mounts,omitempty"` - Authorizations []Authorization `yaml:"authorizations,omitempty"` + Relationships map[string]any `yaml:"relationships,omitempty"` + Mounts map[string]Mount `yaml:"mounts,omitempty"` + Authorizations []Authorization `yaml:"authorizations,omitempty"` // fields lists the keys set on the task, to check that "base" is used alone. fields []string @@ -112,6 +111,12 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { return nil } +// Mount is a writable directory in a container. +type Mount struct { + Source string `yaml:"source,omitempty"` + Service string `yaml:"service,omitempty"` +} + // Authorization grants a workload access to the Upsun API at runtime. type Authorization struct { Type string `yaml:"type"` diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go index 09d6e3d79..4db7dee0d 100644 --- a/internal/lint/relationships.go +++ b/internal/lint/relationships.go @@ -75,6 +75,25 @@ func CheckRelationships(cfg *Config) *Result { checkRelationships("task", taskName, "tasks."+taskName, cfg.Tasks[taskName].Relationships) } + // A service can also be used through a mount (e.g. network storage). + linkMounts := func(mounts map[string]Mount) { + for _, m := range mounts { + if m.Source == "service" && m.Service != "" { + linkedServices[m.Service] = struct{}{} + } + } + } + for appName := range cfg.Applications { + app := cfg.Applications[appName] + linkMounts(app.Mounts) + for _, w := range app.Workers { + linkMounts(w.Mounts) + } + } + for taskName := range cfg.Tasks { + linkMounts(cfg.Tasks[taskName].Mounts) + } + for name := range cfg.Services { if _, linked := linkedServices[name]; !linked { result.AddError("services."+name, fmt.Sprintf("no application or task has a relationship to service '%s'", name)) diff --git a/internal/lint/relationships_test.go b/internal/lint/relationships_test.go index 0c8fd021f..25169fce8 100644 --- a/internal/lint/relationships_test.go +++ b/internal/lint/relationships_test.go @@ -139,6 +139,30 @@ applications: services: database: type: mariadb:11.4`, + }, + { + name: "service_used_by_mounts", + // A network-storage service used only through mounts is not unused. + content: ` +applications: + app: + type: php:8.4 + mounts: + /files: + source: service + service: files +tasks: + agent: + type: python:3.14 + mounts: + /shared: + source: service + service: shared +services: + files: + type: network-storage:2.0 + shared: + type: network-storage:2.0`, }, { name: "task_relationships", diff --git a/internal/lint/web.go b/internal/lint/web.go index 60d073b0c..99c27e440 100644 --- a/internal/lint/web.go +++ b/internal/lint/web.go @@ -78,8 +78,9 @@ func CheckWebConfig(cfg *Config) *Result { continue } - // Use regexp2 to validate PCRE regex syntax (Nginx uses PCRE) - _, err := regexp2.Compile(rulePattern) + // Nginx uses PCRE. regexp2 is closer to it than RE2, and its RE2 mode + // adds the (?P...) group syntax. + _, err := regexp2.Compile(rulePattern, regexp2.RE2) if err != nil { path := "applications." + appName + ".web.locations[\"" + locName + "\"].rules" result.AddError(path, "invalid regular expression: "+err.Error()) diff --git a/internal/lint/web_test.go b/internal/lint/web_test.go index c195ddfd3..2cf0a5652 100644 --- a/internal/lint/web_test.go +++ b/internal/lint/web_test.go @@ -16,6 +16,20 @@ func TestCheckWebConfig(t *testing.T) { wantErr bool errMatch string }{ + { + name: "rule with a PCRE named group", + content: ` +applications: + app1: + type: php:8.4 + web: + locations: + "/": + rules: + '(?P.*)\.css$': + allow: true +`, + }, { name: "location key without leading slash", content: ` From b92d97b6753dab018f88200ce8ad90b85f53e72b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:29:35 +0100 Subject: [PATCH 29/36] fix(lint): drop the missing start command warning A start command is optional: without one nothing runs, and the application can still serve static files, including a single-page app that falls back to `passthru: /index.html`. The linter cannot tell whether an application needs to run a process, so the warning was a false positive for static sites. Co-Authored-By: Claude Opus 5.5 --- internal/lint/config_schema.go | 5 +-- internal/lint/scripts.go | 29 ------------ internal/lint/scripts_test.go | 82 ++++------------------------------ 3 files changed, 11 insertions(+), 105 deletions(-) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index 4fa9218b7..6a6ecada4 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -71,9 +71,8 @@ type Config struct { // WebLocation configures how requests under a web location are served. type WebLocation struct { - Root string `yaml:"root,omitempty"` - Passthru any `yaml:"passthru,omitempty"` // A boolean or a URL path. - Rules map[string]any `yaml:"rules,omitempty"` + Root string `yaml:"root,omitempty"` + Rules map[string]any `yaml:"rules,omitempty"` } // Task is an on-demand, run-to-completion workload (Flex only). diff --git a/internal/lint/scripts.go b/internal/lint/scripts.go index 4fbbcca17..eb66f7ffd 100644 --- a/internal/lint/scripts.go +++ b/internal/lint/scripts.go @@ -15,14 +15,6 @@ func CheckScripts(cfg *Config) *Result { app := cfg.Applications[appName] keyPrefix := "applications." + appName + "." - // Warn if the start command is not set for non-PHP applications, unless - // they only serve static files. - // Skip composable applications for now as they could potentially also use a PHP-FPM default. // TODO check this - if app.Web.Commands.Start == "" && !strings.HasPrefix(app.Type, "php:") && - !strings.HasPrefix(app.Type, "composable:") && !servesStaticOnly(app.Web.Locations) { - result.AddWarning(keyPrefix+"web.commands.start", "a start command is needed for non-PHP applications") - } - // Group all scripts for shell syntax checking. scripts[keyPrefix+"hooks.build"] = app.Hooks.Build scripts[keyPrefix+"hooks.deploy"] = app.Hooks.Deploy @@ -62,24 +54,3 @@ func CheckScripts(cfg *Config) *Result { return result } - -// servesStaticOnly reports whether web locations are defined and none passes -// requests through to the application. -func servesStaticOnly(locations map[string]WebLocation) bool { - if len(locations) == 0 { - return false - } - for _, l := range locations { - switch p := l.Passthru.(type) { - case bool: - if p { - return false - } - case string: - if p != "" { - return false - } - } - } - return true -} diff --git a/internal/lint/scripts_test.go b/internal/lint/scripts_test.go index ce32d4f3b..5589c371c 100644 --- a/internal/lint/scripts_test.go +++ b/internal/lint/scripts_test.go @@ -10,10 +10,9 @@ import ( func TestCheckScripts(t *testing.T) { tests := []struct { - name string - yamlContent string - expectErrorMessage string - expectWarningMessage string + name string + yamlContent string + expectErrorMessage string }{ { name: "all valid scripts", @@ -160,19 +159,9 @@ applications: {}`, expectErrorMessage: "failed to parse YAML: yaml: did not find expected node content", }, { - name: "missing start command for non-PHP application", - yamlContent: ` -applications: - app1: - type: "nodejs:20" - hooks: - build: "echo 'Building app1'" -`, - expectWarningMessage: "linter warnings:\n - applications.app1.web.commands.start: " + - "a start command is needed for non-PHP applications", - }, - { - name: "static site without start command - no warning", + // A start command is optional: without one, nothing runs, and the + // application can still serve static files. + name: "static single-page application without start command", yamlContent: ` applications: app1: @@ -180,57 +169,9 @@ applications: web: locations: "/": - root: dist + root: dist/client + passthru: /index.html scripts: false - passthru: false -`, - }, - { - name: "non-PHP application with passthru and no start command", - yamlContent: ` -applications: - app1: - type: "nodejs:24" - web: - locations: - "/": - root: dist - passthru: true -`, - expectWarningMessage: "linter warnings:\n - applications.app1.web.commands.start: " + - "a start command is needed for non-PHP applications", - }, - { - name: "PHP application without start command - no warning", - yamlContent: ` -applications: - app1: - type: "php:8.2" - hooks: - build: "echo 'Building PHP app'" -`, - }, - { - name: "composable application without start command - no warning", - yamlContent: ` -applications: - app1: - type: "composable:nginx" - hooks: - build: "echo 'Building composable app'" -`, - }, - { - name: "non-PHP application with start command - no warning", - yamlContent: ` -applications: - app1: - type: "nodejs:20" - hooks: - build: "echo 'Building app1'" - web: - commands: - start: "node server.js" `, }, } @@ -251,12 +192,7 @@ applications: assert.False(t, result.HasErrors()) } - if tt.expectWarningMessage != "" { - assert.True(t, result.HasWarnings()) - assert.Equal(t, tt.expectWarningMessage, result.Error()) - } else { - assert.False(t, result.HasWarnings()) - } + assert.False(t, result.HasWarnings(), "unexpected warnings: %s", result) }) } } From 6b5cdfb6b4c0d3d98ff0d6ec640c6561833f7b5a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:45:03 +0100 Subject: [PATCH 30/36] fix(lint): allow Fixed operations and warn on unused services - `operations` (runtime operations) is allowed in Fixed application config, as it already was in Flex config. - A service that no application or task uses is a warning rather than an error: the platform deploys it, and only logs a warning itself. Co-Authored-By: Claude Opus 5.5 --- internal/lint/fixed_test.go | 13 +++++++ internal/lint/relationships.go | 2 +- internal/lint/relationships_test.go | 1 + .../lint/schema/platformsh.application.json | 38 +++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index 0b21744f5..4d9accd11 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -31,6 +31,19 @@ relationships: }, wantNoErr: true, }, + { + name: "app with runtime operations", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +operations: + clear-cache: + role: admin + commands: + start: php bin/console cache:clear`, + }, + wantNoErr: true, + }, { name: "app with env authorization", files: map[string]string{ diff --git a/internal/lint/relationships.go b/internal/lint/relationships.go index 4db7dee0d..37e0aeed8 100644 --- a/internal/lint/relationships.go +++ b/internal/lint/relationships.go @@ -96,7 +96,7 @@ func CheckRelationships(cfg *Config) *Result { for name := range cfg.Services { if _, linked := linkedServices[name]; !linked { - result.AddError("services."+name, fmt.Sprintf("no application or task has a relationship to service '%s'", name)) + result.AddWarning("services."+name, fmt.Sprintf("no application or task has a relationship to service '%s'", name)) } } diff --git a/internal/lint/relationships_test.go b/internal/lint/relationships_test.go index 25169fce8..01fdfc36a 100644 --- a/internal/lint/relationships_test.go +++ b/internal/lint/relationships_test.go @@ -59,6 +59,7 @@ services: foo: {}`, expectErrorMessage: "linter errors:" + "\n - services.foo: duplicate name found: 'foo' in 'services' (previous in 'applications')" + + "\nlinter warnings:" + "\n - services.foo: no application or task has a relationship to service 'foo'", }, { diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index 81f0d531f..2b0c171f8 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -1029,6 +1029,44 @@ ], "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." + } + }, + "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" + ] + } + }, + "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": {} } }, "anyOf": [ From 9d2b5f923d3bcfc2d2123cfc4a8d646157b9a9a5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 21:54:52 +0100 Subject: [PATCH 31/36] feat(lint): resolve YAML tags and report files and line numbers - The `!include` (yaml, string, binary and archive types), `!file` and `!archive` tags are resolved as the platform does: paths are relative to the file, may not leave the project, and recursive includes are rejected. Problems are reported at the tag. - Each issue now has a file and line, found by following its path through the parsed YAML of the file that defines the entry (route keys may contain dots). Content from an included file is reported at the `!include` tag. The JSON output gains `file` and `line` fields. - The text output groups issues by file, with each line number and path on one line and the message below, instead of one long line each. - Schema messages no longer repeat the path. Co-Authored-By: Claude Opus 5.5 --- commands/lint.go | 70 ++++++- commands/lint_test.go | 36 ++++ internal/lint/fixed.go | 187 +++++++++--------- internal/lint/fixed_test.go | 2 +- internal/lint/linter_test.go | 3 +- internal/lint/merge.go | 54 +++--- internal/lint/merge_test.go | 13 +- internal/lint/normalize.go | 49 ++--- internal/lint/normalize_test.go | 63 +++++- internal/lint/result.go | 22 ++- internal/lint/source.go | 329 ++++++++++++++++++++++++++++++++ internal/lint/source_test.go | 132 +++++++++++++ internal/lint/yaml.go | 52 ++--- 13 files changed, 813 insertions(+), 199 deletions(-) create mode 100644 internal/lint/source.go create mode 100644 internal/lint/source_test.go diff --git a/commands/lint.go b/commands/lint.go index 57bbc8ce7..1aa3554d3 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -1,12 +1,15 @@ package commands import ( + "cmp" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" + "slices" + "strconv" "strings" "unicode" @@ -143,8 +146,8 @@ func printLintResult(cmd *cobra.Command, result *lint.Result, format string) err } w := cmd.ErrOrStderr() - printIssueSection(w, color.New(color.FgRed, color.Bold), "Linter errors:", result.ErrorLines()) - printIssueSection(w, color.New(color.FgYellow, color.Bold), "Linter warnings:", result.WarningLines()) + printIssues(w, color.New(color.FgRed, color.Bold), "Errors", result.Errors) + printIssues(w, color.New(color.FgYellow, color.Bold), "Warnings", result.Warnings) if result.HasErrors() { return errLintFailed } @@ -154,12 +157,63 @@ func printLintResult(cmd *cobra.Command, result *lint.Result, format string) err return nil } -// printIssueSection prints a colored heading followed by the issue lines in the -// default color. It is a no-op when there are no lines. -func printIssueSection(w io.Writer, heading *color.Color, title string, lines []string) { - if len(lines) == 0 { +// printIssues prints a colored heading followed by the issues grouped by file, +// each with its line number and path, and the message below. For example: +// +// Errors: +// .upsun/config.yaml +// 28 applications.app.authorizations.0.action +// authorization type 'env' only allows the action 'view' +// +// It is a no-op when there are no issues. +func printIssues(w io.Writer, heading *color.Color, title string, issues []lint.Issue) { + if len(issues) == 0 { return } - fmt.Fprintln(w, heading.Sprint(title)) - fmt.Fprintln(w, strings.Join(lines, "\n")) + sorted := slices.Clone(issues) + slices.SortStableFunc(sorted, func(a, b lint.Issue) int { + return cmp.Or(cmp.Compare(a.File, b.File), cmp.Compare(a.Line, b.Line), + cmp.Compare(a.Path, b.Path), cmp.Compare(a.Message, b.Message)) + }) + + fmt.Fprintln(w, heading.Sprint(title+":")) + for len(sorted) > 0 { + n := 1 + for n < len(sorted) && sorted[n].File == sorted[0].File { + n++ + } + group := sorted[:n] + sorted = sorted[n:] + indent := " " + if file := group[0].File; file != "" { + fmt.Fprintln(w, " "+color.New(color.Bold).Sprint(file)) + indent = " " + } + // Right-align the line numbers within the file. + width := 0 + for _, issue := range group { + if issue.Line > 0 { + width = max(width, len(strconv.Itoa(issue.Line))) + } + } + msgIndent := indent + " " + if width > 0 { + msgIndent = indent + strings.Repeat(" ", width+2) + } + for _, issue := range group { + var line string + if width > 0 { + line = strings.Repeat(" ", width+2) + if issue.Line > 0 { + line = color.New(color.Faint).Sprintf("%*d", width, issue.Line) + " " + } + } + if issue.Path == "" { + fmt.Fprintln(w, indent+line+issue.Message) + continue + } + fmt.Fprintln(w, indent+line+color.CyanString(issue.Path)) + fmt.Fprintln(w, msgIndent+issue.Message) + } + } } diff --git a/commands/lint_test.go b/commands/lint_test.go index eed57d71b..78d8b4824 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -6,6 +6,7 @@ import ( "io" "testing" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -57,3 +58,38 @@ func TestPrintLintResult_JSONWithIssues(t *testing.T) { {Path: "applications.app1.web.commands.start", Message: "a start command is needed"}, }, decoded.Warnings) } + +//nolint:lll +func TestPrintLintResult_Text(t *testing.T) { + color.NoColor = true + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetErr(&out) + + result := &lint.Result{} + result.Errors = []lint.Issue{ + {File: ".upsun/config.yaml", Line: 632, Path: "applications.b.mounts.m.source", Message: "must be one of the following"}, + {File: ".upsun/config.yaml", Line: 28, Path: "applications.a.type", Message: "type not found"}, + {Path: "applications", Message: "no application configuration found"}, + {File: ".upsun/tasks.yaml", Message: "unknown top-level key"}, + } + result.Warnings = []lint.Issue{ + {File: "sub/.upsun", Message: "this .upsun directory is not at the project root and will be ignored"}, + } + + require.ErrorIs(t, printLintResult(cmd, result, "text"), errLintFailed) + assert.Equal(t, `Errors: + applications + no application configuration found + .upsun/config.yaml + 28 applications.a.type + type not found + 632 applications.b.mounts.m.source + must be one of the following + .upsun/tasks.yaml + unknown top-level key +Warnings: + sub/.upsun + this .upsun directory is not at the project root and will be ignored +`, out.String()) +} diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index 43e00fe2e..78808e6a6 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -1,7 +1,9 @@ package lint import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -27,21 +29,21 @@ var flexTopKeys = []string{keyApplications, keyServices, keyRoutes, keyTasks} // cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus // optional cfg.dir/routes.yaml and cfg.dir/services.yaml. func lintFixed(dir string, cfg fixedNames) (*Result, error) { - result := &Result{} + l := &fixedLoader{dir: dir, fsys: os.DirFS(dir), result: &Result{}, sources: sourceIndex{}} - apps, err := loadFixedApplications(dir, cfg, result) + apps, err := l.loadApplications(cfg) if err != nil { return nil, err } - - services, err := loadFixedSection(dir, cfg.dir, keyServices, schema.LoadServices, result) + services, err := l.loadSection(cfg.dir, keyServices, schema.LoadServices) if err != nil { return nil, err } - routes, err := loadFixedSection(dir, cfg.dir, keyRoutes, schema.LoadRoutes, result) + routes, err := l.loadSection(cfg.dir, keyRoutes, schema.LoadRoutes) if err != nil { return nil, err } + result := l.result if len(apps) == 0 && !result.HasErrors() { result.AddError("", "no application configuration found") @@ -73,126 +75,154 @@ func lintFixed(dir string, cfg fixedNames) (*Result, error) { if err != nil { return nil, err } + l.sources.locate(checks) result.Merge(checks) return result, nil } -// loadFixedApplications collects applications from per-app config files and +// fixedLoader reads Fixed-style files, collecting issues and where each +// application, service and route is defined. +type fixedLoader struct { + dir string + fsys fs.FS + result *Result + sources sourceIndex +} + +// load reads a YAML file (an absolute path under the project), reporting a +// problem as an issue and returning a nil node. +func (l *fixedLoader) load(abs string) (file string, node *yaml.Node) { + file = filepath.ToSlash(relTo(l.dir, abs)) + node, err := loadYAML(l.fsys, file) + var srcErr *sourceError + if errors.As(err, &srcErr) { + l.result.Errors = append(l.result.Errors, srcErr.issue()) + return file, nil + } else if err != nil { + l.result.Errors = append(l.result.Errors, Issue{File: file, Message: err.Error()}) + return file, nil + } + return file, node +} + +// check validates data against a schema, locating issues within node. +func (l *fixedLoader) check(data any, sch *gojsonschema.Schema, key string, src source) { + checked := CheckSchemaAt(data, sch, key) + sourceIndex{key: src}.locate(checked) + l.result.Merge(checked) +} + +// loadApplications collects applications from per-app config files and // cfg.dir/applications.yaml, validating each against the application schema. -func loadFixedApplications(dir string, cfg fixedNames, result *Result) (map[string]any, error) { +func (l *fixedLoader) loadApplications(cfg fixedNames) (map[string]any, error) { appSchema, err := schema.LoadApplication() if err != nil { return nil, fmt.Errorf("failed to load application schema: %w", err) } apps := map[string]any{} - sources := map[string]string{} - add := func(name, source string, data map[string]any) { - if _, dup := apps[name]; dup { - result.AddError(source, fmt.Sprintf( - "duplicate application name %q (already defined in %s)", name, sources[name])) + add := func(name string, data map[string]any, src source) { + key := keyApplications + "." + name + if prev, dup := l.sources[key]; dup { + l.result.Errors = append(l.result.Errors, Issue{ + File: src.file, + Line: max(src.line, src.node.Line), + Message: fmt.Sprintf("duplicate application name %q (already defined in %s)", name, prev.file), + }) return } + l.check(data, appSchema, key, src) apps[name] = data - sources[name] = source + l.sources[key] = src + } + decodeApp := func(file string, node *yaml.Node) (map[string]any, bool) { + var data map[string]any + if node.Kind != yaml.MappingNode || node.Decode(&data) != nil { + l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, + Message: "application must be a map"}) + return nil, false + } + return data, true } // Individual per-app config files (e.g. .platform.app.yaml). - for _, abs := range findFixedAppFiles(dir, cfg.app) { - source := relTo(dir, abs) - data, err := readYAMLMap(abs) - if err != nil { - result.AddError(source, err.Error()) + for _, abs := range findFixedAppFiles(l.dir, cfg.app) { + file, node := l.load(abs) + if node == nil { continue } - if data == nil { + data, ok := decodeApp(file, node) + if !ok { continue } if hasAnyKey(data, flexTopKeys) { - result.AddError(source, "this looks like Flex configuration in a Fixed-style file") + l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, + Message: "this looks like Flex configuration in a Fixed-style file"}) continue } - result.Merge(CheckSchemaScoped(data, appSchema, source)) - add(fixedAppName(data), source, data) + add(fixedAppName(data), data, source{file: file, node: node}) } // cfg.dir/applications.yaml (a list of apps, or a map keyed by app name). - appsFile, ok := firstExistingYAML(dir, cfg.dir, keyApplications) + appsFile, ok := firstExistingYAML(l.dir, cfg.dir, keyApplications) if !ok { return apps, nil } - label := relTo(dir, appsFile) - raw, err := os.ReadFile(appsFile) - if err != nil { - result.AddError(label, err.Error()) + file, node := l.load(appsFile) + if node == nil { return apps, nil } - var doc any - if err := yaml.Unmarshal(raw, &doc); err != nil { - result.AddError(label, interpretYAMLError(err)) - return apps, nil - } - switch v := doc.(type) { - case []any: - for i, item := range v { - data, ok := toStringMap(item) - if !ok { - result.AddError(fmt.Sprintf("%s[%d]", label, i), "application must be a map") - continue + switch node.Kind { + case yaml.SequenceNode: + for _, item := range node.Content { + if data, ok := decodeApp(file, item); ok { + add(fixedAppName(data), data, source{file: file, node: item}) } - src := fmt.Sprintf("%s[%d]", label, i) - result.Merge(CheckSchemaScoped(data, appSchema, src)) - add(fixedAppName(data), src, data) } - case map[string]any: - for name, item := range v { + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + name, item := node.Content[i].Value, node.Content[i+1] if strings.HasPrefix(name, ".") { continue } - data, ok := toStringMap(item) + data, ok := decodeApp(file, item) if !ok { - result.AddError(label+": "+name, "application must be a map") continue } - src := label + ": " + name // In map form the name comes from the key and must not be set in the value. if _, ok := data["name"]; ok { - result.AddError(src, "the application name must not be set here; it is taken from the key") + l.result.Errors = append(l.result.Errors, Issue{File: file, Line: lineOf(item, ".name"), + Message: "the application name must not be set here; it is taken from the key"}) continue } data["name"] = name - result.Merge(CheckSchemaScoped(data, appSchema, src)) - add(name, src, data) + add(name, data, source{file: file, line: node.Content[i].Line, node: item}) } - case nil: - // Empty file. default: - result.AddError(label, "contents must be a YAML list or map") + l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, + Message: "contents must be a YAML list or map"}) } return apps, nil } -// loadFixedSection reads and schema-validates an optional cfg.dir/.{yaml,yml}, +// loadSection reads and schema-validates an optional /
.{yaml,yml}, // returning its decoded map (keyed by name/URL). -func loadFixedSection( - dir, configDir, base string, +func (l *fixedLoader) loadSection( + configDir, section string, loadSchema func() (*gojsonschema.Schema, error), - result *Result, ) (map[string]any, error) { - path, ok := firstExistingYAML(dir, configDir, base) + abs, ok := firstExistingYAML(l.dir, configDir, section) if !ok { return nil, nil } - label := relTo(dir, path) - raw, err := os.ReadFile(path) - if err != nil { - result.AddError(label, err.Error()) + file, node := l.load(abs) + if node == nil { return nil, nil } data := map[string]any{} - if err := yaml.Unmarshal(raw, &data); err != nil { - result.AddError(label, interpretYAMLError(err)) + if node.Kind != yaml.MappingNode || node.Decode(&data) != nil { + l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, Message: "contents must be a YAML map"}) return nil, nil } if len(data) == 0 { @@ -200,9 +230,15 @@ func loadFixedSection( } sch, err := loadSchema() if err != nil { - return nil, fmt.Errorf("failed to load %s schema: %w", base, err) + return nil, fmt.Errorf("failed to load %s schema: %w", section, err) } - result.Merge(CheckSchemaScoped(data, sch, label)) + l.sources[section] = source{file: file, node: node} + for i := 0; i+1 < len(node.Content); i += 2 { + l.sources[section+"."+node.Content[i].Value] = source{file: file, line: node.Content[i].Line, node: node.Content[i+1]} + } + checked := CheckSchemaAt(data, sch, section) + l.sources.locate(checked) + l.result.Merge(checked) return data, nil } @@ -214,18 +250,6 @@ func fixedAppName(data map[string]any) string { return "app" } -func readYAMLMap(path string) (map[string]any, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - data := map[string]any{} - if err := yaml.Unmarshal(raw, &data); err != nil { - return nil, fmt.Errorf("%s", interpretYAMLError(err)) - } - return data, nil -} - func hasAnyKey(m map[string]any, keys []string) bool { for _, k := range keys { if _, ok := m[k]; ok { @@ -235,13 +259,6 @@ func hasAnyKey(m map[string]any, keys []string) bool { return false } -// toStringMap asserts that v is a YAML map. yaml.v3 always decodes mappings to -// map[string]any, so a plain type assertion suffices. -func toStringMap(v any) (map[string]any, bool) { - m, ok := v.(map[string]any) - return m, ok -} - // relTo returns abs relative to dir, falling back to abs on error. func relTo(dir, abs string) string { if rel, err := filepath.Rel(dir, abs); err == nil { diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index 4d9accd11..bdca8ca6a 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -140,7 +140,7 @@ type: "php:999"`, files: map[string]string{ ".platform.app.yaml": `type: "php:8.3"`, }, - wantErrors: []string{".platform.app.yaml: name is required"}, + wantErrors: []string{".platform.app.yaml:1: applications.app: name is required"}, }, } diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 2476f78a2..7d3ecd506 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -107,7 +107,6 @@ services: } } -//nolint:lll func TestLint_Schema(t *testing.T) { cases := []struct { name string @@ -210,7 +209,7 @@ tasks: start: python agent.py `, wantErrors: `linter errors: - - applications.myapp.authorizations.0.type: applications.myapp.authorizations.0.type must be one of the following: "env", "task" + - applications.myapp.authorizations.0.type: must be one of the following: "env", "task" - applications.myapp.authorizations.0: Additional property scope is not allowed - tasks.myagent.run.timeout: Must be less than or equal to 86400 - tasks.myagent: Additional property web is not allowed`, diff --git a/internal/lint/merge.go b/internal/lint/merge.go index 4c4193d47..6f160c958 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -37,45 +37,53 @@ func findFlexConfigFiles(fsys fs.FS, dir, configDir string) ([]string, error) { // mergeConfigFiles merges the given YAML files, combining the top-level sections // (flexTopKeys). A key duplicated across files, or an unknown top-level key, is -// an error. It returns the merged YAML and the file that defines each entry, -// keyed by "
." (e.g. "applications.app"). -func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources map[string]string, err error) { +// an error. It returns the merged YAML and where each entry is defined. +func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources sourceIndex, err error) { sections := map[string]map[string]any{} - sources = map[string]string{} + sources = sourceIndex{} for _, file := range files { - b, err := fs.ReadFile(fsys, file) + doc, err := loadYAML(fsys, file) if err != nil { - return "", nil, fmt.Errorf("failed to read %s: %w", file, err) + return "", nil, err } - var doc map[string]any - if err := yaml.Unmarshal(b, &doc); err != nil { - return "", nil, fmt.Errorf("failed to parse YAML in %s: %w", file, err) + if doc == nil { + continue } - for key, section := range doc { + if doc.Kind != yaml.MappingNode { + return "", nil, &sourceError{file: file, line: doc.Line, msg: "contents should be a YAML map"} + } + for i := 0; i+1 < len(doc.Content); i += 2 { + keyNode, section := doc.Content[i], doc.Content[i+1] + key := keyNode.Value if strings.HasPrefix(key, ".") { continue } if !slices.Contains(flexTopKeys, key) { - return "", nil, fmt.Errorf("unknown top-level key '%s' in %s: it must be one of: %s", - key, file, strings.Join(flexTopKeys, ", ")) + return "", nil, &sourceError{file: file, line: keyNode.Line, msg: fmt.Sprintf( + "unknown top-level key '%s': it must be one of: %s", key, strings.Join(flexTopKeys, ", "))} } - if section == nil { + if section.Tag == "!!null" { continue } - sectionMap, ok := section.(map[string]any) - if !ok { - return "", nil, fmt.Errorf("%s in %s is not a map", key, file) + if section.Kind != yaml.MappingNode { + return "", nil, &sourceError{file: file, line: keyNode.Line, msg: key + " is not a map"} } if sections[key] == nil { sections[key] = map[string]any{} } - for k, v := range sectionMap { - if _, exists := sections[key][k]; exists { - return "", nil, fmt.Errorf("duplicate key '%s' in section '%s' found in file %s (already defined in %s)", - k, key, file, sources[key+"."+k]) + for j := 0; j+1 < len(section.Content); j += 2 { + nameNode, valueNode := section.Content[j], section.Content[j+1] + name := nameNode.Value + if prev, exists := sources[key+"."+name]; exists { + return "", nil, &sourceError{file: file, line: nameNode.Line, msg: fmt.Sprintf( + "duplicate key '%s' in section '%s' (already defined in %s)", name, key, prev.file)} + } + var v any + if err := valueNode.Decode(&v); err != nil { + return "", nil, &sourceError{file: file, line: nameNode.Line, msg: err.Error()} } - sections[key][k] = v - sources[key+"."+k] = file + sections[key][name] = v + sources[key+"."+name] = source{file: file, line: nameNode.Line, node: valueNode} } } } @@ -94,7 +102,7 @@ func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources map[st // getMergedConfigFiles merges all Flex config files in the given directory. // It is a convenience wrapper for findFlexConfigFiles + mergeConfigFiles. -func getMergedConfigFiles(fsys fs.FS, dir, configDir string) (merged string, sources map[string]string, err error) { +func getMergedConfigFiles(fsys fs.FS, dir, configDir string) (merged string, sources sourceIndex, err error) { files, err := findFlexConfigFiles(fsys, dir, configDir) if err != nil { return "", nil, err diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go index cb8ec59cd..06aacce7e 100644 --- a/internal/lint/merge_test.go +++ b/internal/lint/merge_test.go @@ -50,11 +50,15 @@ routes: require.Contains(t, merged, "foo") require.Contains(t, merged, "db") require.Contains(t, merged, "/:") + files := map[string]string{} + for key, src := range sources { + files[key] = src.file + } assert.Equal(t, map[string]string{ "applications.foo": "a.yaml", "routes./": "a.yaml", "services.db": "b.yml", - }, sources) + }, files) } func TestMergeConfigFiles_Tasks(t *testing.T) { @@ -67,7 +71,7 @@ func TestMergeConfigFiles_Tasks(t *testing.T) { merged, sources, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yaml"}) require.NoError(t, err) require.Contains(t, merged, "agent") - assert.Equal(t, "b.yaml", sources["tasks.agent"]) + assert.Equal(t, source{file: "b.yaml", line: 2, node: sources["tasks.agent"].node}, sources["tasks.agent"]) } func TestMergeConfigFiles_TopLevelKeys(t *testing.T) { @@ -77,7 +81,7 @@ func TestMergeConfigFiles_TopLevelKeys(t *testing.T) { wantErr string }{ {"dot-prefixed key is ignored", ".anchors: {a: 1}\napplications:\n foo: {type: go}", ""}, - {"unknown key", "applications:\n foo: {type: go}\nworkers: {}", "unknown top-level key 'workers' in a.yaml"}, + {"unknown key", "applications:\n foo: {type: go}\nworkers: {}", "a.yaml:3: unknown top-level key 'workers'"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -93,7 +97,6 @@ func TestMergeConfigFiles_TopLevelKeys(t *testing.T) { } } -//nolint:lll func TestMergeConfigFiles_DuplicateKey(t *testing.T) { fsys := fstest.MapFS{ "a.yaml": &fstest.MapFile{Data: []byte(`applications: @@ -103,7 +106,7 @@ func TestMergeConfigFiles_DuplicateKey(t *testing.T) { } _, _, err := mergeConfigFiles(fsys, []string{"a.yaml", "b.yml"}) require.Error(t, err) - require.Contains(t, err.Error(), "duplicate key 'foo' in section 'applications' found in file b.yml (already defined in a.yaml)") + require.Contains(t, err.Error(), "b.yml:2: duplicate key 'foo' in section 'applications' (already defined in a.yaml)") } func TestGetMergedConfigFiles_Success(t *testing.T) { diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 6d881e468..757e42fe2 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -1,6 +1,7 @@ package lint import ( + "errors" "fmt" "io/fs" "os" @@ -82,14 +83,19 @@ func CheckDir(dir string, vendor Vendor) (*Result, Style, error) { switch { case flexOK: content, sources, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) - if err != nil { - return nil, StyleFlex, err - } - result, err := CheckContent(content) - if err != nil { + var result *Result + var srcErr *sourceError + switch { + case errors.As(err, &srcErr): + result = &Result{Errors: []Issue{srcErr.issue()}} + case err != nil: return nil, StyleFlex, err + default: + if result, err = CheckContent(content); err != nil { + return nil, StyleFlex, err + } + sources.locate(result) } - attributeSources(result, sources) addStrayWarnings(result, dir, flexDirs, fixedSet) if fixedOK { result.AddWarning("", fmt.Sprintf( @@ -186,8 +192,10 @@ func addStrayWarnings(result *Result, root string, flexDirs []string, fixed []fi // A known config directory below the root (not a root-level copy, which // detection already handles) is ignored by the platform. if d.IsDir() && configDirs[d.Name()] && filepath.Dir(path) != root { - result.AddWarning(relTo(root, path), - fmt.Sprintf("this %s directory is not at the project root and will be ignored", d.Name())) + result.Warnings = append(result.Warnings, Issue{ + File: filepath.ToSlash(relTo(root, path)), + Message: fmt.Sprintf("this %s directory is not at the project root and will be ignored", d.Name()), + }) } }) } @@ -260,31 +268,6 @@ func yamlVariants(base string) []string { return []string{base + ".yaml", base + ".yml"} } -// attributeSources prefixes each issue path with the file that defines it, given -// the files keyed by "
." (see mergeConfigFiles). -func attributeSources(result *Result, sources map[string]string) { - attribute := func(issues []Issue) { - for i, issue := range issues { - // Route keys are URLs that may contain dots, so the longest match wins. - // Route issues may also use the form routes[""]. - var match string - for key := range sources { - section, name, _ := strings.Cut(key, ".") - for _, prefix := range []string{key, fmt.Sprintf("%s[%q]", section, name)} { - if (issue.Path == prefix || strings.HasPrefix(issue.Path, prefix+".")) && len(key) > len(match) { - match = key - } - } - } - if match != "" { - issues[i].Path = scopePath(sources[match], issue.Path) - } - } - } - attribute(result.Errors) - attribute(result.Warnings) -} - // isSeparateCheckout reports whether dir is a Git checkout other than a // submodule: a separate clone (a .git directory) or a linked worktree (a .git // file pointing into another repository's "worktrees" directory). These are not diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index 23a626473..b4dadd649 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -209,7 +209,7 @@ func TestLintDir_FlexSourceFiles(t *testing.T) { result, _, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, `Linter errors: - - .upsun/tasks.yaml: tasks.agent: Additional property extra is not allowed`, result.String()) + - .upsun/tasks.yaml:2: tasks.agent: Additional property extra is not allowed`, result.String()) // Semantic checks run once the schema passes. writeFile(t, filepath.Join(dir, ".upsun", "tasks.yaml"), `tasks: @@ -220,8 +220,8 @@ func TestLintDir_FlexSourceFiles(t *testing.T) { `) result, _, err = CheckDir(dir, upsunVendor()) require.NoError(t, err) - assert.Contains(t, result.String(), ".upsun/apps.yaml: applications.app.authorizations.0.resource: task 'missing' is not found") - assert.Contains(t, result.String(), `.upsun/routes.yaml: routes["https://{default}/api"].upstream: upstream target 'missing' does not exist`) + assert.Contains(t, result.String(), ".upsun/apps.yaml:6: applications.app.authorizations.0.resource: task 'missing' is not found") + assert.Contains(t, result.String(), `.upsun/routes.yaml:4: routes["https://{default}/api"].upstream: upstream target 'missing' does not exist`) } func TestLintDir_SkipsSeparateCheckouts(t *testing.T) { @@ -239,7 +239,7 @@ func TestLintDir_SkipsSeparateCheckouts(t *testing.T) { result, _, err := CheckDir(dir, upsunVendor()) require.NoError(t, err) assert.Equal(t, []Issue{{ - Path: filepath.Join("sub", ".upsun"), + File: "sub/.upsun", Message: "this .upsun directory is not at the project root and will be ignored", }}, result.Warnings) } @@ -257,3 +257,58 @@ func TestLintFixed_WorktreeAppFileIgnored(t *testing.T) { assert.Equal(t, StyleFixed, style) assert.False(t, result.HasErrors(), "unexpected errors: %s", result) } + +func TestLintDir_Includes(t *testing.T) { + t.Run("flex", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `applications: + app: !include apps/app.yaml + other: !include apps/missing.yaml +`) + writeFile(t, filepath.Join(dir, ".upsun", "apps", "app.yaml"), `type: "php:8.4" +hooks: + build: !include {type: string, path: ../build.sh} +`) + writeFile(t, filepath.Join(dir, ".upsun", "build.sh"), "set -e\ncomposer install\n") + + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, []Issue{{ + File: ".upsun/config.yaml", Line: 3, Message: "'apps/missing.yaml' doesn't exist in the repository", + }}, result.Errors) + + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), "applications:\n app: !include apps/app.yaml\n") + writeFile(t, filepath.Join(dir, ".upsun", "build.sh"), "set -e\ncomposer install (\n") + result, _, err = CheckDir(dir, upsunVendor()) + require.NoError(t, err) + require.Len(t, result.Errors, 1) + assert.Equal(t, ".upsun/config.yaml", result.Errors[0].File) + assert.Equal(t, 2, result.Errors[0].Line) + assert.Equal(t, "applications.app.hooks.build", result.Errors[0].Path) + }) + + t.Run("fixed", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "search", ".platform.app.yaml"), `name: search +type: "python:3.14" +hooks: + build: !include + type: string + path: build.sh +`) + writeFile(t, filepath.Join(dir, "search", "build.sh"), "pip install -r requirements.txt\n") + writeFile(t, filepath.Join(dir, ".platform", "routes.yaml"), `"https://{default}/": + type: upstream + upstream: "missing:http" +`) + + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + assert.Equal(t, []Issue{{ + File: ".platform/routes.yaml", + Line: 3, + Path: `routes["https://{default}/"].upstream`, + Message: "upstream target 'missing' does not exist, available targets: search", + }}, result.Errors) + }) +} diff --git a/internal/lint/result.go b/internal/lint/result.go index da5c127f4..7b6c01d9b 100644 --- a/internal/lint/result.go +++ b/internal/lint/result.go @@ -14,10 +14,20 @@ type Result struct { // Issue represents a single linting problem with its location in the content. type Issue struct { - Path string `json:"path"` // e.g., "applications.foo.type", "services.database" + File string `json:"file,omitempty"` // The file, relative to the project root, if known. + Line int `json:"line,omitempty"` // The line in the file, if known. + Path string `json:"path"` // e.g., "applications.foo.type", "services.database" Message string `json:"message"` } +// Location returns the file and line of the issue, e.g. ".upsun/config.yaml:12". +func (i Issue) Location() string { + if i.Line > 0 { + return fmt.Sprintf("%s:%d", i.File, i.Line) + } + return i.File +} + // AddError adds an error to the linter result. func (r *Result) AddError(path, message string) { r.Errors = append(r.Errors, Issue{ @@ -53,11 +63,13 @@ func issueLines(issues []Issue) []string { messages := make([]string, 0, len(issues)) for _, issue := range issues { - if issue.Path != "" { - messages = append(messages, fmt.Sprintf(" - %s: %s", issue.Path, issue.Message)) - } else { - messages = append(messages, fmt.Sprintf(" - %s", issue.Message)) + var parts []string + for _, part := range []string{issue.Location(), issue.Path, issue.Message} { + if part != "" { + parts = append(parts, part) + } } + messages = append(messages, " - "+strings.Join(parts, ": ")) } // Sort the issue messages for consistent ordering. diff --git a/internal/lint/source.go b/internal/lint/source.go new file mode 100644 index 000000000..79ab65e28 --- /dev/null +++ b/internal/lint/source.go @@ -0,0 +1,329 @@ +package lint + +import ( + "encoding/base64" + "fmt" + "io/fs" + "path" + "slices" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// sourceError is a problem found while loading a YAML file, at a line. +type sourceError struct { + file string + line int + msg string +} + +func (e *sourceError) Error() string { + if e.line > 0 { + return fmt.Sprintf("%s:%d: %s", e.file, e.line, e.msg) + } + return e.file + ": " + e.msg +} + +// issue converts the error to a lint issue. +func (e *sourceError) issue() Issue { + return Issue{File: e.file, Line: e.line, Message: e.msg} +} + +// loadYAML reads and parses the YAML file name in fsys, resolving the !include, +// !file and !archive tags as the platform does. It returns the document's +// content node, or nil for an empty document. +func loadYAML(fsys fs.FS, name string) (*yaml.Node, error) { + return loadYAMLIncluding(fsys, name, nil) +} + +func loadYAMLIncluding(fsys fs.FS, name string, including []string) (*yaml.Node, error) { + b, err := fs.ReadFile(fsys, name) + if err != nil { + return nil, &sourceError{file: name, msg: err.Error()} + } + var doc yaml.Node + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, &sourceError{file: name, msg: interpretYAMLError(err)} + } + if len(doc.Content) == 0 { + return nil, nil + } + root := doc.Content[0] + r := &tagResolver{fsys: fsys, file: name, including: append(including, name)} + if err := r.resolve(root); err != nil { + return nil, err + } + return root, nil +} + +// tagResolver resolves the repository tags in a file. Paths are relative to the +// file's directory and may not leave the project. +type tagResolver struct { + fsys fs.FS + file string + including []string +} + +func (r *tagResolver) resolve(node *yaml.Node) error { + switch node.Tag { + case "!file", "!archive": + return r.resolveReference(node) + case "!include": + return r.resolveInclude(node) + } + for _, child := range node.Content { + if err := r.resolve(child); err != nil { + return err + } + } + return nil +} + +// resolveReference checks a !file or !archive tag, which refers to a file or a +// directory, and replaces it with the path. +func (r *tagResolver) resolveReference(node *yaml.Node) error { + wantDir := node.Tag == "!archive" + if node.Kind != yaml.ScalarNode { + return r.errorf(node, "the `%s` tag value should be a path", node.Tag) + } + p, isDir, err := r.lookup(node, node.Value) + if err != nil { + return err + } + if wantDir && !isDir { + return r.errorf(node, "'%s' doesn't point to a directory", node.Value) + } else if !wantDir && isDir { + return r.errorf(node, "'%s' doesn't point to a file", node.Value) + } + setScalar(node, p) + return nil +} + +// resolveInclude replaces an !include tag with the content it refers to. +func (r *tagResolver) resolveInclude(node *yaml.Node) error { + opts := map[string]string{} + switch node.Kind { + case yaml.ScalarNode: + opts["path"], opts["type"] = node.Value, "yaml" + case yaml.MappingNode: + if err := node.Decode(&opts); err != nil { + return r.errorf(node, "invalid `!include` tag: %s", err) + } + default: + return r.errorf(node, "the `!include` tag value should be a scalar or a mapping") + } + rel, ok := opts["path"] + if !ok { + return r.errorf(node, "the `!include` tag must specify a `path`") + } + p, isDir, err := r.lookup(node, rel) + if err != nil { + return err + } + includeType, ok := opts["type"] + if !ok { + return r.errorf(node, "the `!include` tag must have a `type` specified") + } + types := []string{"archive", "binary", "string", "yaml"} + if !slices.Contains(types, includeType) { + return r.errorf(node, "`!include` type must be one of %s", strings.Join(types, ", ")) + } + if includeType == "archive" { + if !isDir { + return r.errorf(node, "`%s` doesn't point to a directory", rel) + } + setScalar(node, p) + return nil + } + if isDir { + return r.errorf(node, "`%s` doesn't point to a file", rel) + } + switch includeType { + case "yaml": + if slices.Contains(r.including, p) { + return r.errorf(node, "`%s` is included recursively", rel) + } + included, err := loadYAMLIncluding(r.fsys, p, r.including) + if err != nil { + return err + } + if included == nil { + setScalar(node, "") + node.Tag = "!!null" + return nil + } + // Issues within the included content are reported at the tag. + line, column := node.Line, node.Column + *node = *included + setPosition(node, line, column) + case "binary": + b, err := fs.ReadFile(r.fsys, p) + if err != nil { + return r.errorf(node, "cannot read file `%s`: %s", rel, err) + } + setScalar(node, "data:;base64,"+base64.StdEncoding.EncodeToString(b)) + default: // string + b, err := fs.ReadFile(r.fsys, p) + if err != nil { + return r.errorf(node, "cannot read file `%s`: %s", rel, err) + } + setScalar(node, string(b)) + } + return nil +} + +// lookup resolves a tag's path relative to the current file, returning its +// path in the project and whether it is a directory. +func (r *tagResolver) lookup(node *yaml.Node, rel string) (p string, isDir bool, err error) { + p = path.Join(path.Dir(r.file), rel) + if !fs.ValidPath(p) || strings.HasPrefix(rel, "/") { + return "", false, r.errorf(node, "'%s' is outside the project", rel) + } + fi, err := fs.Stat(r.fsys, p) + if err != nil { + return "", false, r.errorf(node, "'%s' doesn't exist in the repository", rel) + } + return p, fi.IsDir(), nil +} + +func (r *tagResolver) errorf(node *yaml.Node, format string, args ...any) error { + return &sourceError{file: r.file, line: node.Line, msg: fmt.Sprintf(format, args...)} +} + +// setScalar turns node into a plain string scalar, keeping its position. +func setScalar(node *yaml.Node, value string) { + node.Kind, node.Tag, node.Value, node.Style, node.Content = yaml.ScalarNode, "!!str", value, 0, nil +} + +// setPosition sets the position of node and all of its descendants. +func setPosition(node *yaml.Node, line, column int) { + node.Line, node.Column = line, column + for _, child := range node.Content { + setPosition(child, line, column) + } +} + +// source is where a configuration entry is defined. +type source struct { + file string + line int // The line of the entry's key, or 0 to use the node's line. + node *yaml.Node +} + +// sourceIndex maps configuration entries to where they are defined. Keys are +// "
." (e.g. "applications.app"), or "
" for a Fixed +// file holding a whole section (e.g. .platform/services.yaml). +type sourceIndex map[string]source + +// locate sets the file and line of issues that do not have them, by matching +// each issue's path to an entry and then to a node within it. +func (s sourceIndex) locate(result *Result) { + for _, issues := range [][]Issue{result.Errors, result.Warnings} { + for i := range issues { + if issues[i].File != "" { + continue + } + key, rest, ok := s.match(issues[i].Path) + if !ok { + continue + } + src := s[key] + issues[i].File = src.file + if issues[i].Line = lineOf(src.node, rest); rest == "" && src.line > 0 { + issues[i].Line = src.line + } + } + } +} + +// match returns the longest entry key that prefixes path, and the rest of the +// path. Route keys are URLs that may contain dots, and issues may refer to an +// entry as
[""]. +func (s sourceIndex) match(p string) (key, rest string, ok bool) { + for k := range s { + section, name, hasName := strings.Cut(k, ".") + prefixes := []string{k} + if hasName { + prefixes = append(prefixes, fmt.Sprintf("%s[%q]", section, name)) + } + for _, prefix := range prefixes { + r, found := strings.CutPrefix(p, prefix) + if found && (r == "" || r[0] == '.' || r[0] == '[') && len(k) > len(key) { + key, rest, ok = k, r, true + } + } + } + return key, rest, ok +} + +// lineOf follows a path such as ".web.locations[\"/\"].root" or +// ".authorizations.0" from node, and returns the line of the deepest part found. +func lineOf(node *yaml.Node, p string) int { + if node == nil { + return 0 + } + line := node.Line + for p != "" { + switch node.Kind { + case yaml.MappingNode: + var key string + if r, ok := strings.CutPrefix(p, `["`); ok { + end := strings.Index(r, `"]`) + if end < 0 { + return line + } + key, p = r[:end], r[end+2:] + } else { + p = strings.TrimPrefix(p, ".") + // Keys may contain dots, so the longest matching key wins. + for i := 0; i+1 < len(node.Content); i += 2 { + k := node.Content[i].Value + if (p == k || strings.HasPrefix(p, k+".") || strings.HasPrefix(p, k+"[")) && len(k) > len(key) { + key = k + } + } + if key == "" { + return line + } + p = p[len(key):] + } + value := mappingValue(node, key) + if value == nil { + return line + } + line, node = value.keyLine, value.node + case yaml.SequenceNode: + r := strings.TrimPrefix(p, ".") + end := strings.IndexAny(r, ".[") + if end < 0 { + end = len(r) + } + i, err := strconv.Atoi(r[:end]) + if err != nil || i < 0 || i >= len(node.Content) { + return line + } + node, p = node.Content[i], r[end:] + line = node.Line + default: + return line + } + } + return line +} + +type mappingEntry struct { + keyLine int + node *yaml.Node +} + +// mappingValue returns the value for key in a mapping node, or nil. +func mappingValue(node *yaml.Node, key string) *mappingEntry { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return &mappingEntry{keyLine: node.Content[i].Line, node: node.Content[i+1]} + } + } + return nil +} diff --git a/internal/lint/source_test.go b/internal/lint/source_test.go new file mode 100644 index 000000000..b26fea8a2 --- /dev/null +++ b/internal/lint/source_test.go @@ -0,0 +1,132 @@ +package lint + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestLoadYAML_Tags(t *testing.T) { + fsys := fstest.MapFS{ + ".upsun/config.yaml": {Data: []byte(`applications: + app: !include apps/app.yaml + other: !include + type: yaml + path: apps/other.yaml +`)}, + ".upsun/apps/app.yaml": {Data: []byte(`type: php:8.4 +hooks: + build: !include {type: string, path: ../scripts/build.sh} + deploy: !include {type: binary, path: ../scripts/build.sh} +source: + root: !archive ../../src +favicon: !file ../../src/favicon.ico +bundle: !include {type: archive, path: ../../src} +`)}, + ".upsun/apps/other.yaml": {Data: []byte("type: nodejs:24\n")}, + ".upsun/scripts/build.sh": {Data: []byte("set -e\nmake\n")}, + "src/favicon.ico": {Data: []byte("icon")}, + "src/index.php": {Data: []byte(" Date: Fri, 25 Sep 2026 22:01:27 +0100 Subject: [PATCH 32/36] fix(lint): confine includes to the project and follow YAML aliases - Files are opened through os.Root, so an !include through a symbolic link cannot read a file outside the project. - Aliases and merge keys ("<<") are followed when walking sections and entries, so `applications: *apps` and merged application definitions work again. - Duplicate keys within a file are reported again. Found by codex review. Co-Authored-By: Claude Opus 5.5 --- internal/lint/fixed.go | 23 +++++++----- internal/lint/merge.go | 10 ++--- internal/lint/normalize.go | 8 +++- internal/lint/normalize_test.go | 33 +++++++++++++++++ internal/lint/source.go | 65 ++++++++++++++++++++++++++++++--- internal/lint/source_test.go | 34 +++++++++++++++++ 6 files changed, 152 insertions(+), 21 deletions(-) diff --git a/internal/lint/fixed.go b/internal/lint/fixed.go index 78808e6a6..bcee5df3e 100644 --- a/internal/lint/fixed.go +++ b/internal/lint/fixed.go @@ -29,7 +29,12 @@ var flexTopKeys = []string{keyApplications, keyServices, keyRoutes, keyTasks} // cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus // optional cfg.dir/routes.yaml and cfg.dir/services.yaml. func lintFixed(dir string, cfg fixedNames) (*Result, error) { - l := &fixedLoader{dir: dir, fsys: os.DirFS(dir), result: &Result{}, sources: sourceIndex{}} + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + defer root.Close() + l := &fixedLoader{dir: dir, fsys: root.FS(), result: &Result{}, sources: sourceIndex{}} apps, err := l.loadApplications(cfg) if err != nil { @@ -137,7 +142,7 @@ func (l *fixedLoader) loadApplications(cfg fixedNames) (map[string]any, error) { } decodeApp := func(file string, node *yaml.Node) (map[string]any, bool) { var data map[string]any - if node.Kind != yaml.MappingNode || node.Decode(&data) != nil { + if resolveAlias(node).Kind != yaml.MappingNode || node.Decode(&data) != nil { l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, Message: "application must be a map"}) return nil, false @@ -172,7 +177,7 @@ func (l *fixedLoader) loadApplications(cfg fixedNames) (map[string]any, error) { if node == nil { return apps, nil } - switch node.Kind { + switch node = resolveAlias(node); node.Kind { case yaml.SequenceNode: for _, item := range node.Content { if data, ok := decodeApp(file, item); ok { @@ -180,8 +185,8 @@ func (l *fixedLoader) loadApplications(cfg fixedNames) (map[string]any, error) { } } case yaml.MappingNode: - for i := 0; i+1 < len(node.Content); i += 2 { - name, item := node.Content[i].Value, node.Content[i+1] + for _, entry := range mappingEntries(node) { + name, item := entry.key.Value, entry.value if strings.HasPrefix(name, ".") { continue } @@ -196,7 +201,7 @@ func (l *fixedLoader) loadApplications(cfg fixedNames) (map[string]any, error) { continue } data["name"] = name - add(name, data, source{file: file, line: node.Content[i].Line, node: item}) + add(name, data, source{file: file, line: entry.key.Line, node: item}) } default: l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, @@ -221,7 +226,7 @@ func (l *fixedLoader) loadSection( return nil, nil } data := map[string]any{} - if node.Kind != yaml.MappingNode || node.Decode(&data) != nil { + if resolveAlias(node).Kind != yaml.MappingNode || node.Decode(&data) != nil { l.result.Errors = append(l.result.Errors, Issue{File: file, Line: node.Line, Message: "contents must be a YAML map"}) return nil, nil } @@ -233,8 +238,8 @@ func (l *fixedLoader) loadSection( return nil, fmt.Errorf("failed to load %s schema: %w", section, err) } l.sources[section] = source{file: file, node: node} - for i := 0; i+1 < len(node.Content); i += 2 { - l.sources[section+"."+node.Content[i].Value] = source{file: file, line: node.Content[i].Line, node: node.Content[i+1]} + for _, entry := range mappingEntries(node) { + l.sources[section+"."+entry.key.Value] = source{file: file, line: entry.key.Line, node: entry.value} } checked := CheckSchemaAt(data, sch, section) l.sources.locate(checked) diff --git a/internal/lint/merge.go b/internal/lint/merge.go index 6f160c958..a4edcec03 100644 --- a/internal/lint/merge.go +++ b/internal/lint/merge.go @@ -46,14 +46,14 @@ func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources source if err != nil { return "", nil, err } - if doc == nil { + if doc = resolveAlias(doc); doc == nil { continue } if doc.Kind != yaml.MappingNode { return "", nil, &sourceError{file: file, line: doc.Line, msg: "contents should be a YAML map"} } - for i := 0; i+1 < len(doc.Content); i += 2 { - keyNode, section := doc.Content[i], doc.Content[i+1] + for _, top := range mappingEntries(doc) { + keyNode, section := top.key, resolveAlias(top.value) key := keyNode.Value if strings.HasPrefix(key, ".") { continue @@ -71,8 +71,8 @@ func mergeConfigFiles(fsys fs.FS, files []string) (merged string, sources source if sections[key] == nil { sections[key] = map[string]any{} } - for j := 0; j+1 < len(section.Content); j += 2 { - nameNode, valueNode := section.Content[j], section.Content[j+1] + for _, entry := range mappingEntries(section) { + nameNode, valueNode := entry.key, entry.value name := nameNode.Value if prev, exists := sources[key+"."+name]; exists { return "", nil, &sourceError{file: file, line: nameNode.Line, msg: fmt.Sprintf( diff --git a/internal/lint/normalize.go b/internal/lint/normalize.go index 757e42fe2..0fc4cbf35 100644 --- a/internal/lint/normalize.go +++ b/internal/lint/normalize.go @@ -82,7 +82,13 @@ func CheckDir(dir string, vendor Vendor) (*Result, Style, error) { switch { case flexOK: - content, sources, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) + root, err := os.OpenRoot(dir) + if err != nil { + return nil, StyleFlex, err + } + defer root.Close() + // The root keeps !include tags from following symbolic links out of the project. + content, sources, err := getMergedConfigFiles(root.FS(), ".", flexDir) var result *Result var srcErr *sourceError switch { diff --git a/internal/lint/normalize_test.go b/internal/lint/normalize_test.go index b4dadd649..146f0e739 100644 --- a/internal/lint/normalize_test.go +++ b/internal/lint/normalize_test.go @@ -312,3 +312,36 @@ hooks: }}, result.Errors) }) } + +func TestLintDir_Aliases(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `.apps: &apps + app: + type: "php:1.0" +applications: *apps +`) + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + require.Len(t, result.Errors, 1) + assert.Equal(t, Issue{ + File: ".upsun/config.yaml", Line: 3, Path: "applications.app.type", + Message: result.Errors[0].Message, + }, result.Errors[0]) +} + +func TestLintDir_IncludeSymlinkOutsideProject(t *testing.T) { + outside := t.TempDir() + writeFile(t, filepath.Join(outside, "secret.txt"), "php:secret") + dir := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link"))) + writeFile(t, filepath.Join(dir, ".upsun", "config.yaml"), `applications: + app: + type: !include {type: string, path: ../link/secret.txt} +`) + result, _, err := CheckDir(dir, upsunVendor()) + require.NoError(t, err) + require.Len(t, result.Errors, 1) + assert.Equal(t, Issue{ + File: ".upsun/config.yaml", Line: 3, Message: "'../link/secret.txt' is outside the project", + }, result.Errors[0]) +} diff --git a/internal/lint/source.go b/internal/lint/source.go index 79ab65e28..5739c4a27 100644 --- a/internal/lint/source.go +++ b/internal/lint/source.go @@ -2,6 +2,7 @@ package lint import ( "encoding/base64" + "errors" "fmt" "io/fs" "path" @@ -51,6 +52,11 @@ func loadYAMLIncluding(fsys fs.FS, name string, including []string) (*yaml.Node, return nil, nil } root := doc.Content[0] + // Decoding checks for duplicate keys, which the node tree allows. + var v any + if err := root.Decode(&v); err != nil { + return nil, &sourceError{file: name, msg: interpretYAMLError(err)} + } r := &tagResolver{fsys: fsys, file: name, including: append(including, name)} if err := r.resolve(root); err != nil { return nil, err @@ -182,8 +188,11 @@ func (r *tagResolver) lookup(node *yaml.Node, rel string) (p string, isDir bool, return "", false, r.errorf(node, "'%s' is outside the project", rel) } fi, err := fs.Stat(r.fsys, p) - if err != nil { + if errors.Is(err, fs.ErrNotExist) { return "", false, r.errorf(node, "'%s' doesn't exist in the repository", rel) + } else if err != nil { + // E.g. a symbolic link out of the project, which os.Root refuses. + return "", false, r.errorf(node, "'%s' is outside the project", rel) } return p, fi.IsDir(), nil } @@ -266,6 +275,7 @@ func lineOf(node *yaml.Node, p string) int { } line := node.Line for p != "" { + node = resolveAlias(node) switch node.Kind { case yaml.MappingNode: var key string @@ -278,8 +288,8 @@ func lineOf(node *yaml.Node, p string) int { } else { p = strings.TrimPrefix(p, ".") // Keys may contain dots, so the longest matching key wins. - for i := 0; i+1 < len(node.Content); i += 2 { - k := node.Content[i].Value + for _, e := range mappingEntries(node) { + k := e.key.Value if (p == k || strings.HasPrefix(p, k+".") || strings.HasPrefix(p, k+"[")) && len(k) > len(key) { key = k } @@ -320,10 +330,53 @@ type mappingEntry struct { // mappingValue returns the value for key in a mapping node, or nil. func mappingValue(node *yaml.Node, key string) *mappingEntry { - for i := 0; i+1 < len(node.Content); i += 2 { - if node.Content[i].Value == key { - return &mappingEntry{keyLine: node.Content[i].Line, node: node.Content[i+1]} + for _, e := range mappingEntries(node) { + if e.key.Value == key { + return &mappingEntry{keyLine: e.key.Line, node: e.value} } } return nil } + +// resolveAlias returns the node that an alias refers to, or node itself. +func resolveAlias(node *yaml.Node) *yaml.Node { + for node != nil && node.Kind == yaml.AliasNode { + node = node.Alias + } + return node +} + +type keyValue struct { + key, value *yaml.Node +} + +// mappingEntries returns the entries of a mapping node, including those merged +// with "<<" keys, which explicit keys override. +func mappingEntries(node *yaml.Node) []keyValue { + node = resolveAlias(node) + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + var entries, merged []keyValue + for i := 0; i+1 < len(node.Content); i += 2 { + k, v := node.Content[i], node.Content[i+1] + if k.Tag != "!!merge" { + entries = append(entries, keyValue{k, v}) + continue + } + v = resolveAlias(v) + sources := []*yaml.Node{v} + if v.Kind == yaml.SequenceNode { + sources = v.Content + } + for _, src := range sources { + merged = append(merged, mappingEntries(src)...) + } + } + for _, m := range merged { + if !slices.ContainsFunc(entries, func(e keyValue) bool { return e.key.Value == m.key.Value }) { + entries = append(entries, m) + } + } + return entries +} diff --git a/internal/lint/source_test.go b/internal/lint/source_test.go index b26fea8a2..33d415ebf 100644 --- a/internal/lint/source_test.go +++ b/internal/lint/source_test.go @@ -130,3 +130,37 @@ routes: }) } } + +func TestLoadYAML_DuplicateKeys(t *testing.T) { + fsys := fstest.MapFS{"c.yaml": {Data: []byte("applications:\n app: {}\napplications: null\n")}} + _, err := loadYAML(fsys, "c.yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), `mapping key "applications" already defined`) +} + +func TestMappingEntries_AliasesAndMergeKeys(t *testing.T) { + var doc yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`.base: &base + type: php:8.4 + size: S +.apps: &apps + app: + <<: *base + size: M +applications: *apps +`), &doc)) + root := doc.Content[0] + apps := resolveAlias(mappingValue(root, "applications").node) + require.NotNil(t, apps) + app := mappingValue(apps, "app") + require.NotNil(t, app) + assert.Equal(t, 5, app.keyLine) + + values := map[string]string{} + for _, e := range mappingEntries(resolveAlias(app.node)) { + values[e.key.Value] = e.value.Value + } + // An explicit key overrides a merged one. + assert.Equal(t, map[string]string{"type": "php:8.4", "size": "M"}, values) + assert.Equal(t, 2, lineOf(root, ".applications.app.type")) +} From 45f27e99b9901af04becfed74647514687326bf1 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 22:12:32 +0100 Subject: [PATCH 33/36] fix(lint): address review findings on output and Fixed rules - The report (issues and the success line) is written to stdout, as the command's output, so --quiet no longer hides it. The "Validating..." line stays on stderr. - With --format json, an operational error such as a missing path or no configuration is reported as a JSON error, so there is always a document. - --stdin with a path argument is an error instead of ignoring the path. - In Fixed config, a web location rule may set `passthru` to a boolean, as the location itself and Flex rules can. Co-Authored-By: Claude Opus 5.5 --- commands/lint.go | 11 +++- commands/lint_test.go | 61 ++++++++++++++++++- internal/lint/fixed_test.go | 16 +++++ .../lint/schema/platformsh.application.json | 5 +- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/commands/lint.go b/commands/lint.go index 1aa3554d3..8d5764187 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -57,6 +57,11 @@ func vendorFromConfig(cnf *config.Config) lint.Vendor { func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error { result, format, err := lintInput(cmd, args, vendor) if err != nil { + // Report operational errors as lint errors in JSON, so there is always a document. + if format == "json" { + result = &lint.Result{Errors: []lint.Issue{{Message: err.Error()}}} + return printLintResult(cmd, result, format) + } // Print operational errors ourselves, since the command silences errors. // Go error strings are lowercase by convention; capitalize for display. fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(capitalizeFirst(err.Error()))) @@ -72,6 +77,9 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) } + if explicitStdin && len(args) > 0 { + return nil, format, errors.New("--stdin cannot be used with a path") + } if explicitStdin { result, err := lintStdin(cmd) return result, format, err @@ -145,7 +153,8 @@ func printLintResult(cmd *cobra.Command, result *lint.Result, format string) err return nil } - w := cmd.ErrOrStderr() + // The report is the command's output, so it is not hidden by --quiet. + w := cmd.OutOrStdout() printIssues(w, color.New(color.FgRed, color.Bold), "Errors", result.Errors) printIssues(w, color.New(color.FgYellow, color.Bold), "Warnings", result.Warnings) if result.HasErrors() { diff --git a/commands/lint_test.go b/commands/lint_test.go index 78d8b4824..b8d4263d5 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -4,6 +4,9 @@ import ( "bytes" "encoding/json" "io" + "os" + "path/filepath" + "strings" "testing" "github.com/fatih/color" @@ -11,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/lint" ) @@ -64,7 +68,8 @@ func TestPrintLintResult_Text(t *testing.T) { color.NoColor = true cmd := &cobra.Command{} var out bytes.Buffer - cmd.SetErr(&out) + cmd.SetOut(&out) + cmd.SetErr(io.Discard) result := &lint.Result{} result.Errors = []lint.Issue{ @@ -93,3 +98,57 @@ Warnings: this .upsun directory is not at the project root and will be ignored `, out.String()) } + +func TestLintCommand(t *testing.T) { + color.NoColor = true + valid := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(valid, ".upsun"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(valid, ".upsun", "config.yaml"), + []byte("applications:\n app:\n type: \"php:8.4\"\n"), 0o600)) + missing := filepath.Join(t.TempDir(), "missing") + + cases := []struct { + name string + args []string + wantErr bool + wantStdout string + }{ + {"report on stdout", []string{valid}, false, "✓ The configuration is valid.\n"}, + {"stdin with a path", []string{"--stdin", valid}, true, ""}, + {"JSON for an operational error", []string{"--format", "json", missing}, true, `{ + "errors": [ + { + "path": "", + "message": "stat ` + missing + `: no such file or directory" + } + ], + "warnings": [] +} +`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cnf := &config.Config{} + cnf.Service.ProjectConfigFlavor = "upsun" + cnf.Service.ProjectConfigDir = ".upsun" + cmd := newLintCommand(cnf) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs(c.args) + err := cmd.Execute() + if c.wantErr { + require.ErrorIs(t, err, errLintFailed) + } else { + require.NoError(t, err) + } + if c.wantStdout != "" { + assert.Equal(t, c.wantStdout, stdout.String()) + } + if c.name == "stdin with a path" { + assert.Contains(t, stderr.String(), "--stdin cannot be used with a path") + } + }) + } +} diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index bdca8ca6a..d827da28a 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -31,6 +31,22 @@ relationships: }, wantNoErr: true, }, + { + name: "rule overriding passthru with a boolean", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "php:8.4" +web: + locations: + "/": + root: public + passthru: /index.php + rules: + '\.php$': + passthru: false`, + }, + wantNoErr: true, + }, { name: "app with runtime operations", files: map[string]string{ diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index 2b0c171f8..0d2984697 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -511,7 +511,10 @@ "title": "Amount of time to cache static assets." }, "passthru": { - "type": "string", + "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": { From 875e21cb3ad189a6c8f276804886f1e51ed7def9 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 22:15:49 +0100 Subject: [PATCH 34/36] fix(lint): say the configuration is valid when there are only warnings With warnings and no errors, the command printed only the warnings, so it was unclear whether the configuration was valid. It now ends with "The configuration is valid, with N warnings." Co-Authored-By: Claude Opus 5.5 --- commands/lint.go | 7 ++++++- commands/lint_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/commands/lint.go b/commands/lint.go index 8d5764187..95ff995fd 100644 --- a/commands/lint.go +++ b/commands/lint.go @@ -160,8 +160,13 @@ func printLintResult(cmd *cobra.Command, result *lint.Result, format string) err if result.HasErrors() { return errLintFailed } - if !result.HasWarnings() { + switch n := len(result.Warnings); n { + case 0: fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.") + case 1: + fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid, with 1 warning.") + default: + fmt.Fprintf(w, "%s The configuration is valid, with %d warnings.\n", color.GreenString("✓"), n) } return nil } diff --git a/commands/lint_test.go b/commands/lint_test.go index b8d4263d5..62119aaee 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -152,3 +152,18 @@ func TestLintCommand(t *testing.T) { }) } } + +func TestPrintLintResult_ValidWithWarnings(t *testing.T) { + color.NoColor = true + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + + result := &lint.Result{} + result.AddWarning("", "both .upsun and .platform configuration found") + require.NoError(t, printLintResult(cmd, result, "text")) + assert.Equal(t, `Warnings: + both .upsun and .platform configuration found +✓ The configuration is valid, with 1 warning. +`, out.String()) +} From 8271c79fecd0b9abc257cc6e16515832d3b1c78e Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 22:22:06 +0100 Subject: [PATCH 35/36] test(lint): use a Fixed-style project in the command test Avoids another "upsun" literal in the commands package, which CI's goconst check flagged. Co-Authored-By: Claude Opus 5.5 --- commands/lint_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/commands/lint_test.go b/commands/lint_test.go index 62119aaee..919b3b547 100644 --- a/commands/lint_test.go +++ b/commands/lint_test.go @@ -102,9 +102,8 @@ Warnings: func TestLintCommand(t *testing.T) { color.NoColor = true valid := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(valid, ".upsun"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(valid, ".upsun", "config.yaml"), - []byte("applications:\n app:\n type: \"php:8.4\"\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(valid, ".platform.app.yaml"), + []byte("name: app\ntype: \"php:8.4\"\n"), 0o600)) missing := filepath.Join(t.TempDir(), "missing") cases := []struct { @@ -129,8 +128,7 @@ func TestLintCommand(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { cnf := &config.Config{} - cnf.Service.ProjectConfigFlavor = "upsun" - cnf.Service.ProjectConfigDir = ".upsun" + cnf.Service.ProjectConfigDir = ".platform" cmd := newLintCommand(cnf) var stdout, stderr bytes.Buffer cmd.SetOut(&stdout) From 8cd64114b449d6f54223b45ccf2d6f87f85cb373 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 25 Sep 2026 23:52:23 +0100 Subject: [PATCH 36/36] feat(lint): support egress, OCI images, container profiles and structured stacks Following the platform's config validator: - `egress` (per-phase allowed domains for `build` and `runtime`) on applications, their web and worker containers, and tasks. - `container_profile` on web and worker containers and on services, in both styles, and on Fixed applications. - OCI images: a `docker:` type needs an `image` with either `name` or `buildfile`, and is not checked against the image registry. `image` on any other type is an error. - `stack` is the structured form only: a mapping of `runtimes` and `packages`, each a name, a mapping or a list of them. The platform rejects the older list form for new deployments. - Cron `timeout`, and operation `timeout` and `commands.stop` (timeouts are 1 to 86400 seconds). Explicit nulls stay rejected. The Fixed services schema is reformatted like the others. Co-Authored-By: Claude Opus 5.5 --- internal/lint/config_schema.go | 58 ++-- internal/lint/fixed_test.go | 59 +++- internal/lint/linter_test.go | 178 +++++++++- .../lint/schema/platformsh.application.json | 311 +++++++++++++++++- internal/lint/schema/platformsh.services.json | 124 +++---- internal/lint/schema/upsun-config-schema.json | 222 ++++++++++--- internal/lint/types.go | 24 +- internal/lint/types_test.go | 4 +- 8 files changed, 827 insertions(+), 153 deletions(-) diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go index 6a6ecada4..c5ef805cb 100644 --- a/internal/lint/config_schema.go +++ b/internal/lint/config_schema.go @@ -2,15 +2,15 @@ package lint import ( "fmt" - "reflect" "gopkg.in/yaml.v3" ) type Config struct { Applications map[string]struct { - Type string `yaml:"type"` - Stack any `yaml:"stack,omitempty"` // The stack can be an object, string, or array. + Type string `yaml:"type"` + Stack any `yaml:"stack,omitempty"` + Image *OCIImage `yaml:"image,omitempty"` Hooks struct { Build string `yaml:"build,omitempty"` @@ -77,9 +77,10 @@ type WebLocation struct { // Task is an on-demand, run-to-completion workload (Flex only). type Task struct { - Type string `yaml:"type,omitempty"` - Base string `yaml:"base,omitempty"` - Stack any `yaml:"stack,omitempty"` + Type string `yaml:"type,omitempty"` + Base string `yaml:"base,omitempty"` + Stack any `yaml:"stack,omitempty"` + Image *OCIImage `yaml:"image,omitempty"` Run struct { Command string `yaml:"command,omitempty"` @@ -110,6 +111,12 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { return nil } +// OCIImage configures the image of a container with a docker: type. +type OCIImage struct { + Name string `yaml:"name,omitempty"` + Buildfile string `yaml:"buildfile,omitempty"` +} + // Mount is a writable directory in a container. type Mount struct { Source string `yaml:"source,omitempty"` @@ -131,21 +138,32 @@ func DecodeConfig(content string) (*Config, error) { return &c, nil } -// isStackEmpty checks if the stack field is empty, handling all possible types. +// isStackEmpty reports whether a stack declares no runtimes or packages. The +// stack is a mapping of "runtimes" and "packages", each a name, a mapping or a +// list; the schema rejects other forms. func isStackEmpty(stack any) bool { - if stack == nil { - return true + m, ok := stack.(map[string]any) + if !ok { + return stack == nil } - - v := reflect.ValueOf(stack) - switch v.Kind() { - case reflect.String: - return v.String() == "" - case reflect.Slice, reflect.Array, reflect.Map: - return v.Len() == 0 - case reflect.Pointer, reflect.Interface: - return v.IsNil() - default: - return v.IsZero() + for _, v := range m { + switch v := v.(type) { + case nil: + case string: + if v != "" { + return false + } + case []any: + if len(v) > 0 { + return false + } + case map[string]any: + if len(v) > 0 { + return false + } + default: + return false + } } + return true } diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go index d827da28a..ca31675b1 100644 --- a/internal/lint/fixed_test.go +++ b/internal/lint/fixed_test.go @@ -47,6 +47,61 @@ web: }, wantNoErr: true, }, + { + name: "container features", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "composable:25.11" +container_profile: HIGH_CPU +stack: + runtimes: ["php@8.4"] + packages: [{package: jq}] +egress: + build: + allowed_domains: + - host: repo.packagist.org +web: + container_profile: BALANCED +workers: + queue: + container_profile: HIGH_MEMORY + commands: + start: php worker.php + egress: + runtime: + allowed_domains: + - host: queue.example.com + port: 443 +crons: + report: + spec: "0 * * * *" + timeout: 600 + commands: + start: php report.php +operations: + rebuild: + timeout: 3600 + commands: + start: php rebuild.php + stop: pkill -f rebuild.php +relationships: + db: "db:mysql"`, + ".platform/services.yaml": `db: + type: "mariadb:11.4" + container_profile: HIGH_MEMORY`, + }, + wantNoErr: true, + }, + { + name: "OCI image", + files: map[string]string{ + ".platform.app.yaml": `name: myapp +type: "docker:1" +image: + name: ghcr.io/example/app:1.0`, + }, + wantNoErr: true, + }, { name: "app with runtime operations", files: map[string]string{ @@ -89,7 +144,7 @@ authorizations: files: map[string]string{ ".platform.app.yaml": `name: myapp type: "composable:25.11" -stack: ["php@8.3"]`, +stack: {runtimes: ["php@8.3"]}`, }, wantNoErr: true, }, @@ -97,7 +152,7 @@ stack: ["php@8.3"]`, name: "composable image with stack and no type", files: map[string]string{ ".platform.app.yaml": `name: myapp -stack: ["php@8.3"]`, +stack: {runtimes: ["php@8.3"]}`, }, wantNoErr: true, }, diff --git a/internal/lint/linter_test.go b/internal/lint/linter_test.go index 7d3ecd506..6f5f79761 100644 --- a/internal/lint/linter_test.go +++ b/internal/lint/linter_test.go @@ -174,7 +174,7 @@ tasks: action: view composed: type: composable:25.11 - stack: ["python@3.14"] + stack: {runtimes: ["python@3.14"]} run: command: python run.py perf: @@ -240,3 +240,179 @@ workers: {} }) } } + +func TestLint_ContainerFeatures(t *testing.T) { + cases := []struct { + name string + content string + wantErrors string + }{ + { + name: "egress, container profiles, operations and cron timeouts", + content: ` +applications: + app: + type: php:8.4 + container_profile: HIGH_CPU + relationships: + db: "db:mysql" + egress: + build: + allowed_domains: + - host: repo.packagist.org + runtime: + allowed_domains: + - host: api.example.com + port: 443 + web: + container_profile: BALANCED + egress: + runtime: + allowed_domains: + - host: api.example.com + workers: + queue: + container_profile: HIGH_MEMORY + commands: + start: php worker.php + egress: + runtime: + allowed_domains: + - host: queue.example.com + crons: + report: + spec: "0 * * * *" + timeout: 600 + commands: + start: php report.php + operations: + rebuild: + role: admin + timeout: 3600 + commands: + start: php rebuild.php + stop: pkill -f rebuild.php +services: + db: + type: mariadb:11.4 + container_profile: HIGH_MEMORY +tasks: + agent: + type: python:3.14 + container_profile: HIGH_CPU + run: + command: python agent.py + egress: + runtime: + allowed_domains: + - host: api.example.com +`, + }, + { + name: "structured stack forms", + content: ` +applications: + listed: + type: composable:25.11 + stack: + runtimes: ["php@8.4", {"nodejs@24": {}}] + packages: [yarn, {package: jq, channel: unstable}] + scalar: + type: composable:25.11 + stack: + runtimes: php@8.4 + packages: yarn +routes: + "https://{default}/": + type: upstream + upstream: "listed:http" +`, + }, + { + name: "OCI images", + content: ` +applications: + named: + type: docker:1 + image: + name: ghcr.io/example/app:1.0 + built: + type: docker:1 + image: + buildfile: Dockerfile +tasks: + job: + type: docker:1 + image: + name: ghcr.io/example/job:1.0 + run: + command: ./job +routes: + "https://{default}/": + type: upstream + upstream: "named:http" +`, + }, + { + name: "invalid container features", + content: ` +applications: + app: + type: composable:25.11 + stack: ["php@8.4"] + egress: + runtime: + allowed_domains: + - port: 443 + crons: + report: + spec: "0 * * * *" + timeout: 100000 + commands: + start: php report.php +`, + wantErrors: `linter errors: + - applications.app.crons.report.timeout: Must be less than or equal to 86400 + - applications.app.egress.runtime.allowed_domains.0: host is required + - applications.app.stack: Invalid type. Expected: object, given: array`, + }, + { + name: "invalid images", + content: ` +applications: + none: + type: docker:1 + both: + type: docker:1 + image: + name: example/app + buildfile: Dockerfile + classic: + type: php:8.4 + image: + name: example/app +routes: + "https://{default}/": + type: upstream + upstream: "classic:http" +`, + wantErrors: `linter errors: + - applications.both.image: either name or buildfile must be provided, not both + - applications.classic.image: 'image' is only used with a docker: type + - applications.none.image: either name or buildfile must be provided, both are missing`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + result, err := CheckContent(c.content) + require.NoError(t, err) + if c.wantErrors == "" { + assert.False(t, result.HasErrors(), "unexpected errors: %s", result) + assert.False(t, result.HasWarnings(), "unexpected warnings: %s", result) + return + } + assert.Equal(t, c.wantErrors, result.Error()) + }) + } +} diff --git a/internal/lint/schema/platformsh.application.json b/internal/lint/schema/platformsh.application.json index 0d2984697..8c1d15931 100644 --- a/internal/lint/schema/platformsh.application.json +++ b/internal/lint/schema/platformsh.application.json @@ -675,6 +675,74 @@ ], "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, @@ -742,6 +810,12 @@ "cmd": { "type": "string", "title": "The command to execute." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "The maximum duration in seconds." } }, "required": [ @@ -958,6 +1032,74 @@ ], "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": [ @@ -976,27 +1118,68 @@ "title": "Resolve additional IPs to domain names." }, "stack": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" + "type": "object", + "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.", + "properties": { + "runtimes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "minProperties": 1, + "maxProperties": 1 + } + ] + } + } + ] }, - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" + "packages": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "package" + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "package" + ] + } + ] } - ] - } + } + ] } - ] + }, + "additionalProperties": false }, "authorizations": { "type": "array", @@ -1045,6 +1228,10 @@ "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": [ @@ -1060,6 +1247,12 @@ "contributor", "admin" ] + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "The maximum duration in seconds." } }, "required": [ @@ -1070,6 +1263,88 @@ "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": [ diff --git a/internal/lint/schema/platformsh.services.json b/internal/lint/schema/platformsh.services.json index 0ce492156..3a20fe18b 100644 --- a/internal/lint/schema/platformsh.services.json +++ b/internal/lint/schema/platformsh.services.json @@ -1,114 +1,118 @@ { - "title": "Platform.sh services configuration file", - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", + "title": "Platform.sh services configuration file", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", "additionalProperties": { - "type": "object", + "type": "object", "properties": { "type": { - "type": "string", + "type": "string", "title": "The service type." - }, + }, "size": { - "type": "string", - "title": "The service size.", + "type": "string", + "title": "The service size.", "default": "AUTO" - }, + }, "disk": { - "type": "integer", - "title": "The size of the disk.", + "type": "integer", + "title": "The size of the disk.", "default": null - }, + }, "access": { - "type": "object", - "title": "The configuration of the service.", + "type": "object", + "title": "The configuration of the service.", "default": {} - }, + }, "configuration": { - "type": "object", - "title": "The configuration of the service.", + "type": "object", + "title": "The configuration of the service.", "default": {} - }, + }, "relationships": { - "type": "object", + "type": "object", "additionalProperties": { "type": "string" - }, - "title": "The relationships of the service to other services.", + }, + "title": "The relationships of the service to other services.", "default": {} - }, + }, "firewall": { - "type": "object", + "type": "object", "properties": { "outbound": { - "type": "array", + "type": "array", "items": { - "type": "object", + "type": "object", "properties": { "protocol": { - "type": "string", - "title": "The IP protocol to apply the restriction on.", + "type": "string", + "title": "The IP protocol to apply the restriction on.", "default": "tcp" - }, + }, "ips": { - "type": "array", + "type": "array", "items": { "type": "string" - }, - "title": "The IP range in CIDR notation to apply the restriction on.", + }, + "title": "The IP range in CIDR notation to apply the restriction on.", "default": [] - }, + }, "domains": { - "type": "array", + "type": "array", "items": { "type": "string" - }, - "title": "Domains of the restriction.", + }, + "title": "Domains of the restriction.", "default": [] - }, + }, "ports": { - "type": "array", + "type": "array", "items": { "type": "integer" - }, - "title": "The port to apply the restriction on.", + }, + "title": "The port to apply the restriction on.", "default": [] } - }, + }, "additionalProperties": false - }, - "title": "Outbound firewall restrictions", + }, + "title": "Outbound firewall restrictions", "default": [] } - }, - "additionalProperties": false, - "nullable": true, - "title": "Firewall", + }, + "additionalProperties": false, + "nullable": true, + "title": "Firewall", "default": null - }, + }, "resources": { - "type": "object", + "type": "object", "properties": { "base_memory": { - "type": "integer", - "title": "The base memory for the container", + "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", + "type": "integer", + "title": "The amount of memory to allocate per units of CPU", "default": 128 } - }, - "additionalProperties": false, - "nullable": true, - "title": "Resources", + }, + "additionalProperties": false, + "nullable": true, + "title": "Resources", "default": null + }, + "container_profile": { + "type": "string", + "title": "The container profile for this container" } - }, + }, "required": [ "type" - ], + ], "additionalProperties": false } -} \ No newline at end of file +} diff --git a/internal/lint/schema/upsun-config-schema.json b/internal/lint/schema/upsun-config-schema.json index 587ec461a..03d59a78d 100644 --- a/internal/lint/schema/upsun-config-schema.json +++ b/internal/lint/schema/upsun-config-schema.json @@ -14,29 +14,7 @@ "description": "The base image to use with a specific app language. \nFormat: runtime:version. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/type/" }, "stack": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" - }, - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ] - } - } - ], - "title": "Composable Image definition", - "description": "A list of packages from the Upsun collection of supported runtimes and/or from NixPkgs. \nMore information: \nhttps://docs.upsun.com/anchors/app/composable/" + "$ref": "#/definitions/stack" }, "source": { "type": "object", @@ -347,6 +325,12 @@ }, "authorizations": { "$ref": "#/definitions/authorizations" + }, + "egress": { + "$ref": "#/definitions/egress" + }, + "container_profile": { + "$ref": "#/definitions/container_profile" } }, "additionalProperties": false, @@ -467,6 +451,9 @@ }, "authorizations": { "$ref": "#/definitions/authorizations" + }, + "egress": { + "$ref": "#/definitions/egress" } }, "required": [ @@ -596,6 +583,7 @@ "type": "integer", "title": "The cron timeout", "description": "The maximum amount of time a cron can run before it’s terminated. Defaults to the maximum allowed value of `86400` seconds (24 hours).", + "minimum": 1, "maximum": 86400 }, "cmd": { @@ -732,6 +720,10 @@ "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": [ @@ -747,6 +739,12 @@ "contributor", "admin" ] + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "description": "The maximum duration in seconds." } }, "required": [ @@ -760,6 +758,12 @@ }, "authorizations": { "$ref": "#/definitions/authorizations" + }, + "egress": { + "$ref": "#/definitions/egress" + }, + "image": { + "$ref": "#/definitions/image" } }, "anyOf": [ @@ -971,6 +975,9 @@ "description": "What global dependencies to install before the build `hook` is run. They will be downloaded by the language's package manager. \nMore information: \nhttps://docs.upsun.com/anchors/app/reference/dependencies/", "default": {} }, + "egress": { + "$ref": "#/definitions/egress" + }, "hooks": { "type": "object", "properties": { @@ -991,6 +998,9 @@ }, "additionalProperties": false }, + "image": { + "$ref": "#/definitions/image" + }, "mounts": { "$ref": "#/definitions/mounts" }, @@ -1094,29 +1104,7 @@ "additionalProperties": false }, "stack": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" - }, - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ] - } - } - ], - "title": "Composable Image definition", - "description": "A list of packages from the Upsun collection of supported runtimes and/or from NixPkgs. \nMore information: \nhttps://docs.upsun.com/anchors/app/composable/" + "$ref": "#/definitions/stack" }, "timezone": { "type": "string", @@ -1632,6 +1620,148 @@ ], "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 + }, + "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 + }, + "stack": { + "type": "object", + "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.", + "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 } }, "patternProperties": { diff --git a/internal/lint/types.go b/internal/lint/types.go index 57941391e..32d373fb6 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -22,8 +22,24 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { } } - // checkRuntime checks the type and stack of an application or task at path. - checkRuntime := func(path, t string, stack any) { + // checkRuntime checks the type, stack and image of an application or task at path. + checkRuntime := func(path, t string, stack any, image *OCIImage) { + // A docker: type is built from an OCI image rather than a registry image. + if strings.HasPrefix(t, "docker:") { + switch { + case image == nil || image.Name == "" && image.Buildfile == "": + result.AddError(path+".image", "either name or buildfile must be provided, both are missing") + case image.Name != "" && image.Buildfile != "": + result.AddError(path+".image", "either name or buildfile must be provided, not both") + } + if !isStackEmpty(stack) { + result.AddWarning(path+".stack", "'stack' is only used with a composable image type") + } + return + } + if image != nil { + result.AddError(path+".image", "'image' is only used with a docker: type") + } check(path+".type", t, true) isComposable := strings.HasPrefix(t, "composable") if isComposable && isStackEmpty(stack) { @@ -41,12 +57,12 @@ func CheckTypes(cfg *Config, reg registry.Registry) *Result { "'type' should be specified (as a composable image) when using 'stack'") continue } - checkRuntime("applications."+appName, app.Type, app.Stack) + 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. if task := cfg.Tasks[taskName]; task.Base == "" { - checkRuntime("tasks."+taskName, task.Type, task.Stack) + checkRuntime("tasks."+taskName, task.Type, task.Stack, task.Image) } } for appName := range cfg.Applications { diff --git a/internal/lint/types_test.go b/internal/lint/types_test.go index b5deabf88..a5d963785 100644 --- a/internal/lint/types_test.go +++ b/internal/lint/types_test.go @@ -123,7 +123,7 @@ applications: type: php:8.4 tasks: bad: - stack: ["php@8.4"]`, + stack: {runtimes: ["php@8.4"]}`, expectErrorMessage: `linter errors: - tasks.bad.type: type cannot be empty linter warnings: @@ -135,7 +135,7 @@ linter warnings: applications: foo: type: php:8.4 - stack: ["php@8.4"]`, + stack: {runtimes: ["php@8.4"]}`, expectErrorMessage: `linter warnings: - applications.foo.stack: 'stack' is only used with a composable image type`, },