diff --git a/backend/cmd/kubeyaml/kubeyaml.go b/backend/cmd/kubeyaml/kubeyaml.go index 64f1889..3f9edef 100644 --- a/backend/cmd/kubeyaml/kubeyaml.go +++ b/backend/cmd/kubeyaml/kubeyaml.go @@ -27,9 +27,10 @@ contents */ type options struct { - Versions []string - versions *string - silent *bool + Versions []string + versions *string + silent *bool + ignoreEmpty *bool } func (o *options) Validate() error { @@ -47,11 +48,39 @@ func main() { } } +// SplitAt is a bufio.SplitFunc that allows to split on a specific string +func SplitAt(substring string) func(data []byte, atEOF bool) (advance int, token []byte, err error) { + searchBytes := []byte(substring) + searchLen := len(searchBytes) + return func(data []byte, atEOF bool) (advance int, token []byte, err error) { + dataLen := len(data) + + // Return nothing if at end of file and no data passed + if atEOF && dataLen == 0 { + return 0, nil, nil + } + + // Find next separator and return token + if i := bytes.Index(data, searchBytes); i >= 0 { + return i + searchLen, data[0:i], nil + } + + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return dataLen, data, nil + } + + // Request more data. + return 0, nil, nil + } +} + func run(in io.Reader, out io.Writer, args ...string) error { opts := &options{} validate := flag.NewFlagSet("validate", flag.ExitOnError) opts.versions = validate.String("versions", "1.19,1.18,1.17,1.16,1.15", "comma separated list of kubernetes versions to validate against") opts.silent = validate.Bool("silent", false, "if true, kubeyaml will not print any output") + opts.ignoreEmpty = validate.Bool("ignore-empty", false, "if true, kubeyaml will not treat empty YAML documents as error") validate.Parse(args) err := opts.Validate() if err != nil { @@ -63,39 +92,48 @@ func run(in io.Reader, out io.Writer, args ...string) error { // Read the input reader := bufio.NewReader(in) - var input bytes.Buffer - readerCopy := io.TeeReader(reader, &input) - i, err := loader.Load(readerCopy) - if err != nil { - return &mainError{input.String(), err} - } - - aggregatedErrors := &aggErr{} - for _, version := range opts.Versions { - reslover, err := kubernetes.NewResolver(version) + scanner := bufio.NewScanner(reader) + // Split input YAML into separate documents + scanner.Split(SplitAt("\n---\n")) + for scanner.Scan() { + input := scanner.Bytes() + i, err := loader.LoadBytes(input) if err != nil { - aggregatedErrors.Add(fmt.Errorf("%s: %v", version, err)) - continue + _, ok := err.(*kubernetes.EmptyDocument) + if ok && *opts.ignoreEmpty { + continue + } else { + return &mainError{string(input), err} + } } - validator := kubernetes.NewValidator(reslover) - schema, err := reslover.Resolve(gf.APIKey(i.APIVersion, i.Kind)) - if err != nil { - aggregatedErrors.Add(fmt.Errorf("%s: %v", version, err)) - continue - } + aggregatedErrors := &aggErr{} + for _, version := range opts.Versions { + reslover, err := kubernetes.NewResolver(version) + if err != nil { + aggregatedErrors.Add(fmt.Errorf("%s: %v", version, err)) + continue + } + validator := kubernetes.NewValidator(reslover) - if len(aggregatedErrors.errors) > 0 { - return aggregatedErrors - } + schema, err := reslover.Resolve(gf.APIKey(i.APIVersion, i.Kind)) + if err != nil { + aggregatedErrors.Add(fmt.Errorf("%s: %v", version, err)) + continue + } + + if len(aggregatedErrors.errors) > 0 { + return aggregatedErrors + } - errors := validator.Validate(i.Data, schema) - if len(errors) > 0 { - if !*opts.silent { - fmt.Fprintln(out, string(redbg(errors[0].Error()))) - fmt.Fprintln(out, colorize(errors[0], input.Bytes())) + errors := validator.Validate(i.Data, schema) + if len(errors) > 0 { + if !*opts.silent { + fmt.Fprintln(out, string(redbg(errors[0].Error()))) + fmt.Fprintln(out, colorize(errors[0], input)) + } + return &aggErr{errors} } - return &aggErr{errors} } } return nil diff --git a/backend/cmd/kubeyaml/kubeyaml_test.go b/backend/cmd/kubeyaml/kubeyaml_test.go index a950861..d494a0a 100644 --- a/backend/cmd/kubeyaml/kubeyaml_test.go +++ b/backend/cmd/kubeyaml/kubeyaml_test.go @@ -10,6 +10,7 @@ import ( func TestIntegrations(t *testing.T) { testcases := []struct { filename string + extraArgs string shouldValidate bool }{ // missing a selector. @@ -18,6 +19,12 @@ func TestIntegrations(t *testing.T) { {filename: "issue-8.yaml", shouldValidate: false}, // type Airflow is invalid. But we don't validate data {filename: "issue-9.yaml", shouldValidate: true}, + // first document is valid, second is not + {filename: "issue-7.yaml", shouldValidate: false}, + // first two documents are valid, but there is an empty one (which fails) + {filename: "issue-7_2.yaml", shouldValidate: false}, + // unless -ignore-empty is set + {filename: "issue-7_2.yaml", extraArgs: "-ignore-empty", shouldValidate: true}, } for _, tc := range testcases { @@ -27,7 +34,7 @@ func TestIntegrations(t *testing.T) { t.Fatal(err) } var b bytes.Buffer - err = run(f, &b, "-silent") + err = run(f, &b, "-silent", tc.extraArgs) if tc.shouldValidate && err != nil { t.Fatal(err) } diff --git a/backend/cmd/kubeyaml/testdata/issue-7.yaml b/backend/cmd/kubeyaml/testdata/issue-7.yaml new file mode 100644 index 0000000..1794ddd --- /dev/null +++ b/backend/cmd/kubeyaml/testdata/issue-7.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/azure-load-balancer-internal: "true" + name: test + namespace: tester +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: 5000 + selector: + app: tes + sessionAffinity: None + type: Airflow +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/azure-load-balancer-internal: "true" + name: test + namespace: tester +INVALIDspec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: 5000 + selector: + app: tes + sessionAffinity: None + type: Airflow diff --git a/backend/cmd/kubeyaml/testdata/issue-7_2.yaml b/backend/cmd/kubeyaml/testdata/issue-7_2.yaml new file mode 100644 index 0000000..db343bf --- /dev/null +++ b/backend/cmd/kubeyaml/testdata/issue-7_2.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/azure-load-balancer-internal: "true" + name: test + namespace: tester +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: 5000 + selector: + app: tes + sessionAffinity: None + type: Airflow +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + service.beta.kubernetes.io/azure-load-balancer-internal: "true" + name: test + namespace: tester +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: 5000 + selector: + app: tes + sessionAffinity: None + type: Airflow +--- + diff --git a/backend/internal/kubernetes/errors.go b/backend/internal/kubernetes/errors.go index ab0787e..33a6f16 100644 --- a/backend/internal/kubernetes/errors.go +++ b/backend/internal/kubernetes/errors.go @@ -156,3 +156,12 @@ func NewUnknownFormatError(format string) error { Format: format, } } + +type EmptyDocument struct{} + +func (e *EmptyDocument) Error() string { + return fmt.Sprintf("empty document") +} +func NewEmptyDocument() error { + return &EmptyDocument{} +} diff --git a/backend/internal/kubernetes/loader.go b/backend/internal/kubernetes/loader.go index 3331043..59f2e32 100644 --- a/backend/internal/kubernetes/loader.go +++ b/backend/internal/kubernetes/loader.go @@ -23,19 +23,18 @@ func NewLoader() *Loader { return &Loader{} } -// Load reads the input and returns the internal type representing the top level document +// LoadBytes unmarshals the input and returns the internal type representing the top level document // that is properly cleaned. -func (l *Loader) Load(reader io.Reader) (*Input, error) { - b, err := ioutil.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("failed to read incoming reader: %v", err) - } - +func (l *Loader) LoadBytes(b []byte) (*Input, error) { incoming := map[interface{}]interface{}{} if err := yaml.Unmarshal(b, incoming); err != nil { return nil, fmt.Errorf("failed to unmarshal yaml with error %v", err) } + if len(incoming) == 0 { + return nil, NewEmptyDocument() + } + val, ok := incoming["apiVersion"] if !ok { return nil, NewRequiredKeyNotFoundError("apiVersion", []string{"apiVersion"}) @@ -64,3 +63,13 @@ func (l *Loader) Load(reader io.Reader) (*Input, error) { Data: incoming, }, nil } + +// Load reads the input and returns the internal type representing the top level document +// that is properly cleaned (via LoadBytes) +func (l *Loader) Load(reader io.Reader) (*Input, error) { + b, err := ioutil.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read incoming reader: %v", err) + } + return l.LoadBytes(b) +}