Add ability to audit a directory of files (#70)

* refactor kubernetes API usage

* add ability to audit directory

* refactor a bit

* fix return statement

* fix main.go

* add ability to audit multiple resources in a single file
This commit is contained in:
Bobby Brennan
2019-05-07 12:42:57 -04:00
committed by GitHub
parent 33f2a875c4
commit 520d6572e4
14 changed files with 333 additions and 94 deletions

13
main.go
View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

158
pkg/kube/resources.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: two

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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
---

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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)
}