diff --git a/cmd/plugin/commands/lint/main.go b/cmd/plugin/commands/lint/main.go new file mode 100644 index 000000000..5140083d6 --- /dev/null +++ b/cmd/plugin/commands/lint/main.go @@ -0,0 +1,232 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lint + +import ( + "fmt" + + "github.com/spf13/cobra" + + appsv1 "k8s.io/api/apps/v1" + + "k8s.io/api/extensions/v1beta1" + kmeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/ingress-nginx/cmd/plugin/lints" + "k8s.io/ingress-nginx/cmd/plugin/request" + "k8s.io/ingress-nginx/cmd/plugin/util" + "k8s.io/ingress-nginx/version" +) + +// CreateCommand creates and returns this cobra subcommand +func CreateCommand(flags *genericclioptions.ConfigFlags) *cobra.Command { + var opts *lintOptions + cmd := &cobra.Command{ + Use: "lint", + Short: "Inspect kubernetes resources for possible issues", + RunE: func(cmd *cobra.Command, args []string) error { + err := opts.Validate() + if err != nil { + return err + } + + fmt.Println("Checking ingresses...") + err = ingresses(*opts) + if err != nil { + util.PrintError(err) + } + fmt.Println("Checking deployments...") + err = deployments(*opts) + if err != nil { + util.PrintError(err) + } + + return nil + }, + } + + opts = addCommonOptions(flags, cmd) + + cmd.AddCommand(createSubcommand(flags, []string{"ingresses", "ingress", "ing"}, "Check ingresses for possible issues", ingresses)) + cmd.AddCommand(createSubcommand(flags, []string{"deployments", "deployment", "dep"}, "Check deployments for possible issues", deployments)) + + return cmd +} + +func createSubcommand(flags *genericclioptions.ConfigFlags, names []string, short string, f func(opts lintOptions) error) *cobra.Command { + var opts *lintOptions + cmd := &cobra.Command{ + Use: names[0], + Aliases: names[1:], + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + err := opts.Validate() + if err != nil { + return err + } + util.PrintError(f(*opts)) + return nil + }, + } + + opts = addCommonOptions(flags, cmd) + + return cmd +} + +func addCommonOptions(flags *genericclioptions.ConfigFlags, cmd *cobra.Command) *lintOptions { + out := lintOptions{ + flags: flags, + } + cmd.Flags().BoolVar(&out.allNamespaces, "all-namespaces", false, "Check resources in all namespaces") + cmd.Flags().BoolVar(&out.showAll, "show-all", false, "Show all resources, not just the ones with problems") + cmd.Flags().BoolVarP(&out.verbose, "verbose", "v", false, "Show extra information about the lints") + cmd.Flags().StringVarP(&out.versionFrom, "from-version", "f", "0.0.0", "Use lints added for versions starting with this one") + cmd.Flags().StringVarP(&out.versionTo, "to-version", "t", version.RELEASE, "Use lints added for versions up to and including this one") + + return &out +} + +type lintOptions struct { + flags *genericclioptions.ConfigFlags + allNamespaces bool + showAll bool + verbose bool + versionFrom string + versionTo string +} + +func (opts *lintOptions) Validate() error { + _, _, _, err := util.ParseVersionString(opts.versionFrom) + if err != nil { + return err + } + + _, _, _, err = util.ParseVersionString(opts.versionTo) + if err != nil { + return err + } + + return nil +} + +type lint interface { + Check(obj kmeta.Object) bool + Message() string + Link() string + Version() string +} + +func checkObjectArray(lints []lint, objects []kmeta.Object, opts lintOptions) { + usedLints := make([]lint, 0) + for _, lint := range lints { + lintVersion := lint.Version() + if lint.Version() == "" { + lintVersion = "0.0.0" + } + if util.InVersionRangeInclusive(opts.versionFrom, lintVersion, opts.versionTo) { + usedLints = append(usedLints, lint) + } + } + + for _, obj := range objects { + objName := obj.GetName() + if opts.allNamespaces { + objName = obj.GetNamespace() + "/" + obj.GetName() + } + + failedLints := make([]lint, 0) + for _, lint := range usedLints { + if lint.Check(obj) { + failedLints = append(failedLints, lint) + } + } + + if len(failedLints) != 0 { + fmt.Printf("✗ %v\n", objName) + for _, lint := range failedLints { + fmt.Printf(" - %v\n", lint.Message()) + if opts.verbose && lint.Version() != "" { + fmt.Printf(" Lint added for version %v\n", lint.Version()) + } + if opts.verbose && lint.Link() != "" { + fmt.Printf(" %v\n", lint.Link()) + } + } + fmt.Println("") + continue + } + + if opts.showAll { + fmt.Printf("✓ %v\n", objName) + } + } +} + +func ingresses(opts lintOptions) error { + var ings []v1beta1.Ingress + var err error + if opts.allNamespaces { + ings, err = request.GetIngressDefinitions(opts.flags, "") + } else { + ings, err = request.GetIngressDefinitions(opts.flags, util.GetNamespace(opts.flags)) + } + if err != nil { + return err + } + + var iLints []lints.IngressLint = lints.GetIngressLints() + genericLints := make([]lint, len(iLints)) + for i := range iLints { + genericLints[i] = iLints[i] + } + + objects := make([]kmeta.Object, 0) + for i := range ings { + objects = append(objects, &ings[i]) + } + + checkObjectArray(genericLints, objects, opts) + return nil +} + +func deployments(opts lintOptions) error { + var deps []appsv1.Deployment + var err error + if opts.allNamespaces { + deps, err = request.GetDeployments(opts.flags, "") + } else { + deps, err = request.GetDeployments(opts.flags, util.GetNamespace(opts.flags)) + } + if err != nil { + return err + } + + var iLints []lints.DeploymentLint = lints.GetDeploymentLints() + genericLints := make([]lint, len(iLints)) + for i := range iLints { + genericLints[i] = iLints[i] + } + + objects := make([]kmeta.Object, 0) + for i := range deps { + objects = append(objects, &deps[i]) + } + + checkObjectArray(genericLints, objects, opts) + return nil +} diff --git a/cmd/plugin/lints/deployment.go b/cmd/plugin/lints/deployment.go new file mode 100644 index 000000000..26e218d66 --- /dev/null +++ b/cmd/plugin/lints/deployment.go @@ -0,0 +1,108 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lints + +import ( + "fmt" + "strings" + + v1 "k8s.io/api/apps/v1" + kmeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/ingress-nginx/cmd/plugin/util" +) + +// DeploymentLint is a validation for a deployment +type DeploymentLint struct { + message string + version string + issue int + f func(cmp v1.Deployment) bool +} + +// Check returns true if the lint detects an issue +func (lint DeploymentLint) Check(obj kmeta.Object) bool { + cmp := obj.(*v1.Deployment) + return lint.f(*cmp) +} + +// Message is a description of the lint +func (lint DeploymentLint) Message() string { + return lint.message +} + +// Version is the ingress-nginx version the lint was added for, or the empty string +func (lint DeploymentLint) Version() string { + return lint.version +} + +// Link is a URL to the issue or PR explaining the lint +func (lint DeploymentLint) Link() string { + if lint.issue > 0 { + return fmt.Sprintf("%v%v", util.IssuePrefix, lint.issue) + } + + return "" +} + +// GetDeploymentLints retuns all of the lints for ingresses +func GetDeploymentLints() []DeploymentLint { + return []DeploymentLint{ + removedFlag("sort-backends", 3655, "0.22.0"), + removedFlag("force-namespace-isolation", 3887, "0.24.0"), + } +} + +func removedFlag(flag string, issueNumber int, version string) DeploymentLint { + return DeploymentLint{ + message: fmt.Sprintf("Uses removed config flag --%v", flag), + issue: issueNumber, + version: version, + f: func(dep v1.Deployment) bool { + if !isIngressNginxDeployment(dep) { + return false + } + + args := getNginxArgs(dep) + for _, arg := range args { + if strings.HasPrefix(arg, fmt.Sprintf("--%v", flag)) { + return true + } + } + + return false + }, + } +} + +func getNginxArgs(dep v1.Deployment) []string { + for _, container := range dep.Spec.Template.Spec.Containers { + if len(container.Args) > 0 && container.Args[0] == "/nginx-ingress-controller" { + return container.Args + } + } + return make([]string, 0) +} + +func isIngressNginxDeployment(dep v1.Deployment) bool { + containers := dep.Spec.Template.Spec.Containers + for _, container := range containers { + if len(container.Args) > 0 && container.Args[0] == "/nginx-ingress-controller" { + return true + } + } + return false +} diff --git a/cmd/plugin/lints/ingress.go b/cmd/plugin/lints/ingress.go new file mode 100644 index 000000000..3515c3272 --- /dev/null +++ b/cmd/plugin/lints/ingress.go @@ -0,0 +1,125 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lints + +import ( + "fmt" + "strings" + + "k8s.io/api/extensions/v1beta1" + kmeta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/ingress-nginx/cmd/plugin/util" +) + +// IngressLint is a validation for an ingress +type IngressLint struct { + message string + issue int + version string + f func(ing v1beta1.Ingress) bool +} + +// Check returns true if the lint detects an issue +func (lint IngressLint) Check(obj kmeta.Object) bool { + ing := obj.(*v1beta1.Ingress) + return lint.f(*ing) +} + +// Message is a description of the lint +func (lint IngressLint) Message() string { + return lint.message +} + +// Link is a URL to the issue or PR explaining the lint +func (lint IngressLint) Link() string { + if lint.issue > 0 { + return fmt.Sprintf("%v%v", util.IssuePrefix, lint.issue) + } + + return "" +} + +// Version is the ingress-nginx version the lint was added for, or the empty string +func (lint IngressLint) Version() string { + return lint.version +} + +// GetIngressLints retuns all of the lints for ingresses +func GetIngressLints() []IngressLint { + return []IngressLint{ + removedAnnotation("add-base-url", 3174, "0.22.0"), + removedAnnotation("base-url-scheme", 3174, "0.22.0"), + removedAnnotation("session-cookie-hash", 3743, "0.24.0"), + { + message: "The rewrite-target annotation value does not reference a capture group", + issue: 3174, + version: "0.22.0", + f: rewriteTargetWithoutCaptureGroup, + }, + { + message: "Contains an annotation with the prefix 'nginx.org'. This is a prefix for https://github.com/nginxinc/kubernetes-ingress", + f: annotationPrefixIsNginxOrg, + }, + { + message: "Contains an annotation with the prefix 'nginx.com'. This is a prefix for https://github.com/nginxinc/kubernetes-ingress", + f: annotationPrefixIsNginxCom, + }, + } +} + +func annotationPrefixIsNginxCom(ing v1beta1.Ingress) bool { + for name := range ing.Annotations { + if strings.HasPrefix(name, "nginx.com/") { + return true + } + } + return false +} + +func annotationPrefixIsNginxOrg(ing v1beta1.Ingress) bool { + for name := range ing.Annotations { + if strings.HasPrefix(name, "nginx.org/") { + return true + } + } + return false +} + +func rewriteTargetWithoutCaptureGroup(ing v1beta1.Ingress) bool { + for name, val := range ing.Annotations { + if strings.HasSuffix(name, "/rewrite-target") && !strings.Contains(val, "$1") { + return true + } + } + return false +} + +func removedAnnotation(annotationName string, issueNumber int, version string) IngressLint { + return IngressLint{ + message: fmt.Sprintf("Contains the removed %v annotation.", annotationName), + issue: issueNumber, + version: version, + f: func(ing v1beta1.Ingress) bool { + for annotation := range ing.Annotations { + if strings.HasSuffix(annotation, "/"+annotationName) { + return true + } + } + return false + }, + } +} diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 49a339fcc..deea59b51 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -18,9 +18,10 @@ package main import ( "fmt" - "github.com/spf13/cobra" "os" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" //Just importing this is supposed to allow cloud authentication @@ -34,6 +35,7 @@ import ( "k8s.io/ingress-nginx/cmd/plugin/commands/general" "k8s.io/ingress-nginx/cmd/plugin/commands/info" "k8s.io/ingress-nginx/cmd/plugin/commands/ingresses" + "k8s.io/ingress-nginx/cmd/plugin/commands/lint" "k8s.io/ingress-nginx/cmd/plugin/commands/logs" "k8s.io/ingress-nginx/cmd/plugin/commands/ssh" ) @@ -57,6 +59,7 @@ func main() { rootCmd.AddCommand(logs.CreateCommand(flags)) rootCmd.AddCommand(exec.CreateCommand(flags)) rootCmd.AddCommand(ssh.CreateCommand(flags)) + rootCmd.AddCommand(lint.CreateCommand(flags)) if err := rootCmd.Execute(); err != nil { fmt.Println(err) diff --git a/cmd/plugin/request/request.go b/cmd/plugin/request/request.go index 25561dc23..1e4938479 100644 --- a/cmd/plugin/request/request.go +++ b/cmd/plugin/request/request.go @@ -18,10 +18,13 @@ package request import ( "fmt" + + appsv1 "k8s.io/api/apps/v1" apiv1 "k8s.io/api/core/v1" "k8s.io/api/extensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/cli-runtime/pkg/genericclioptions" + appsv1client "k8s.io/client-go/kubernetes/typed/apps/v1" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" extensions "k8s.io/client-go/kubernetes/typed/extensions/v1beta1" "k8s.io/ingress-nginx/cmd/plugin/util" @@ -66,6 +69,26 @@ func GetDeploymentPod(flags *genericclioptions.ConfigFlags, deployment string) ( return ings[0], nil } +// GetDeployments returns an array of Deployments +func GetDeployments(flags *genericclioptions.ConfigFlags, namespace string) ([]appsv1.Deployment, error) { + rawConfig, err := flags.ToRESTConfig() + if err != nil { + return make([]appsv1.Deployment, 0), err + } + + api, err := appsv1client.NewForConfig(rawConfig) + if err != nil { + return make([]appsv1.Deployment, 0), err + } + + deployments, err := api.Deployments(namespace).List(metav1.ListOptions{}) + if err != nil { + return make([]appsv1.Deployment, 0), err + } + + return deployments.Items, nil +} + // GetIngressDefinitions returns an array of Ingress resource definitions func GetIngressDefinitions(flags *genericclioptions.ConfigFlags, namespace string) ([]v1beta1.Ingress, error) { rawConfig, err := flags.ToRESTConfig() diff --git a/cmd/plugin/util/util.go b/cmd/plugin/util/util.go index 8a0753e9f..913eeeb38 100644 --- a/cmd/plugin/util/util.go +++ b/cmd/plugin/util/util.go @@ -18,6 +18,9 @@ package util import ( "fmt" + "regexp" + "strconv" + "github.com/spf13/cobra" apiv1 "k8s.io/api/core/v1" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -29,6 +32,11 @@ const ( DefaultIngressServiceName = "ingress-nginx" ) +// IssuePrefix is the github url that we can append an issue number to to link to it +const IssuePrefix = "https://github.com/kubernetes/ingress-nginx/issues/" + +var versionRegex = regexp.MustCompile(`(\d)+\.(\d)+\.(\d)+.*`) + // PrintError receives an error value and prints it if it exists func PrintError(e error) { if e != nil { @@ -51,6 +59,48 @@ func printOrError(s string, e error) error { return nil } +// ParseVersionString returns the major, minor, and patch numbers of a verison string +func ParseVersionString(v string) (int, int, int, error) { + parts := versionRegex.FindStringSubmatch(v) + + if len(parts) != 4 { + return 0, 0, 0, fmt.Errorf("Could not parse %v as a version string (like 0.20.3)", v) + } + + major, _ := strconv.Atoi(parts[1]) + minor, _ := strconv.Atoi(parts[2]) + patch, _ := strconv.Atoi(parts[3]) + + return major, minor, patch, nil +} + +// InVersionRangeInclusive checks that the middle version is between the other two versions +func InVersionRangeInclusive(start, v, stop string) bool { + return !isVersionLessThan(v, start) && !isVersionLessThan(stop, v) +} + +func isVersionLessThan(a, b string) bool { + aMajor, aMinor, aPatch, err := ParseVersionString(a) + if err != nil { + panic(err) + } + + bMajor, bMinor, bPatch, err := ParseVersionString(b) + if err != nil { + panic(err) + } + + if aMajor != bMajor { + return aMajor < bMajor + } + + if aMinor != bMinor { + return aMinor < bMinor + } + + return aPatch < bPatch +} + // AddPodFlag adds a --pod flag to a cobra command func AddPodFlag(cmd *cobra.Command) *string { v := ""