-
Notifications
You must be signed in to change notification settings - Fork 6
feat(lint): native config lint command for Flex and Fixed config #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pjcdawkins
wants to merge
36
commits into
main
Choose a base branch
from
feat/native-lint-command
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
8951f2d
feat(lint): port pure config linter from ai-api
pjcdawkins e037496
feat(lint): add native lint command for Flex config
pjcdawkins 4cdf0d3
feat(lint): support Fixed-style (legacy Platform.sh) config
pjcdawkins 805a54f
feat(lint): refresh embedded registry and schemas from upstream
pjcdawkins 068c5b9
docs(lint): document path argument and output flags
pjcdawkins acebaf6
fix(lint): don't read stdin in non-interactive contexts; review fixes
pjcdawkins 446db76
fix(lint): sort available route targets for deterministic output
pjcdawkins bc3fbc6
feat(lint): resolve project root and detect style from vendor config
pjcdawkins 1603cf5
feat(lint): clearer command output
pjcdawkins 196654c
Drop GOEXPERIMENT=jsonv2
bojanz a7dc7ae
fix(lint): build Flex glob patterns with forward slashes
pjcdawkins edea3fb
fix(lint): always emit JSON arrays for errors and warnings
pjcdawkins f3e00fb
feat(lint): check the shell syntax of worker commands
pjcdawkins aaccab5
chore(lint): drop unused registry helper and tidy tests and docs
pjcdawkins 49da939
fix(lint): accept valkey-persistent and replica service types
pjcdawkins d76dab3
fix(lint): scope worker names to their application
pjcdawkins 3f311a6
chore(lint): refresh embedded registry and derive replica types
pjcdawkins cf808f6
refactor(lint): require --stdin for piped input and drop unused param…
pjcdawkins 8424257
chore(lint): refresh embedded registry
pjcdawkins 7d4553c
fix(lint): address review findings
pjcdawkins 9678f31
fix(lint): accept type alongside stack in Fixed app config
pjcdawkins ef8e33a
feat(lint): warn when stack is set with a non-composable type
pjcdawkins d5fa7ee
feat(lint): allow retired image versions with a warning
pjcdawkins 5a3d9ef
fix(lint): omit the version list when a type has no supported versions
pjcdawkins 645c99b
style(lint): normalize the Flex schema's JSON formatting
pjcdawkins acd0dfb
style(lint): normalize the Fixed application schema's JSON formatting
pjcdawkins 9cebf68
feat(lint): support tasks and workload authorizations
pjcdawkins daf0268
fix(lint): count service mounts and accept PCRE named groups
pjcdawkins b92d97b
fix(lint): drop the missing start command warning
pjcdawkins 6b5cdfb
fix(lint): allow Fixed operations and warn on unused services
pjcdawkins 9d2b5f9
feat(lint): resolve YAML tags and report files and line numbers
pjcdawkins 9ead80a
fix(lint): confine includes to the project and follow YAML aliases
pjcdawkins 45f27e9
fix(lint): address review findings on output and Fixed rules
pjcdawkins 875e21c
fix(lint): say the configuration is valid when there are only warnings
pjcdawkins 8271c79
test(lint): use a Fixed-style project in the command test
pjcdawkins 8cd6411
feat(lint): support egress, OCI images, container profiles and struct…
pjcdawkins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| package commands | ||
|
|
||
| import ( | ||
| "cmp" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "path/filepath" | ||
| "slices" | ||
| "strconv" | ||
| "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 { | ||
| // Report operational errors as lint errors in JSON, so there is always a document. | ||
| if format == "json" { | ||
| result = &lint.Result{Errors: []lint.Issue{{Message: err.Error()}}} | ||
| return printLintResult(cmd, result, format) | ||
| } | ||
| // Print operational errors ourselves, since the command silences errors. | ||
| // Go error strings are lowercase by convention; capitalize for display. | ||
| fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(capitalizeFirst(err.Error()))) | ||
|
pjcdawkins marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
|
|
||
| if explicitStdin && len(args) > 0 { | ||
| return nil, format, errors.New("--stdin cannot be used with a path") | ||
| } | ||
| if explicitStdin { | ||
| result, err := lintStdin(cmd) | ||
| return result, format, err | ||
| } | ||
|
|
||
| // An explicit path is linted as given; by default the enclosing repository root is used. | ||
| var root string | ||
| if len(args) == 1 { | ||
| abs, err := filepath.Abs(args[0]) | ||
| if err != nil { | ||
| return nil, format, err | ||
| } | ||
| if fi, err := os.Stat(abs); err != nil { | ||
| return nil, format, err | ||
| } else if !fi.IsDir() { | ||
| return nil, format, fmt.Errorf("not a directory: %s", args[0]) | ||
| } | ||
| root = abs | ||
| } else { | ||
| root = lint.FindProjectRoot(".") | ||
| } | ||
| if format == "text" { | ||
| fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root)) | ||
| } | ||
| result, _, err := lint.CheckDir(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(cmd *cobra.Command) (*lint.Result, error) { | ||
| content, err := io.ReadAll(cmd.InOrStdin()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return lint.CheckContent(string(content)) | ||
| } | ||
|
|
||
| // issuesOrEmpty replaces a nil slice with an empty one, so that the JSON output | ||
| // always contains arrays rather than null. | ||
| func issuesOrEmpty(issues []lint.Issue) []lint.Issue { | ||
| if issues == nil { | ||
| return []lint.Issue{} | ||
| } | ||
| return issues | ||
| } | ||
|
|
||
| func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error { | ||
| if format == "json" { | ||
| out := struct { | ||
| Errors []lint.Issue `json:"errors"` | ||
| Warnings []lint.Issue `json:"warnings"` | ||
| }{Errors: issuesOrEmpty(result.Errors), Warnings: issuesOrEmpty(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 | ||
| } | ||
|
|
||
| // The report is the command's output, so it is not hidden by --quiet. | ||
| w := cmd.OutOrStdout() | ||
| printIssues(w, color.New(color.FgRed, color.Bold), "Errors", result.Errors) | ||
| printIssues(w, color.New(color.FgYellow, color.Bold), "Warnings", result.Warnings) | ||
| if result.HasErrors() { | ||
| return errLintFailed | ||
| } | ||
| switch n := len(result.Warnings); n { | ||
| case 0: | ||
| fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.") | ||
| case 1: | ||
| fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid, with 1 warning.") | ||
| default: | ||
| fmt.Fprintf(w, "%s The configuration is valid, with %d warnings.\n", color.GreenString("✓"), n) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // printIssues prints a colored heading followed by the issues grouped by file, | ||
| // each with its line number and path, and the message below. For example: | ||
| // | ||
| // Errors: | ||
| // .upsun/config.yaml | ||
| // 28 applications.app.authorizations.0.action | ||
| // authorization type 'env' only allows the action 'view' | ||
| // | ||
| // It is a no-op when there are no issues. | ||
| func printIssues(w io.Writer, heading *color.Color, title string, issues []lint.Issue) { | ||
| if len(issues) == 0 { | ||
| return | ||
| } | ||
| sorted := slices.Clone(issues) | ||
| slices.SortStableFunc(sorted, func(a, b lint.Issue) int { | ||
| return cmp.Or(cmp.Compare(a.File, b.File), cmp.Compare(a.Line, b.Line), | ||
| cmp.Compare(a.Path, b.Path), cmp.Compare(a.Message, b.Message)) | ||
| }) | ||
|
|
||
| fmt.Fprintln(w, heading.Sprint(title+":")) | ||
| for len(sorted) > 0 { | ||
| n := 1 | ||
| for n < len(sorted) && sorted[n].File == sorted[0].File { | ||
| n++ | ||
| } | ||
| group := sorted[:n] | ||
| sorted = sorted[n:] | ||
| indent := " " | ||
| if file := group[0].File; file != "" { | ||
| fmt.Fprintln(w, " "+color.New(color.Bold).Sprint(file)) | ||
| indent = " " | ||
| } | ||
| // Right-align the line numbers within the file. | ||
| width := 0 | ||
| for _, issue := range group { | ||
| if issue.Line > 0 { | ||
| width = max(width, len(strconv.Itoa(issue.Line))) | ||
| } | ||
| } | ||
| msgIndent := indent + " " | ||
| if width > 0 { | ||
| msgIndent = indent + strings.Repeat(" ", width+2) | ||
| } | ||
| for _, issue := range group { | ||
| var line string | ||
| if width > 0 { | ||
| line = strings.Repeat(" ", width+2) | ||
| if issue.Line > 0 { | ||
| line = color.New(color.Faint).Sprintf("%*d", width, issue.Line) + " " | ||
| } | ||
| } | ||
| if issue.Path == "" { | ||
| fmt.Fprintln(w, indent+line+issue.Message) | ||
| continue | ||
| } | ||
| fmt.Fprintln(w, indent+line+color.CyanString(issue.Path)) | ||
| fmt.Fprintln(w, msgIndent+issue.Message) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.