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/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/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/commands/lint.go b/commands/lint.go new file mode 100644 index 00000000..cf04df3b --- /dev/null +++ b/commands/lint.go @@ -0,0 +1,165 @@ +package commands + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "unicode" + + "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: "app:config-validate [path]", + Short: "Validate project configuration", + Aliases: []string{"lint", "validate"}, + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + 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") + cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) { + internalCmd := innerAppConfigValidateCommand(cnf) + fmt.Println(internalCmd.HelpPage(cnf)) + }) + return cmd +} + +// 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. + // 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) +} + +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" { + return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format) + } + + ctx := cmd.Context() + if explicitStdin { + result, err := lintStdin(ctx, cmd) + return result, format, err + } + + // With no path argument, lint piped stdin if it carries content; otherwise + // (e.g. a non-interactive shell or CI with no input) fall back to the directory. + if len(args) == 0 && stdinIsPiped() { + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, format, err + } + if strings.TrimSpace(string(content)) != "" { + result, err := lint.CheckContent(ctx, string(content)) + return result, format, err + } + } + + path := "." + if len(args) == 1 { + path = args[0] + } + root := lint.FindProjectRoot(path) + 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()) + 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 { + 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() + 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() { + return errLintFailed + } + if !result.HasWarnings() { + 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/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, 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/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/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/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..ab6941c4 --- /dev/null +++ b/internal/lint/dependencies.go @@ -0,0 +1,43 @@ +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 := range cfg.Applications { + app := cfg.Applications[appName] + 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/fixed.go b/internal/lint/fixed.go new file mode 100644 index 00000000..d864a4ca --- /dev/null +++ b/internal/lint/fixed.go @@ -0,0 +1,251 @@ +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. +const ( + keyApplications = "applications" + keyServices = "services" + keyRoutes = "routes" +) + +// flexTopKeys are the top-level keys that indicate Flex-style configuration. +var flexTopKeys = []string{keyApplications, keyServices, keyRoutes} + +// lintFixed lints Fixed-style configuration in dir using the resolved names in +// cfg: per-app config files (cfg.app) and/or cfg.dir/applications.yaml, plus +// optional cfg.dir/routes.yaml and cfg.dir/services.yaml. +func lintFixed(_ context.Context, dir string, cfg fixedNames) (*Result, error) { + result := &Result{} + + apps, err := loadFixedApplications(dir, cfg, result) + if err != nil { + return nil, err + } + + services, err := loadFixedSection(dir, cfg.dir, keyServices, schema.LoadServices, result) + if err != nil { + return nil, err + } + routes, err := loadFixedSection(dir, cfg.dir, keyRoutes, 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 + } + decoded, err := DecodeConfig(string(mergedYAML)) + if err != nil { + return nil, err + } + + checks, err := runChecks(decoded, StyleFixed) + if err != nil { + return nil, err + } + result.Merge(checks) + return result, nil +} + +// 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 (already defined in %s)", name, sources[name])) + return + } + apps[name] = data + sources[name] = source + } + + // Individual per-app config files (e.g. .platform.app.yaml). + for _, abs := range findFixedAppFiles(dir, cfg.app) { + source := relTo(dir, abs) + data, err := readYAMLMap(abs) + if err != nil { + result.AddError(source, err.Error()) + continue + } + if data == nil { + continue + } + if hasAnyKey(data, flexTopKeys) { + 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) + } + + // 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 { + result.AddError(label, err.Error()) + return apps, nil + } + var doc any + if err := yaml.Unmarshal(raw, &doc); err != nil { + result.AddError(label, interpretYAMLError(err)) + return apps, nil + } + switch v := doc.(type) { + case []any: + for i, item := range v { + data, ok := toStringMap(item) + if !ok { + result.AddError(fmt.Sprintf("%s[%d]", label, i), "application must be a map") + continue + } + src := fmt.Sprintf("%s[%d]", label, i) + result.Merge(CheckSchemaScoped(data, appSchema, src)) + add(fixedAppName(data), src, data) + } + case map[string]any: + for name, item := range v { + if strings.HasPrefix(name, ".") { + continue + } + data, ok := toStringMap(item) + if !ok { + result.AddError(label+": "+name, "application must be a map") + continue + } + src := label + ": " + name + // In map form the name comes from the key and must not be set in the value. + if _, ok := data["name"]; ok { + result.AddError(src, "the application name must not be set here; it is taken from the key") + continue + } + data["name"] = name + result.Merge(CheckSchemaScoped(data, appSchema, src)) + add(name, src, data) + } + case nil: + // Empty file. + default: + result.AddError(label, "contents must be a YAML list or map") + } + + return apps, nil +} + +// loadFixedSection reads and schema-validates an optional cfg.dir/.{yaml,yml}, +// returning its decoded map (keyed by name/URL). +func loadFixedSection( + dir, configDir, base string, + loadSchema func() (*gojsonschema.Schema, error), + result *Result, +) (map[string]any, error) { + path, ok := firstExistingYAML(dir, configDir, base) + if !ok { + return nil, nil + } + label := relTo(dir, path) + raw, err := os.ReadFile(path) + if err != nil { + result.AddError(label, err.Error()) + return nil, nil + } + data := map[string]any{} + if err := yaml.Unmarshal(raw, &data); err != nil { + result.AddError(label, 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", base, err) + } + result.Merge(CheckSchemaScoped(data, sch, label)) + 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 +} + +// toStringMap asserts that v is a YAML map. yaml.v3 always decodes mappings to +// map[string]any, so a plain type assertion suffices. +func toStringMap(v any) (map[string]any, bool) { + m, ok := v.(map[string]any) + return m, ok +} + +// relTo returns abs relative to dir, falling back to abs on error. +func relTo(dir, abs string) string { + if rel, err := filepath.Rel(dir, abs); err == nil { + return rel + } + return abs +} diff --git a/internal/lint/fixed_test.go b/internal/lint/fixed_test.go new file mode 100644 index 00000000..b193dcc2 --- /dev/null +++ b/internal/lint/fixed_test.go @@ -0,0 +1,128 @@ +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: "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{ + ".platform.app.yaml": `applications: + foo: + type: "php:8.3"`, + }, + wantErrors: []string{"looks like Flex 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, upsunVendor()) + 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() + // 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, 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/linter.go b/internal/lint/linter.go new file mode 100644 index 00000000..1c6c88c8 --- /dev/null +++ b/internal/lint/linter.go @@ -0,0 +1,55 @@ +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") + +// 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 + } + + 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 + } + + 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, style), + 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..9e3649ca --- /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.25 + 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.25 + 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.25 + 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 := CheckContent(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..6b36a5ea --- /dev/null +++ b/internal/lint/merge.go @@ -0,0 +1,87 @@ +package lint + +import ( + "fmt" + "io/fs" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// findFlexConfigFiles returns all *.yaml and *.yml files in the Flex config +// directory (e.g. .upsun) of the given path. Flex files may have any name. +func findFlexConfigFiles(fsys fs.FS, path, configDir string) ([]string, error) { + patterns := []string{ + 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 %s directory: %w", configDir, 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{ + keyApplications: {}, + keyRoutes: {}, + keyServices: {}, + } + 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{keyApplications, keyRoutes, keyServices} { + if section, ok := doc[key]; ok && section != nil { + sectionMap, ok := section.(map[string]any) + if !ok { + return "", fmt.Errorf("%s in %s is not a map", key, file) + } + for k, v := range sectionMap { + if _, exists := merged[key][k]; exists { + return "", fmt.Errorf("duplicate key '%s' in section '%s' found in file %s", k, key, file) + } + merged[key][k] = v + } + } + } + } + out := map[string]any{} + for _, key := range []string{keyApplications, keyRoutes, keyServices} { + 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 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 + } + return mergeConfigFiles(fsys, files) +} diff --git a/internal/lint/merge_test.go b/internal/lint/merge_test.go new file mode 100644 index 00000000..7ea283ad --- /dev/null +++ b/internal/lint/merge_test.go @@ -0,0 +1,77 @@ +package lint + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" +) + +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 := findFlexConfigFiles(fsys, ".", ".upsun") + 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, ".", ".upsun") + 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, ".", ".upsun") + 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, ".", ".upsun") + require.Error(t, err) + require.Contains(t, err.Error(), "no configuration files found") +} diff --git a/internal/lint/names.go b/internal/lint/names.go new file mode 100644 index 00000000..1e93d30c --- /dev/null +++ b/internal/lint/names.go @@ -0,0 +1,73 @@ +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 := range cfg.Applications { + app := cfg.Applications[name] + 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/normalize.go b/internal/lint/normalize.go new file mode 100644 index 00000000..4e35ff7f --- /dev/null +++ b/internal/lint/normalize.go @@ -0,0 +1,268 @@ +package lint + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// 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" + } +} + +// 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 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 flexOK: + content, err := getMergedConfigFiles(os.DirFS(dir), ".", flexDir) + if err != nil { + return nil, StyleFlex, err + } + result, err := CheckContent(ctx, content) + if err != nil { + return nil, StyleFlex, err + } + 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 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 configuration found in %q (looked for %s)", dir, lookedFor(flexDirs, fixedSet)) + } +} + +// 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 +} + +// 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 +} + +// 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") + } + for _, f := range fixed { + parts = append(parts, f.app, f.dir+"/") + } + return strings.Join(parts, ", ") +} + +// 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() && path != root { + switch d.Name() { + 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 + } + } + 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 new file mode 100644 index 00000000..2ea5cc90 --- /dev/null +++ b/internal/lint/normalize_test.go @@ -0,0 +1,185 @@ +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), 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) { + 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) + }) + } +} + +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) + }) + } +} + +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("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: + 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 := 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) +} + +func TestLintDir_NoConfig(t *testing.T) { + dir := t.TempDir() + _, style, err := CheckDir(context.Background(), dir, upsunVendor()) + require.Error(t, err) + assert.Equal(t, StyleUnknown, style) + assert.Contains(t, err.Error(), "no 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 := CheckDir(context.Background(), dir, upsunVendor()) + 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/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/model.go b/internal/lint/registry/model.go new file mode 100644 index 00000000..69007040 --- /dev/null +++ b/internal/lint/registry/model.go @@ -0,0 +1,134 @@ +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 := range r { + if r[k].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 := range r { + img := r[key] + result[key] = map[string]any{ + "name": img.Name, + "type": img.Type, + "is_runtime": img.IsRuntime, + "latest_version": img.Versions.LatestVersion(), + "versions": img.Versions, + } + } + return result +} diff --git a/internal/lint/registry/model_test.go b/internal/lint/registry/model_test.go 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..50265a23 --- /dev/null +++ b/internal/lint/registry/registry.go @@ -0,0 +1,102 @@ +package registry + +import ( + _ "embed" + "encoding/json" + "sync" +) + +// 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 + +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 := range reg { + img := reg[k] + // 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..f0b9e46e --- /dev/null +++ b/internal/lint/registry/registry.json @@ -0,0 +1,395 @@ +{ + "chrome-headless": { + "name": "Headless Chrome", + "type": "chrome-headless", + "runtime": false, + "versions": { + "supported": [ + "120", + "113" + ] + } + }, + "clickhouse": { + "name": "ClickHouse", + "type": "clickhouse", + "runtime": false, + "versions": { + "supported": [ + "26.3", + "25.8" + ], + "legacy": [ + "25.3", + "24.3", + "23.8" + ] + } + }, + "dotnet": { + "name": "C#/.Net Core", + "type": "dotnet", + "runtime": true, + "versions": { + "supported": [ + "10.0", + "8.0" + ] + } + }, + "elasticsearch": { + "name": "Elasticsearch", + "type": "elasticsearch", + "runtime": false, + "versions": { + "supported": [ + "7.10" + ] + } + }, + "elasticsearch-enterprise": { + "name": "elasticsearch-enterprise", + "type": "elasticsearch-enterprise", + "runtime": false, + "versions": { + "supported": [ + "9.3", + "8.19" + ] + } + }, + "elixir": { + "name": "Elixir", + "type": "elixir", + "runtime": true, + "versions": { + "supported": [ + "1.19", + "1.15" + ], + "legacy": [ + "1.18" + ] + } + }, + "golang": { + "name": "Go", + "type": "golang", + "runtime": true, + "versions": { + "supported": [ + "1.26", + "1.25" + ] + } + }, + "gotenberg": { + "name": "Gotenberg", + "type": "gotenberg", + "runtime": false, + "versions": { + "supported": [ + "8" + ] + } + }, + "influxdb": { + "name": "InfluxDB", + "type": "influxdb", + "runtime": false, + "versions": { + "supported": [ + "3", + "2.7", + "2.3" + ] + } + }, + "java": { + "name": "Java", + "type": "java", + "runtime": true, + "versions": { + "supported": [ + "25", + "21", + "17" + ] + } + }, + "kafka": { + "name": "Kafka", + "type": "kafka", + "runtime": false, + "versions": { + "supported": [ + "4.1" + ] + } + }, + "mariadb": { + "name": "MariaDB/MySQL", + "type": "mariadb", + "runtime": false, + "versions": { + "supported": [ + "11.8", + "11.4", + "10.11", + "10.6" + ] + } + }, + "mariadb-replica": { + "name": "mariadb-replica", + "type": "mariadb-replica", + "runtime": false + }, + "memcached": { + "name": "Memcached", + "type": "memcached", + "runtime": false, + "versions": { + "supported": [ + "1.6" + ] + } + }, + "mercure": { + "name": "mercure", + "type": "mercure", + "runtime": false, + "versions": { + "supported": [ + "0" + ] + } + }, + "mongodb": { + "name": "mongodb", + "type": "mongodb", + "runtime": false, + "versions": { + "supported": [ + "4.0" + ] + } + }, + "mongodb-enterprise": { + "name": "MongoDB", + "type": "mongodb-enterprise", + "runtime": false, + "versions": { + "supported": [ + "7.0" + ] + } + }, + "mysql": { + "name": "MariaDB/MySQL", + "type": "mysql", + "runtime": false, + "versions": { + "supported": [ + "11.8", + "11.4", + "10.11", + "10.6" + ] + } + }, + "network-storage": { + "name": "Network Storage", + "type": "network-storage", + "runtime": false, + "versions": { + "supported": [ + "1.0" + ], + "legacy": [ + "2.0" + ] + } + }, + "nodejs": { + "name": "JavaScript/Node.js", + "type": "nodejs", + "runtime": true, + "versions": { + "supported": [ + "26", + "24" + ], + "legacy": [ + "22" + ] + } + }, + "opensearch": { + "name": "OpenSearch", + "type": "opensearch", + "runtime": false, + "versions": { + "supported": [ + "3" + ], + "legacy": [ + "2" + ] + } + }, + "oracle-mysql": { + "name": "Oracle MySQL", + "type": "oracle-mysql", + "runtime": false, + "versions": { + "supported": [ + "8.4" + ] + } + }, + "php": { + "name": "PHP", + "type": "php", + "runtime": true, + "versions": { + "supported": [ + "8.5", + "8.4" + ], + "legacy": [ + "8.3", + "8.2" + ] + } + }, + "postgresql": { + "name": "PostgreSQL", + "type": "postgresql", + "runtime": false, + "versions": { + "supported": [ + "18", + "17", + "16", + "15", + "14" + ] + } + }, + "postgresql-replica": { + "name": "postgresql-replica", + "type": "postgresql-replica", + "runtime": false + }, + "python": { + "name": "Python", + "type": "python", + "runtime": true, + "versions": { + "supported": [ + "3.14", + "3.13" + ], + "legacy": [ + "3.12", + "3.11", + "3.10" + ] + } + }, + "rabbitmq": { + "name": "RabbitMQ", + "type": "rabbitmq", + "runtime": false, + "versions": { + "supported": [ + "4.2" + ] + } + }, + "redis": { + "name": "Redis", + "type": "redis", + "runtime": false, + "versions": { + "supported": [ + "8.0" + ], + "legacy": [ + "7.2", + "6.2" + ] + } + }, + "ruby": { + "name": "Ruby", + "type": "ruby", + "runtime": true, + "versions": { + "supported": [ + "4.0", + "3.4", + "3.3" + ] + } + }, + "rust": { + "name": "Rust", + "type": "rust", + "runtime": true, + "versions": { + "supported": [ + "1" + ] + } + }, + "solr": { + "name": "Solr", + "type": "solr", + "runtime": false, + "versions": { + "supported": [ + "10.0", + "9.9", + "9.6", + "9.4", + "9.2", + "9.1" + ] + } + }, + "valkey": { + "name": "Valkey", + "type": "valkey", + "runtime": false, + "versions": { + "supported": [ + "9.0", + "8.1", + "8.0" + ] + } + }, + "varnish": { + "name": "Varnish", + "type": "varnish", + "runtime": false, + "versions": { + "supported": [ + "9.0", + "6.0" + ] + } + }, + "vault-kms": { + "name": "Vault KMS", + "type": "vault-kms", + "runtime": false, + "versions": { + "supported": [ + "1.12" + ] + } + } +} diff --git a/internal/lint/registry/registry_test.go b/internal/lint/registry/registry_test.go new file mode 100644 index 00000000..a847ec4c --- /dev/null +++ b/internal/lint/registry/registry_test.go @@ -0,0 +1,22 @@ +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") + // 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/relationships.go b/internal/lint/relationships.go new file mode 100644 index 00000000..2c6f6f06 --- /dev/null +++ b/internal/lint/relationships.go @@ -0,0 +1,91 @@ +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 := range cfg.Applications { + app := cfg.Applications[appName] + if msg := addService(appName, "applications"); msg != "" { + result.AddError("applications."+appName, msg) + } + for name := range app.Workers { + if msg := addService(name, "applications."+appName+".workers."+name); msg != "" { + result.AddError("applications."+appName+".workers."+name, msg) + } + } + } + for name := range cfg.Services { + if msg := addService(name, "services"); msg != "" { + result.AddError("services."+name, msg) + } + } + + var linkedServices = make(map[string]struct{}) + for appName := range cfg.Applications { + appConfig := cfg.Applications[appName] + for relName, value := range appConfig.Relationships { + // By default, the relationship links to the service with the same name. + var relationshipService = relName + var explicit bool + + // The service name can also be specified explicitly, via a map or a string. + // TODO validate the endpoint + switch details := value.(type) { + case map[string]any: + if s, ok := details["service"].(string); ok { + relationshipService = s + explicit = true + } + case string: + relationshipService = strings.SplitN(details, ":", 2)[0] + explicit = true + } + + 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..da5c127f --- /dev/null +++ b/internal/lint/result.go @@ -0,0 +1,134 @@ +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 `json:"path"` // e.g., "applications.foo.type", "services.database" + Message string `json:"message"` +} + +// 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 +} + +// 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 + } + + messages := make([]string, 0, len(issues)) + for _, issue := range issues { + if issue.Path != "" { + messages = append(messages, fmt.Sprintf(" - %s: %s", issue.Path, issue.Message)) + } else { + messages = append(messages, fmt.Sprintf(" - %s", issue.Message)) + } + } + + // 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) } + +// 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. +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) +} + +// 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{} + 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..0aea07a6 --- /dev/null +++ b/internal/lint/routes.go @@ -0,0 +1,86 @@ +package lint + +import ( + "fmt" + "sort" + "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 + availableTargets := make([]string, 0, len(validTargets)) + for target := range validTargets { + availableTargets = append(availableTargets, target) + } + sort.Strings(availableTargets) + + 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/fixed.go b/internal/lint/schema/fixed.go new file mode 100644 index 00000000..7255458a --- /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 + parsedApplication *gojsonschema.Schema + parsedRoutes *gojsonschema.Schema + parsedServices *gojsonschema.Schema + fixedErr error +) + +func loadFixed() error { + fixedOnce.Do(func() { + if parsedApplication, 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 parsedApplication, 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/schema/schema.go b/internal/lint/schema/schema.go new file mode 100644 index 00000000..464bcfaf --- /dev/null +++ b/internal/lint/schema/schema.go @@ -0,0 +1,28 @@ +package schema + +import ( + _ "embed" + "sync" + + "github.com/xeipuuv/gojsonschema" +) + +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 + 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..3e8557eb --- /dev/null +++ b/internal/lint/schema/upsun-config-schema.json @@ -0,0 +1,1286 @@ +{ + "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": [ + "type", + "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..c14aa607 --- /dev/null +++ b/internal/lint/scripts.go @@ -0,0 +1,49 @@ +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 := range cfg.Applications { + app := cfg.Applications[appName] + 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..5f749e8f --- /dev/null +++ b/internal/lint/types.go @@ -0,0 +1,98 @@ +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, style Style) *Result { + result := &Result{} + + check := func(t string, runtime bool) error { return checkType(t, reg, runtime) } + + for appName := range cfg.Applications { + app := cfg.Applications[appName] + // Composable images and the 'stack' key are a Flex-only feature. + if style == StyleFlex && app.Type == "" && !isStackEmpty(app.Stack) { + // 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 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 := range cfg.Applications { + app := cfg.Applications[appName] + for workerName, w := range app.Workers { + if w.Type != "" { + if err := check(w.Type, true); err != nil { + result.AddError("applications."+appName+".workers."+workerName+".type", err.Error()) + } + } + } + } + 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..3bcf3953 --- /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, lint.StyleFlex) + 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..60d073b0 --- /dev/null +++ b/internal/lint/web.go @@ -0,0 +1,91 @@ +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 := range cfg.Applications { + app := cfg.Applications[appName] + 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..fb641bb8 --- /dev/null +++ b/internal/lint/yaml.go @@ -0,0 +1,72 @@ +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 +} + +// 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") { + 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 +}