Skip to content

Commit

Permalink
feat: add lint error output
Browse files Browse the repository at this point in the history
  • Loading branch information
crandles committed Oct 5, 2024
1 parent ce13d95 commit 7fe3fb1
Show file tree
Hide file tree
Showing 65 changed files with 14,405 additions and 7 deletions.
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.22.1

require (
github.com/evanphx/json-patch v5.6.0+incompatible
github.com/goccy/go-yaml v1.12.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
Expand All @@ -29,6 +30,7 @@ require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fatih/color v1.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
Expand All @@ -52,6 +54,8 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.8 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
Expand Down Expand Up @@ -86,6 +90,7 @@ require (
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/grpc v1.65.0 // indirect
Expand Down
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
Expand All @@ -49,8 +51,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.12.0 h1:/1WHjnMsI1dlIBQutrvSMGZRQufVO3asrHfTwfACoPM=
github.com/goccy/go-yaml v1.12.0/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
Expand Down Expand Up @@ -105,8 +115,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -225,6 +241,8 @@ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
Expand All @@ -246,6 +264,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
Expand Down
127 changes: 127 additions & 0 deletions pkg/cmd/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package cmd

import (
"cmp"
"fmt"
"os"
"slices"
"strings"

"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/parser"
"golang.org/x/exp/maps"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// convert the given map of filenames to validation errors into a lint output format: '%f:%l:%c: %m'
// %f - file, %l - line, %c - column, %m - message
func lintMarshal(details map[string][]metav1.Status) ([]byte, error) {
const (
nilValue = "<nil>"
)
files := maps.Keys(details)
slices.Sort(files)

results := []string{}
DETAILS:
for _, file := range files {
status := details[file]
causes := make(map[string][]metav1.StatusCause)
for _, s := range status {
if s.Status == metav1.StatusSuccess {
continue DETAILS // only lint errors
}
for _, c := range s.Details.Causes {
if c.Field == nilValue {
continue // no field to lookup/annotate
}
key := string(c.Type)
causes[key] = append(causes[key], c)
}
}
if len(causes) == 0 {
continue // nothing to do, no causes deemed problematic
}
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
// group causes by position, so that we can group them together in the same output line
errors := make(map[Position][]metav1.StatusCause)
for _, items := range causes {
for _, c := range items {
path, err := yaml.PathString(fmt.Sprintf("$.%s", c.Field))
if err != nil {
return nil, err
}
position, err := getPosition(path, b)
if err != nil {
return nil, err
}
errors[position] = append(errors[position], c)
}
}
keys := maps.Keys(errors)
slices.SortFunc(keys, func(i, j Position) int {
return cmp.Or(
cmp.Compare(i.Line, j.Line),
cmp.Compare(i.Column, j.Column),
)
})
for _, position := range keys {
causes := errors[position]
messages := make(map[string][]string)
for _, c := range causes {
messages[c.Field] = append(messages[c.Field], fmt.Sprintf("(reason: %q; %s)", c.Type, c.Message))
}
fieldMessages := []string{}
for field, msgs := range messages {
fieldMessages = append(fieldMessages, fmt.Sprintf("field %q: %s", field, strings.Join(msgs, ", ")))
}
le := LintError{
File: file,
Line: position.Line,
Column: position.Column,
Message: strings.Join(fieldMessages, ", "),
}
results = append(results, le.String())
}
}
return []byte(strings.Join(results, "\n")), nil
}

type Position struct {
Line int
Column int
}

type Reason struct {
Type string
Message string
}

type LintError struct {
File string
Line int
Column int
Message string
}

func (e LintError) String() string {
return fmt.Sprintf("%s:%d:%d: %s", e.File, e.Line, e.Column, e.Message)
}

func getPosition(p *yaml.Path, source []byte) (Position, error) {
file, err := parser.ParseBytes([]byte(source), 0)
if err != nil {
return Position{}, err
}
node, err := p.FilterFile(file)
if err != nil {
return Position{}, err
}
return Position{
Line: node.GetToken().Position.Line,
Column: node.GetToken().Position.Column,
}, nil
}
118 changes: 118 additions & 0 deletions pkg/cmd/lint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package cmd

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestLintMarshal(t *testing.T) {
cases := []struct {
name string
input map[string][]metav1.Status
expected string
}{
{
name: "empty",
input: map[string][]metav1.Status{},
expected: ``,
},
{
name: "success",
input: map[string][]metav1.Status{
"file.yaml": {
{Status: metav1.StatusSuccess, Reason: "valid"},
},
},
expected: ``,
},
{
name: "single error, single cause",
input: map[string][]metav1.Status{
"../../testcases/manifests/configmap.yaml": {
{Status: metav1.StatusFailure, Reason: "invalid", Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{
Type: "FailureType",
Field: "metadata.name",
Message: "name is required or invalid somehow",
},
},
}},
},
},
expected: `../../testcases/manifests/configmap.yaml:8:9: field "metadata.name": (reason: "FailureType"; name is required or invalid somehow)`,
},
{
name: "single error with ignored success",
input: map[string][]metav1.Status{
"../../testcases/manifests/configmap.yaml": {
{Status: metav1.StatusSuccess, Reason: "valid"},
},
"../../testcases/manifests/apiservice.yaml": {
{Status: metav1.StatusFailure, Reason: "invalid", Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{
Type: "FailureType",
Field: "metadata.name",
Message: "name is required or invalid somehow but specific to apiservices",
},
},
}},
},
},
expected: `../../testcases/manifests/apiservice.yaml:14:9: field "metadata.name": (reason: "FailureType"; name is required or invalid somehow but specific to apiservices)`,
},
{
name: "multiple errors, multiple causes",
input: map[string][]metav1.Status{
"../../testcases/manifests/configmap.yaml": {
{Status: metav1.StatusFailure, Reason: "invalid", Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{
Type: "FailureType",
Field: "metadata.name",
Message: "name is required or invalid somehow 1x1",
},
{
Type: "FailureType",
Field: "metadata.finalizers",
Message: "something wrong with finalizers 1x2",
},
},
}},
},
"../../testcases/manifests/apiservice.yaml": {
{Status: metav1.StatusFailure, Reason: "invalid", Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{
Type: "FailureType",
Field: "metadata.name",
Message: "name is required or invalid somehow 2x1",
},
{
Type: "FailureType",
Field: "metadata.name",
Message: "name is required or invalid somehow 2x2",
},
},
}},
},
},
expected: strings.Join([]string{
`../../testcases/manifests/apiservice.yaml:14:9: field "metadata.name": (reason: "FailureType"; name is required or invalid somehow 2x1), (reason: "FailureType"; name is required or invalid somehow 2x2)`,
`../../testcases/manifests/configmap.yaml:8:9: field "metadata.name": (reason: "FailureType"; name is required or invalid somehow 1x1)`,
`../../testcases/manifests/configmap.yaml:10:3: field "metadata.finalizers": (reason: "FailureType"; something wrong with finalizers 1x2)`,
}, "\n"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual, err := lintMarshal(tc.input)
require.NoError(t, err)
require.Equal(t, tc.expected, string(actual))
})
}
}
25 changes: 18 additions & 7 deletions pkg/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type OutputFormat string
const (
OutputHuman OutputFormat = "human"
OutputJSON OutputFormat = "json"
OutputLint OutputFormat = "lint"
)

