diff --git a/main.go b/main.go index 84849c30..ae1ff475 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,7 @@ func main() { dashboard := flag.Bool("dashboard", false, "Runs the webserver for Fairwinds dashboard.") webhook := flag.Bool("webhook", false, "Runs the webhook webserver.") audit := flag.Bool("audit", false, "Runs a one-time audit.") + auditPath := flag.String("audit-path", "", "If specified, audits one or more YAML files instead of a cluster") dashboardPort := flag.Int("dashboard-port", 8080, "Port for the dashboard webserver") webhookPort := flag.Int("webhook-port", 9876, "Port for the webhook webserver") auditOutputURL := flag.String("output-url", "", "Destination URL to send audit results") @@ -74,14 +75,15 @@ func main() { if *webhook { startWebhookServer(c, *disableWebhookConfigInstaller, *webhookPort) } else if *dashboard { - startDashboardServer(c, *dashboardPort) + k, _ := kube.CreateResourceProvider(*auditPath) + startDashboardServer(c, k, *dashboardPort) } else if *audit { - runAudit(c, *auditOutputFile, *auditOutputURL) + k, _ := kube.CreateResourceProvider(*auditPath) + runAudit(c, k, *auditOutputFile, *auditOutputURL) } } -func startDashboardServer(c conf.Configuration, port int) { - k, _ := kube.CreateKubeAPI() +func startDashboardServer(c conf.Configuration, k *kube.ResourceProvider, port int) { http.HandleFunc("/results.json", func(w http.ResponseWriter, r *http.Request) { dashboard.EndpointHandler(w, r, c, k) }) @@ -175,8 +177,7 @@ func startWebhookServer(c conf.Configuration, disableWebhookConfigInstaller bool } } -func runAudit(c conf.Configuration, outputFile string, outputURL string) { - k, _ := kube.CreateKubeAPI() +func runAudit(c conf.Configuration, k *kube.ResourceProvider, outputFile string, outputURL string) { auditData, err := validator.RunAudit(c, k) if err != nil { diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index b0d03a0b..5be8d4b9 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -133,8 +133,8 @@ func MainHandler(w http.ResponseWriter, r *http.Request, auditData validator.Aud } // EndpointHandler gets template data and renders json with it. -func EndpointHandler(w http.ResponseWriter, r *http.Request, c conf.Configuration, kubeAPI *kube.API) { - templateData, err := validator.RunAudit(c, kubeAPI) +func EndpointHandler(w http.ResponseWriter, r *http.Request, c conf.Configuration, kubeResources *kube.ResourceProvider) { + templateData, err := validator.RunAudit(c, kubeResources) if err != nil { http.Error(w, "Error Fetching Deployments", 500) return diff --git a/pkg/kube/clientset.go b/pkg/kube/clientset.go deleted file mode 100644 index 1189a1d4..00000000 --- a/pkg/kube/clientset.go +++ /dev/null @@ -1,35 +0,0 @@ -package kube - -import ( - appsv1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // Required for GKE auth. - "sigs.k8s.io/controller-runtime/pkg/client/config" -) - -// API is a wrapper for the clientset and methods to interact with the Kubernetes API. -type API struct { - Clientset kubernetes.Interface -} - -// GetDeploys gets all the deployments in the k8s cluster. -func (api *API) GetDeploys() (*appsv1.DeploymentList, error) { - return api.Clientset.AppsV1().Deployments("").List(metav1.ListOptions{}) -} - -// CreateKubeAPI returns a new KubeAPI object to interact with the cluster API with. -func CreateKubeAPI() (*API, error) { - kubeConf := config.GetConfigOrDie() - - clientset, err := kubernetes.NewForConfig(kubeConf) - if err != nil { - return nil, err - } - - // return clientset, nil - api := API{ - Clientset: clientset, - } - return &api, nil -} diff --git a/pkg/kube/resources.go b/pkg/kube/resources.go new file mode 100644 index 00000000..d0e0c042 --- /dev/null +++ b/pkg/kube/resources.go @@ -0,0 +1,158 @@ +package kube + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sirupsen/logrus" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sYaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // Required for GKE auth. + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +// ResourceProvider contains k8s resources to be audited +type ResourceProvider struct { + ServerVersion string + Nodes []corev1.Node + Deployments []appsv1.Deployment + Namespaces []corev1.Namespace + Pods []corev1.Pod +} + +type k8sResource struct { + Kind string `yaml:"kind"` +} + +// CreateResourceProvider returns a new ResourceProvider object to interact with k8s resources +func CreateResourceProvider(directory string) (*ResourceProvider, error) { + if directory != "" { + return CreateResourceProviderFromPath(directory) + } + return CreateResourceProviderFromCluster() +} + +// CreateResourceProviderFromPath returns a new ResourceProvider using the YAML files in a directory +func CreateResourceProviderFromPath(directory string) (*ResourceProvider, error) { + resources := ResourceProvider{ + ServerVersion: "unknown", + Nodes: []corev1.Node{}, + Deployments: []appsv1.Deployment{}, + Namespaces: []corev1.Namespace{}, + Pods: []corev1.Pod{}, + } + + addYaml := func(contents string) error { + contentBytes := []byte(contents) + decoder := k8sYaml.NewYAMLOrJSONDecoder(bytes.NewReader(contentBytes), 1000) + resource := k8sResource{} + err := decoder.Decode(&resource) + if err != nil { + // TODO: should we panic if the YAML is bad? + logrus.Errorf("Invalid YAML: %s", string(contents)) + return nil + } + decoder = k8sYaml.NewYAMLOrJSONDecoder(bytes.NewReader(contentBytes), 1000) + if resource.Kind == "Deployment" { + dep := appsv1.Deployment{} + err = decoder.Decode(&dep) + if err != nil { + return err + } + resources.Deployments = append(resources.Deployments, dep) + } else if resource.Kind == "Namespace" { + ns := corev1.Namespace{} + err = decoder.Decode(&ns) + if err != nil { + return err + } + resources.Namespaces = append(resources.Namespaces, ns) + } else if resource.Kind == "Pod" { + pod := corev1.Pod{} + err = decoder.Decode(&pod) + if err != nil { + return err + } + resources.Pods = append(resources.Pods, pod) + } + return nil + } + + visitFile := func(path string, f os.FileInfo, err error) error { + if !strings.HasSuffix(path, ".yml") && !strings.HasSuffix(path, ".yaml") { + return nil + } + contents, err := ioutil.ReadFile(path) + if err != nil { + return err + } + specs := regexp.MustCompile("\n-+\n").Split(string(contents), -1) + for _, spec := range specs { + err = addYaml(spec) + if err != nil { + return err + } + } + return nil + } + + err := filepath.Walk(directory, visitFile) + if err != nil { + return nil, err + } + return &resources, nil +} + +// CreateResourceProviderFromCluster creates a new ResourceProvider using live data from a cluster +func CreateResourceProviderFromCluster() (*ResourceProvider, error) { + kubeConf := config.GetConfigOrDie() + api, err := kubernetes.NewForConfig(kubeConf) + if err != nil { + return nil, err + } + return CreateResourceProviderFromAPI(api) +} + +// CreateResourceProviderFromAPI creates a new ResourceProvider from an existing k8s interface +func CreateResourceProviderFromAPI(kube kubernetes.Interface) (*ResourceProvider, error) { + listOpts := metav1.ListOptions{} + serverVersion, err := kube.Discovery().ServerVersion() + if err != nil { + return nil, err + } + deploys, err := kube.AppsV1().Deployments("").List(listOpts) + if err != nil { + return nil, err + } + nodes, err := kube.CoreV1().Nodes().List(listOpts) + if err != nil { + return nil, err + } + namespaces, err := kube.CoreV1().Namespaces().List(listOpts) + if err != nil { + return nil, err + } + allPods := []corev1.Pod{} + for _, ns := range namespaces.Items { + pods, err := kube.CoreV1().Pods(ns.Name).List(listOpts) + if err != nil { + return nil, err + } + allPods = append(allPods, pods.Items...) + } + api := ResourceProvider{ + ServerVersion: serverVersion.Major + "." + serverVersion.Minor, + Deployments: deploys.Items, + Nodes: nodes.Items, + Namespaces: namespaces.Items, + Pods: allPods, + } + return &api, nil +} diff --git a/pkg/kube/resources_test.go b/pkg/kube/resources_test.go new file mode 100644 index 00000000..aef3e479 --- /dev/null +++ b/pkg/kube/resources_test.go @@ -0,0 +1,43 @@ +package kube + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetResourcesFromPath(t *testing.T) { + resources, err := CreateResourceProviderFromPath("./test_files/test_1") + + assert.Equal(t, nil, err, "Error should be nil") + + assert.Equal(t, "unknown", resources.ServerVersion, "Server version should be unknown") + + assert.Equal(t, 0, len(resources.Nodes), "Should not have any nodes") + + assert.Equal(t, 1, len(resources.Deployments), "Should have a deployment") + assert.Equal(t, "ubuntu", resources.Deployments[0].Spec.Template.Spec.Containers[0].Name) + + assert.Equal(t, 1, len(resources.Namespaces), "Should have a namespace") + assert.Equal(t, "two", resources.Namespaces[0].ObjectMeta.Name) + + assert.Equal(t, 2, len(resources.Pods), "Should have two pods") + assert.Equal(t, "", resources.Pods[0].ObjectMeta.Namespace, "Should have one pod in default namespace") + assert.Equal(t, "two", resources.Pods[1].ObjectMeta.Namespace, "Should have one pod in namespace 'two'") +} + +func TestGetMultipleResourceFromSingleFile(t *testing.T) { + resources, err := CreateResourceProviderFromPath("./test_files/test_2/multi.yaml") + + assert.Equal(t, nil, err, "Error should be nil") + + assert.Equal(t, "unknown", resources.ServerVersion, "Server version should be unknown") + + assert.Equal(t, 0, len(resources.Nodes), "Should not have any nodes") + + assert.Equal(t, 1, len(resources.Deployments), "Should have a deployment") + assert.Equal(t, "dashboard", resources.Deployments[0].Spec.Template.Spec.Containers[0].Name) + + assert.Equal(t, 2, len(resources.Namespaces), "Should have a namespace") + assert.Equal(t, "fairwinds", resources.Namespaces[0].ObjectMeta.Name) + assert.Equal(t, "fairwinds-2", resources.Namespaces[1].ObjectMeta.Name) +} diff --git a/pkg/kube/test_files/test_1/deployment.yaml b/pkg/kube/test_files/test_1/deployment.yaml new file mode 100644 index 00000000..a55f2235 --- /dev/null +++ b/pkg/kube/test_files/test_1/deployment.yaml @@ -0,0 +1,20 @@ +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: test-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: test-deployment + template: + metadata: + labels: + app: test-deployment + spec: + containers: + - name: ubuntu + image: ubuntu + ports: + - containerPort: 3000 + diff --git a/pkg/kube/test_files/test_1/namespace.yaml b/pkg/kube/test_files/test_1/namespace.yaml new file mode 100644 index 00000000..f3d4aae2 --- /dev/null +++ b/pkg/kube/test_files/test_1/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: two diff --git a/pkg/kube/test_files/test_1/pods/pod1.yaml b/pkg/kube/test_files/test_1/pods/pod1.yaml new file mode 100644 index 00000000..bf9ecc7c --- /dev/null +++ b/pkg/kube/test_files/test_1/pods/pod1.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: hello-world +spec: + restartPolicy: Never + containers: + - name: hello + image: "ubuntu:14.04" + command: ["/bin/echo", "hello", "world"] diff --git a/pkg/kube/test_files/test_1/pods/pod2.yaml b/pkg/kube/test_files/test_1/pods/pod2.yaml new file mode 100644 index 00000000..31f81ce8 --- /dev/null +++ b/pkg/kube/test_files/test_1/pods/pod2.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Pod +metadata: + name: hello-world + namespace: two +spec: + restartPolicy: Never + containers: + - name: hello + image: "ubuntu:14.04" + command: ["/bin/echo", "hello", "world"] diff --git a/pkg/kube/test_files/test_2/multi.yaml b/pkg/kube/test_files/test_2/multi.yaml new file mode 100644 index 00000000..09c1430e --- /dev/null +++ b/pkg/kube/test_files/test_2/multi.yaml @@ -0,0 +1,53 @@ +--- +# Source: fairwinds/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: fairwinds +--- +# Source: fairwinds/templates/dashboard.deployment.yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + annotations: + checksum/config: '5702aca235561630172c22b6b900f5cebd4e82fae60389df18a3537ff82e2f09' + name: fairwinds-dashboard + namespace: fairwinds + labels: + app: fairwinds + component: dashboard +spec: + replicas: 1 + selector: + matchLabels: + app: fairwinds + component: dashboard + template: + metadata: + labels: + app: fairwinds + component: dashboard + spec: + containers: + - command: + - fairwinds + - --dashboard + image: 'quay.io/reactiveops/fairwinds:master' + imagePullPolicy: 'Always' + name: dashboard +--- +# Source: fairwinds/templates/secret.yaml + +--- +# Source: fairwinds/templates/webhook.deployment.yaml + +--- +# Source: fairwinds/templates/webhook.service.yaml +--- +# Source: fairwinds/templates/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: fairwinds-2 +--- + diff --git a/pkg/validator/deployment.go b/pkg/validator/deployment.go index fe6a516c..f5cc4085 100644 --- a/pkg/validator/deployment.go +++ b/pkg/validator/deployment.go @@ -33,14 +33,10 @@ func ValidateDeployment(conf conf.Configuration, deploy *appsv1.Deployment) Cont // ValidateDeployments validates that each deployment conforms to the Fairwinds config, // returns a list of ResourceResults organized by namespace. -func ValidateDeployments(config conf.Configuration, k8sAPI *kube.API) (NamespacedResults, error) { +func ValidateDeployments(config conf.Configuration, kubeResources *kube.ResourceProvider) (NamespacedResults, error) { nsResults := NamespacedResults{} - deploys, err := k8sAPI.GetDeploys() - if err != nil { - return nsResults, err - } - for _, deploy := range deploys.Items { + for _, deploy := range kubeResources.Deployments { deploymentResult := ValidateDeployment(config, &deploy) nsResults = addResult(deploymentResult, nsResults, deploy.Namespace) } diff --git a/pkg/validator/fullaudit.go b/pkg/validator/fullaudit.go index 7fa5c2b2..696dcbf3 100644 --- a/pkg/validator/fullaudit.go +++ b/pkg/validator/fullaudit.go @@ -3,7 +3,6 @@ package validator import ( conf "github.com/reactiveops/fairwinds/pkg/config" "github.com/reactiveops/fairwinds/pkg/kube" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -13,11 +12,12 @@ const ( // ClusterSummary contains Fairwinds results as well as some high-level stats type ClusterSummary struct { - Results ResultSummary - Version string - Nodes int - Pods int - Namespaces int + Results ResultSummary + Version string + Nodes int + Pods int + Namespaces int + Deployments int } // AuditData contains all the data from a full Fairwinds audit @@ -28,14 +28,14 @@ type AuditData struct { } // RunAudit runs a full Fairwinds audit and returns an AuditData object -func RunAudit(config conf.Configuration, kubeAPI *kube.API) (AuditData, error) { +func RunAudit(config conf.Configuration, kubeResources *kube.ResourceProvider) (AuditData, error) { // TODO: Validate StatefulSets, DaemonSets, Cron jobs // in addition to deployments // TODO: Once we are validating more than deployments, // we will need to merge the namespaceResults that get returned // from each validation. - nsResults, err := ValidateDeployments(config, kubeAPI) + nsResults, err := ValidateDeployments(config, kubeResources) if err != nil { return AuditData{}, err } @@ -49,37 +49,15 @@ func RunAudit(config conf.Configuration, kubeAPI *kube.API) (AuditData, error) { } } - kubeVersion, err := kubeAPI.Clientset.Discovery().ServerVersion() - if err != nil { - return AuditData{}, err - } - - listOpts := metav1.ListOptions{} - nodes, err := kubeAPI.Clientset.CoreV1().Nodes().List(listOpts) - if err != nil { - return AuditData{}, err - } - namespaces, err := kubeAPI.Clientset.CoreV1().Namespaces().List(listOpts) - if err != nil { - return AuditData{}, err - } - numPods := 0 - for _, ns := range namespaces.Items { - pods, err := kubeAPI.Clientset.CoreV1().Pods(ns.Name).List(listOpts) - if err != nil { - return AuditData{}, err - } - numPods += len(pods.Items) - } - auditData := AuditData{ FairwindsOutputVersion: FairwindsOutputVersion, ClusterSummary: ClusterSummary{ - Version: kubeVersion.Major + "." + kubeVersion.Minor, - Nodes: len(nodes.Items), - Pods: numPods, - Namespaces: len(namespaces.Items), - Results: clusterResults, + Version: kubeResources.ServerVersion, + Nodes: len(kubeResources.Nodes), + Pods: len(kubeResources.Pods), + Namespaces: len(kubeResources.Namespaces), + Deployments: len(kubeResources.Deployments), + Results: clusterResults, }, NamespacedResults: nsResults, } diff --git a/pkg/validator/fullaudit_test.go b/pkg/validator/fullaudit_test.go index 033c965d..9e775853 100644 --- a/pkg/validator/fullaudit_test.go +++ b/pkg/validator/fullaudit_test.go @@ -4,6 +4,7 @@ import ( "testing" conf "github.com/reactiveops/fairwinds/pkg/config" + "github.com/reactiveops/fairwinds/pkg/kube" "github.com/reactiveops/fairwinds/test" "github.com/stretchr/testify/assert" ) @@ -11,6 +12,8 @@ import ( func TestGetTemplateData(t *testing.T) { k8s := test.SetupTestAPI() k8s = test.SetupAddDeploys(k8s, "test") + resources, err := kube.CreateResourceProviderFromAPI(k8s) + assert.Equal(t, err, nil, "error should be nil") c := conf.Configuration{ HealthChecks: conf.HealthChecks{ @@ -38,7 +41,7 @@ func TestGetTemplateData(t *testing.T) { Errors: uint(0), } - actualAudit, err := RunAudit(c, k8s) + actualAudit, err := RunAudit(c, resources) assert.Equal(t, err, nil, "error should be nil") assert.EqualValues(t, sum, actualAudit.ClusterSummary.Results) diff --git a/test/fixtures.go b/test/fixtures.go index 1b5d43b3..3c3b548d 100644 --- a/test/fixtures.go +++ b/test/fixtures.go @@ -3,9 +3,9 @@ package test import ( "fmt" - "github.com/reactiveops/fairwinds/pkg/kube" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" ) @@ -40,17 +40,14 @@ func mockDeploy() appsv1.Deployment { } // SetupTestAPI creates a test kube API struct. -func SetupTestAPI() *kube.API { - api := kube.API{ - Clientset: fake.NewSimpleClientset(), - } - return &api +func SetupTestAPI() kubernetes.Interface { + return fake.NewSimpleClientset() } // SetupAddDeploys creates a mock deployment and adds it to the test clientset. -func SetupAddDeploys(k *kube.API, namespace string) *kube.API { +func SetupAddDeploys(k kubernetes.Interface, namespace string) kubernetes.Interface { d1 := mockDeploy() - _, err := k.Clientset.AppsV1().Deployments(namespace).Create(&d1) + _, err := k.AppsV1().Deployments(namespace).Create(&d1) if err != nil { fmt.Println(err) }