From 9e4ed58066568c79c2e7e40024f4f73b09f20d7a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:08:48 +0100 Subject: [PATCH 1/9] 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 | 7 +- 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, 7053 insertions(+), 3 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 e9619070..54ab33f8 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.26.1 github.com/briandowns/spinner v1.23.2 + github.com/dlclark/regexp2/v2 v2.1.1 github.com/fatih/color v1.19.0 github.com/go-chi/chi/v5 v5.3.0 github.com/go-playground/validator/v10 v10.30.3 @@ -20,12 +21,14 @@ require ( github.com/upsun/lib-sun v0.3.16 github.com/upsun/whatsun v0.2.0 github.com/wk8/go-ordered-map/v2 v2.1.8 + github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.13.1 ) require ( @@ -57,7 +60,6 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dlclark/regexp2/v2 v2.1.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 @@ -125,7 +127,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.4 // indirect diff --git a/go.sum b/go.sum index 5901657b..fcf0dcd3 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= @@ -123,6 +124,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.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 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.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -381,3 +384,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.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/lint/config_schema.go b/internal/lint/config_schema.go new file mode 100644 index 00000000..ee816faf --- /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 00000000..b5f4822f --- /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 00000000..fbdd5127 --- /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 00000000..991803b7 --- /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 00000000..5d9a62b4 --- /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 00000000..f52f1cc2 --- /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 00000000..ed0e8604 --- /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 00000000..6b000861 --- /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 00000000..db927273 --- /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 00000000..4f5f4b7e --- /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 00000000..e6b1f804 --- /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 00000000..9b2dfbc7 --- /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 00000000..54766858 --- /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 00000000..d7cc6799 --- /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 00000000..fa41c363 --- /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 00000000..9439ca1e --- /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 00000000..4a784ccb --- /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 00000000..c2f112bb --- /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 00000000..ccfc38d7 --- /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 00000000..d6d52bdd --- /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 00000000..f715878d --- /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 00000000..11e536d7 --- /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 00000000..93a0a9e7 --- /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 00000000..c7b8ba96 --- /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 00000000..2053ec17 --- /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 00000000..9a592839 --- /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 00000000..1afb4e00 --- /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 00000000..4acb73de --- /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 00000000..c195ddfd --- /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 00000000..52403106 --- /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 00000000..fbfa475e --- /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 f4254f3f68a8658777d911efe1fcd2179594cbd3 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:13:51 +0100 Subject: [PATCH 2/9] 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 00000000..d644855c --- /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 49178829..85b231ff 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" @@ -133,22 +132,14 @@ 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( newConfigInstallCommand(), 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 991803b7..205cc36a 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 5d9a62b4..4d6e3eec 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 00000000..8e6aedfa --- /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 00000000..20588ead --- /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 4a784ccb..1f359dc1 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 255e09e9181f9625344cd2f31ae9e8c6c9c11d0b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:23:37 +0100 Subject: [PATCH 3/9] 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 d644855c..6ae72bb5 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 b5f4822f..ab6941c4 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 00000000..924c297f --- /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 00000000..f577ca64 --- /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 205cc36a..1c6c88c8 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 4d6e3eec..e7f9196c 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 f52f1cc2..1fe3fd80 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 6b000861..1e93d30c 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 8e6aedfa..3ed2739c 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 20588ead..3c72d516 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 4f5f4b7e..69007040 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 9b2dfbc7..776b224d 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 fa41c363..2c6f6f06 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 c2f112bb..55e0300b 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 00000000..6e5c10ef --- /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 00000000..c9167ed2 --- /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 00000000..52f92c63 --- /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 00000000..0ce49215 --- /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 93a0a9e7..c14aa607 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 9a592839..5f749e8f 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 1afb4e00..3bcf3953 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 4acb73de..60d073b0 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 52403106..fb641bb8 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 7b56fc4e11c7cdde8a5f9f0eca3e767846a9efac Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:31:12 +0100 Subject: [PATCH 4/9] 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 025c4f23..c41a2a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,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 d913e545..bbc591b9 100644 --- a/Makefile +++ b/Makefile @@ -132,6 +132,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 e7f9196c..9e3649ca 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 00000000..10d6f800 --- /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 776b224d..50265a23 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 54766858..f0b9e46e 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 d7cc6799..a847ec4c 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 d6d52bdd..464bcfaf 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 11e536d7..3e8557eb 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 c91dce762ec0f53afcc4256f882ae31c123c4e95 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:34:05 +0100 Subject: [PATCH 5/9] 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 c9cea0d8..dfca5a0c 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 @@ -66,7 +66,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 8741e251..28c4d779 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 140171ec57f4864da0e813fe1fdf37f3de161f44 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:42:00 +0100 Subject: [PATCH 6/9] 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 6ae72bb5..c2d9ee6a 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 924c297f..adeed218 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 f577ca64..b9b9dd75 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 1f359dc1..6ee0f712 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 6e5c10ef..7255458a 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 b843da7b3fbafdf1d0161642ce2865359d02a144 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 10:19:35 +0100 Subject: [PATCH 7/9] 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 55e0300b..0aea07a6 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 54b5451fa973c334460d4526f9e52c2a36a2ff61 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 12:18:35 +0100 Subject: [PATCH 8/9] 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 c2d9ee6a..47108e9c 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 e995c802..ed44fb4f 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -63,6 +63,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 adeed218..d864a4ca 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 b9b9dd75..b193dcc2 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 1fe3fd80..6b36a5ea 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 ed0e8604..7ea283ad 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 3ed2739c..4e35ff7f 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 3c72d516..2ea5cc90 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 209055200ae60fb3b8f77df7b742e91ecd9e9771 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 12:38:49 +0100 Subject: [PATCH 9/9] 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 47108e9c..cf04df3b 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 6ee0f712..da5c127f 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.