// String is used both by fmt.Print and by Cobra in help text
Expand All @@ -42,12 +43,12 @@ func (e *OutputFormat) String() string {

// Set must have pointer receiver so it doesn't change the value of a copy
func (e *OutputFormat) Set(v string) error {
switch v {
case "human", "json":
switch OutputFormat(v) {
case OutputHuman, OutputJSON, OutputLint:
*e = OutputFormat(v)
return nil
default:
return fmt.Errorf(`must be one of "human", or "json"`)
return fmt.Errorf(`must be one of "human", "json", or "lint"`)
}
}

Expand Down Expand Up @@ -82,7 +83,7 @@ func NewRootCommand() *cobra.Command {
res.Flags().StringVarP(&invoked.localSchemasDir, "local-schemas", "", "", "--local-schemas=./path/to/schemas/dir. Path to a directory with format: /apis/<group>/<version>.json for each group-version's schema.")
res.Flags().StringSliceVarP(&invoked.localCRDsDir, "local-crds", "", []string{}, "--local-crds=./path/to/crds/dir. Paths to directories containing .yaml or .yml files for CRD definitions.")
res.Flags().StringVarP(&invoked.schemaPatchesDir, "schema-patches", "", "", "Path to a directory with format: /apis/<group>/<version>.json for each group-version's schema you wish to jsonpatch to the groupversion's final schema. Patches only apply if the schema exists")
res.Flags().VarP(&invoked.outputFormat, "output", "o", "Output format. Choice of: \"human\" or \"json\"")
res.Flags().VarP(&invoked.outputFormat, "output", "o", "Output format. Choice of: \"human\", \"json\", or \"lint\"")
clientcmd.BindOverrideFlags(&invoked.kubeConfigOverrides, res.Flags(), clientcmd.RecommendedConfigOverrideFlags("kube-"))
return res
}
Expand Down Expand Up @@ -245,9 +246,19 @@ func (c *commandFlags) Run(cmd *cobra.Command, args []string) error {
hasError = hasError || err != nil
}
}
data, e := json.MarshalIndent(res, "", " ")
if e != nil {
return InternalError{fmt.Errorf("failed to render results into JSON: %w", e)}
var data []byte
if c.outputFormat == OutputLint {
var err error
data, err = lintMarshal(res)
if err != nil {
return InternalError{fmt.Errorf("failed to render results into lint format: %w", err)}
}
} else if c.outputFormat == OutputJSON {
var err error
data, err = json.MarshalIndent(res, "", " ")
if err != nil {
return InternalError{fmt.Errorf("failed to render results into JSON: %w", err)}
}
}
fmt.Fprintln(cmd.OutOrStdout(), string(data))
}
Expand Down
Loading

0 comments on commit 7fe3fb1

Please sign in to comment.