Skip to content
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

Add support for generating lint output #142

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
122 changes: 122 additions & 0 deletions pkg/cmd/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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 {
position, err := getPosition(c.Field, 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 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(field string, source []byte) (position, error) {
path, err := yaml.PathString(fmt.Sprintf("$.%s", field))
if err != nil {
return position{}, err
}
file, err := parser.ParseBytes([]byte(source), 0)
if err != nil {
return position{}, err
}
node, err := path.FilterFile(file)
if err != nil {
return position{}, err
}
return position{
Line: node.GetToken().Position.Line,
Column: node.GetToken().Position.Column,
}, nil
}
135 changes: 135 additions & 0 deletions pkg/cmd/lint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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"),
},
{
name: "single error of complex field",
input: map[string][]metav1.Status{
"../../testcases/manifests/error_x_list_map_duplicate_key.yaml": {
{Status: metav1.StatusFailure, Reason: "invalid", Details: &metav1.StatusDetails{
Causes: []metav1.StatusCause{
{
Type: "FieldValueDuplicate",
Field: "spec.containers[0].ports[2]",
Message: `Duplicate value: map[string]interface{}{"key":"value"}`,
},
},
}},
},
},
expected: `../../testcases/manifests/error_x_list_map_duplicate_key.yaml:51:19: field "spec.containers[0].ports[2]": (reason: "FieldValueDuplicate"; Duplicate value: map[string]interface{}{"key":"value"})`,
},
}
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))
})
}
}
Loading