├── .gitignore ├── main.go ├── acceptance.bats ├── glide.yaml ├── examples ├── rc.yaml └── tests │ └── main.sky ├── Dockerfile ├── LICENSE ├── .travis.yml ├── glide.lock ├── cmd └── root.go ├── kubetest └── kubetest.go ├── Makefile ├── README.md └── assert └── assert.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | bin 3 | releases 4 | .bats 5 | *.exe 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/garethr/kubetest/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /acceptance.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | @test "Fail when run with basic examples" { 4 | run kubetest --tests examples/tests examples/rc.yaml 5 | [ "$status" -eq 1 ] 6 | } 7 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/garethr/kubetest 2 | import: 3 | - package: github.com/google/skylark 4 | - package: github.com/garethr/skyhook 5 | - package: github.com/sirupsen/logrus 6 | version: ^1.0.3 7 | - package: golang.org/x/crypto 8 | subpackages: 9 | - ssh/terminal 10 | -------------------------------------------------------------------------------- /examples/rc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ReplicationController 3 | metadata: 4 | name: "bob" 5 | spec: 6 | replicas: 2 7 | selector: 8 | app: nginx 9 | template: 10 | metadata: 11 | name: nginx 12 | labels: 13 | app: nginx 14 | spec: 15 | containers: 16 | - name: nginx 17 | image: nginx 18 | ports: 19 | - containerPort: 80 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.8-alpine as builder 2 | RUN apk --no-cache add make git 3 | RUN mkdir -p /go/src/github.com/garethr/kubetest/ 4 | COPY . /go/src/github.com/garethr/kubetest/ 5 | WORKDIR /go/src/github.com/garethr/kubetest/ 6 | RUN make linux 7 | 8 | FROM alpine:latest 9 | RUN apk --no-cache add ca-certificates 10 | COPY --from=builder /go/src/github.com/garethr/kubetest/bin/linux/amd64/kubetest . 11 | ENTRYPOINT ["/kubetest"] 12 | CMD ["--help"] 13 | -------------------------------------------------------------------------------- /examples/tests/main.sky: -------------------------------------------------------------------------------- 1 | #// vim: set ft=python: 2 | 3 | def test_for_latest_image(): 4 | if spec["kind"] == "ReplicationController": 5 | for container in spec["spec"]["template"]["spec"]["containers"]: 6 | tag = container["image"].split(":")[-1] 7 | assert_not_equal(tag, "latest", "should not use latest images") 8 | 9 | 10 | def test_minimum_replicas(): 11 | if spec["kind"] == "ReplicationController": 12 | test = spec["spec"]["replicas"] >= 4 13 | assert_true(test, "ReplicationController should have at least 4 replicas") 14 | 15 | 16 | test_for_latest_image() 17 | test_minimum_replicas() 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Kubetest - Write tests for your Kubernetes configuration files 2 | 3 | Copyright (C) 2017 Gareth Rushgrove 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | https://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | go: 4 | - 1.8 5 | jobs: 6 | include: 7 | - script: make vet check lint coveralls 8 | - script: PATH=bin/linux/amd64:$PATH make build acceptance 9 | - stage: deploy 10 | - script: make build 11 | deploy: 12 | skip_cleanup: true 13 | provider: releases 14 | api_key: 15 | secure: GhXT1vjiEa71q9QioKk/HpQx/VtxCj8cHuzKR6SdM3aJ5TbCXSMbPwWKB4dLT5AJFv1IAiciTotN6IsaKQ8NpPoIGaSxFz+fVXJH9t3uFiJp8RAwsi5Rskq9TV0130aCK6FVm4cYhEk92i1MR6zr4kBLYz82UsQFMZrEfcLr868rqBr1m2wknxfS/dbYACPvl8gZuWjai4FvVTdZZ/WURVIKUM4iqdk23Ps55ZGSBeUiUxfzt0qGhP79NDgPzeyRYL8cIqIJiyEnXTmaHErLxdh8/RcGfqz4tlnMOeIJwmq/plla8tsDtTxm8YwKjvD/h9aBKcgc/lnW83bGiTjYPfX8cLmZyXJo2s2skyCItVXlYiFYh868sQBbbYCh6hNklUtHUvHUfq1H/UZSgen+22qLT8kVwq6RZajWrlAj0Zui0prZCyAl7XL11zXbfu7rdmpX0J99g8Nrd4r2Il+KyyPQgV4WD3dopGau1hQDv1GXNjWWEJh+ZHDA4/d80NnM5jv6+JcqdRAN21299o6rzoMHDOBoRCIXmwn7B7IBiPYZnAUmEPoqzkk06PveMbh3JG8p3oHfXOslowotO2mQmP7Q1lNwMaccawh9tSVSmX9ZSShXZIqHn0iKpbdrdS5MO9Nql9BFUtt22EF4az3mUtIvwVsd4qyNtGtfMxoGgLw= 16 | file: 17 | - releases/kubetest-darwin-amd64.tar.gz 18 | - releases/kubetest-windows-amd64.tar.gz 19 | - releases/kubetest-windows-amd64.zip 20 | - releases/kubetest-windows-386.tar.gz 21 | - releases/kubetest-windows-386.zip 22 | - releases/kubetest-linux-amd64.tar.gz 23 | on: 24 | repo: garethr/kubetest 25 | tags: true 26 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: 716fb45c9580dd597a42affeafe93a4df7546e77709bd2c9366e5289f1675cdb 2 | updated: 2017-11-13T17:01:11.7346927Z 3 | imports: 4 | - name: github.com/fsnotify/fsnotify 5 | version: 4da3e2cfbabc9f751898f250b49f2439785783a1 6 | - name: github.com/garethr/skyhook 7 | version: 2eca010bcbbc6c21942c2d2f2cda7fc3e7caa0d7 8 | subpackages: 9 | - convert 10 | - name: github.com/google/skylark 11 | version: 25f3813368b0c3c0a1ca3b7fd1dab0e876f35dac 12 | subpackages: 13 | - resolve 14 | - syntax 15 | - name: github.com/hashicorp/hcl 16 | version: 68e816d1c783414e79bc65b3994d9ab6b0a722ab 17 | subpackages: 18 | - hcl/ast 19 | - hcl/parser 20 | - hcl/scanner 21 | - hcl/strconv 22 | - hcl/token 23 | - json/parser 24 | - json/scanner 25 | - json/token 26 | - name: github.com/inconshreveable/mousetrap 27 | version: 76626ae9c91c4f2a10f34cad8ce83ea42c93bb75 28 | - name: github.com/magiconair/properties 29 | version: 8d7837e64d3c1ee4e54a880c5a920ab4316fc90a 30 | - name: github.com/mitchellh/mapstructure 31 | version: d0303fe809921458f417bcf828397a65db30a7e4 32 | - name: github.com/pelletier/go-toml 33 | version: 1d6b12b7cb290426e27e6b4e38b89fcda3aeef03 34 | - name: github.com/sirupsen/logrus 35 | version: f006c2ac4710855cf0f916dd6b77acf6b048dc6e 36 | - name: github.com/spf13/afero 37 | version: ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b 38 | subpackages: 39 | - mem 40 | - name: github.com/spf13/cast 41 | version: acbeb36b902d72a7a4c18e8f3241075e7ab763e4 42 | - name: github.com/spf13/cobra 43 | version: b78744579491c1ceeaaa3b40205e56b0591b93a3 44 | - name: github.com/spf13/jwalterweatherman 45 | version: 12bd96e66386c1960ab0f74ced1362f66f552f7b 46 | - name: github.com/spf13/pflag 47 | version: 7aff26db30c1be810f9de5038ec5ef96ac41fd7c 48 | - name: github.com/spf13/viper 49 | version: 25b30aa063fc18e48662b86996252eabdcf2f0c7 50 | - name: golang.org/x/crypto 51 | version: 6a293f2d4b14b8e6d3f0539e383f6d0d30fce3fd 52 | subpackages: 53 | - ssh/terminal 54 | - name: golang.org/x/sys 55 | version: 1e2299c37cc91a509f1b12369872d27be0ce98a6 56 | subpackages: 57 | - unix 58 | - windows 59 | - name: golang.org/x/text 60 | version: 1cbadb444a806fd9430d14ad08967ed91da4fa0a 61 | subpackages: 62 | - transform 63 | - unicode/norm 64 | - name: gopkg.in/yaml.v2 65 | version: eb3733d160e74a9c7e442f435eb3bea458e1d19f 66 | testImports: [] 67 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/garethr/kubetest/kubetest" 12 | log "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/viper" 15 | ) 16 | 17 | // RootCmd represents the the command to run when kubetest is run 18 | var RootCmd = &cobra.Command{ 19 | Use: "kubetest [file...]", 20 | Short: "Run tests against a Kubernetes YAML file", 21 | Long: `Run tests against a Kubernetes YAML file`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | initLogging() 24 | success := true 25 | windowsStdinIssue := false 26 | testsDir := viper.GetString("testsDir") 27 | stat, err := os.Stdin.Stat() 28 | if err != nil { 29 | // Stat() will return an error on Windows in both Powershell and 30 | // console until go1.9 when nothing is passed on stdin. 31 | // See https://github.com/golang/go/issues/14853. 32 | if runtime.GOOS != "windows" { 33 | log.Error(err) 34 | os.Exit(1) 35 | } else { 36 | windowsStdinIssue = true 37 | } 38 | } 39 | // We detect whether we have anything on stdin to process 40 | if !windowsStdinIssue && ((stat.Mode() & os.ModeCharDevice) == 0) { 41 | var buffer bytes.Buffer 42 | scanner := bufio.NewScanner(os.Stdin) 43 | for scanner.Scan() { 44 | buffer.WriteString(scanner.Text() + "\n") 45 | } 46 | runSuccess := kubetest.Runs(buffer.Bytes(), testsDir, "stdin") 47 | if success { 48 | success = runSuccess 49 | } 50 | } else { 51 | if len(args) < 1 { 52 | log.Fatal("You must pass at least one file as an argument") 53 | } 54 | for _, fileName := range args { 55 | filePath, _ := filepath.Abs(fileName) 56 | fileContents, err := ioutil.ReadFile(filePath) 57 | if err != nil { 58 | log.Fatal("Could not open file ", fileName) 59 | } 60 | runSuccess := kubetest.Runs(fileContents, testsDir, fileName) 61 | if success { 62 | success = runSuccess 63 | } 64 | } 65 | } 66 | if !success { 67 | os.Exit(1) 68 | } 69 | }, 70 | } 71 | 72 | // Execute adds all child commands to the root command sets flags appropriately. 73 | // This is called by main.main(). It only needs to happen once to the rootCmd. 74 | func Execute() { 75 | if err := RootCmd.Execute(); err != nil { 76 | log.Error(err) 77 | os.Exit(-1) 78 | } 79 | } 80 | 81 | func initLogging() { 82 | log.SetOutput(os.Stdout) 83 | if !viper.GetBool("verbose") { 84 | log.SetLevel(log.WarnLevel) 85 | } 86 | if viper.GetBool("useJson") { 87 | log.SetFormatter(&log.JSONFormatter{}) 88 | } else { 89 | formatter := &log.TextFormatter{ 90 | DisableTimestamp: true, 91 | } 92 | log.SetFormatter(formatter) 93 | } 94 | } 95 | 96 | func init() { 97 | viper.SetEnvPrefix("KUBETEST") 98 | viper.AutomaticEnv() 99 | 100 | RootCmd.PersistentFlags().StringP("tests", "t", "tests", "Test directory") 101 | viper.BindPFlag("testsDir", RootCmd.PersistentFlags().Lookup("tests")) 102 | 103 | RootCmd.PersistentFlags().Bool("json", false, "Output results as JSON") 104 | viper.BindPFlag("useJson", RootCmd.PersistentFlags().Lookup("json")) 105 | 106 | RootCmd.PersistentFlags().Bool("verbose", false, "Output passes as well as failures") 107 | viper.BindPFlag("verbose", RootCmd.PersistentFlags().Lookup("verbose")) 108 | 109 | } 110 | -------------------------------------------------------------------------------- /kubetest/kubetest.go: -------------------------------------------------------------------------------- 1 | package kubetest 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "runtime" 10 | 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/garethr/skyhook" 14 | "gopkg.in/yaml.v2" 15 | 16 | "github.com/garethr/kubetest/assert" 17 | ) 18 | 19 | func listTests(testDir string) []string { 20 | fullTestDir, err := filepath.Abs(testDir) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | if _, err := os.Stat(fullTestDir); os.IsNotExist(err) { 25 | log.Fatal(fmt.Sprintf("Unable to find test directory: %s", fullTestDir)) 26 | } 27 | var files []string 28 | filepath.Walk(fullTestDir, func(path string, f os.FileInfo, _ error) error { 29 | if !f.IsDir() { 30 | r, err := regexp.MatchString(".sky", f.Name()) 31 | if err == nil && r { 32 | files = append(files, f.Name()) 33 | } 34 | } 35 | return nil 36 | }) 37 | return files 38 | } 39 | 40 | func Run(config []byte, filePath string, fileName string) bool { 41 | var spec interface{} 42 | yaml.Unmarshal(config, &spec) 43 | 44 | // A file with all commented out content will otherwise 45 | // panic, and we can't make assertions against a blank data 46 | // structure anyhow. But there are valid usecases for commented 47 | // out files so we warn without throwing an error 48 | if spec == nil { 49 | log.Warn("The document " + fileName + " does not contain any content") 50 | return true 51 | } 52 | 53 | sky := skyhook.New([]string{filePath}) 54 | globals := map[string]interface{}{ 55 | "file_name": fileName, 56 | "spec": spec, 57 | "assert_equal": assert.Equal, 58 | "assert_contains": assert.Contains, 59 | "assert_not_contains": assert.NotContains, 60 | "assert_not_equal": assert.NotEqual, 61 | "assert_nil": assert.Nil, 62 | "assert_not_nil": assert.NotNil, 63 | "fail": assert.Fail, 64 | "fail_now": assert.FailNow, 65 | "assert_empty": assert.Empty, 66 | "assert_not_empty": assert.NotEmpty, 67 | "assert_true": assert.True, 68 | "assert_false": assert.False, 69 | } 70 | 71 | tests := listTests(filePath) 72 | for _, test := range tests { 73 | _, err := sky.Run(test, globals) 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | } 78 | 79 | success := true 80 | 81 | for _, result := range assert.Results { 82 | message := fmt.Sprintf("%s %s", fileName, result.Message) 83 | if result.Kind == assert.AssertionError { 84 | log.Error(message) 85 | } else if result.Kind == assert.AssertionFailure { 86 | log.Warn(message) 87 | success = false 88 | } else if result.Kind == assert.AssertionSuccess { 89 | log.Info(message) 90 | } 91 | } 92 | 93 | assert.Results = nil 94 | 95 | return success 96 | } 97 | 98 | // detectLineBreak returns the relevant platform specific line ending 99 | func detectLineBreak(haystack []byte) string { 100 | windowsLineEnding := bytes.Contains(haystack, []byte("\r\n")) 101 | if windowsLineEnding && runtime.GOOS == "windows" { 102 | return "\r\n" 103 | } 104 | return "\n" 105 | } 106 | 107 | func Runs(config []byte, filePath string, fileName string) bool { 108 | 109 | if len(config) == 0 { 110 | log.Error("The document " + fileName + " appears to be empty") 111 | } 112 | 113 | bits := bytes.Split(config, []byte("---"+detectLineBreak(config))) 114 | 115 | results := make([]bool, 0) 116 | for _, element := range bits { 117 | if len(element) > 0 { 118 | result := Run(element, filePath, fileName) 119 | results = append(results, result) 120 | } 121 | } 122 | 123 | for _, value := range results { 124 | if value == false { 125 | return false 126 | } 127 | } 128 | return true 129 | } 130 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME=kubetest 2 | IMAGE_NAME=garethr/$(NAME) 3 | PACKAGE_NAME=github.com/garethr/$(NAME) 4 | GOFMT_FILES?=$$(find . -name '*.go' | grep -v vendor) 5 | TAG=$$(git describe --abbrev=0 --tags) 6 | TAG=0.1.0 7 | 8 | LDFLAGS += -X "$(PACKAGE_NAME)/version.BuildTime=$(shell date -u '+%Y-%m-%d %I:%M:%S %Z')" 9 | LDFLAGS += -X "$(PACKAGE_NAME)/version.BuildVersion=$(shell git describe --abbrev=0 --tags)" 10 | LDFLAGS += -X "$(PACKAGE_NAME)/version.BuildSHA=$(shell git rev-parse HEAD)" 11 | # Strip debug information 12 | LDFLAGS += -s 13 | 14 | ifeq ($(OS),Windows_NT) 15 | suffix := .exe 16 | endif 17 | 18 | all: build 19 | 20 | $(GOPATH)/bin/glide$(suffix): 21 | go get github.com/Masterminds/glide 22 | 23 | $(GOPATH)/bin/golint$(suffix): 24 | go get github.com/golang/lint/golint 25 | 26 | $(GOPATH)/bin/goveralls$(suffix): 27 | go get github.com/mattn/goveralls 28 | 29 | $(GOPATH)/bin/errcheck$(suffix): 30 | go get -u github.com/kisielk/errcheck 31 | 32 | .bats: 33 | git clone --depth 1 https://github.com/sstephenson/bats.git .bats 34 | 35 | glide.lock: glide.yaml $(GOPATH)/bin/glide$(suffix) 36 | glide update 37 | @touch $@ 38 | 39 | vendor: glide.lock 40 | glide install 41 | @touch $@ 42 | 43 | check: vendor $(GOPATH)/bin/errcheck$(suffix) 44 | errcheck 45 | 46 | releases: 47 | mkdir -p releases 48 | 49 | bin/linux/amd64: 50 | mkdir -p bin/linux/amd64 51 | 52 | bin/windows/amd64: 53 | mkdir -p bin/windows/amd64 54 | 55 | bin/windows/386: 56 | mkdir -p bin/windows/386 57 | 58 | bin/darwin/amd64: 59 | mkdir -p bin/darwin/amd64 60 | 61 | build: darwin linux windows 62 | 63 | darwin: vendor releases bin/darwin/amd64 64 | env GOOS=darwin GOAARCH=amd64 go build -ldflags '$(LDFLAGS)' -v -o $(CURDIR)/bin/darwin/amd64/$(NAME) 65 | tar -C bin/darwin/amd64 -cvzf releases/$(NAME)-darwin-amd64.tar.gz $(NAME) 66 | 67 | linux: vendor releases bin/linux/amd64 68 | env GOOS=linux GOAARCH=amd64 go build -ldflags '$(LDFLAGS)' -v -o $(CURDIR)/bin/linux/amd64/$(NAME) 69 | tar -C bin/linux/amd64 -cvzf releases/$(NAME)-linux-amd64.tar.gz $(NAME) 70 | 71 | windows: windows-64 windows-32 72 | 73 | windows-64: vendor releases bin/windows/amd64 74 | env GOOS=windows GOAARCH=amd64 go build -ldflags '$(LDFLAGS)' -v -o $(CURDIR)/bin/windows/amd64/$(NAME).exe 75 | tar -C bin/windows/amd64 -cvzf releases/$(NAME)-windows-amd64.tar.gz $(NAME).exe 76 | cd bin/windows/amd64 && zip ../../../releases/$(NAME)-windows-amd64.zip $(NAME).exe 77 | 78 | windows-32: vendor releases bin/windows/386 79 | env GOOS=windows GOAARCH=386 go build -ldflags '$(LDFLAGS)' -v -o $(CURDIR)/bin/windows/386/$(NAME).exe 80 | tar -C bin/windows/386 -cvzf releases/$(NAME)-windows-386.tar.gz $(NAME).exe 81 | cd bin/windows/386 && zip ../../../releases/$(NAME)-windows-386.zip $(NAME).exe 82 | 83 | lint: $(GOPATH)/bin/golint$(suffix) 84 | golint 85 | 86 | docker: 87 | docker build -t $(IMAGE_NAME):$(TAG) . 88 | docker tag $(IMAGE_NAME):$(TAG) $(IMAGE_NAME):latest 89 | 90 | publish: docker 91 | docker push $(IMAGE_NAME):$(TAG) 92 | docker push $(IMAGE_NAME):latest 93 | 94 | vet: 95 | go vet `glide novendor` 96 | 97 | test: vendor vet lint check 98 | go test -v -cover `glide novendor` 99 | 100 | coveralls: vendor $(GOPATH)/bin/goveralls$(suffix) 101 | goveralls -service=travis-ci 102 | 103 | watch: 104 | ls */*.go | entr make test 105 | 106 | acceptance: .bats 107 | env PATH=./.bats/bin:$$PATH:./bin/darwin/amd64 ./acceptance.bats 108 | 109 | cover: 110 | go test -v ./$(NAME) -coverprofile=coverage.out 111 | go tool cover -html=coverage.out 112 | rm coverage.out 113 | 114 | clean: 115 | rm -fr releases bin 116 | 117 | fmt: 118 | gofmt -w $(GOFMT_FILES) 119 | 120 | checksum-windows-386: 121 | cd releases && checksum -f $(NAME)-windows-386.zip -t=sha256 122 | 123 | checksum-windows-amd64: 124 | cd releases && checksum -f $(NAME)-windows-amd64.zip -t=sha256 125 | 126 | checksum-darwin: 127 | cd releases && checksum -f $(NAME)-darwin-amd64.tar.gz -t=sha256 128 | 129 | chocolatey/$(NAME)/$(NAME).$(TAG).nupkg: chocolatey/$(NAME)/$(NAME).nuspec 130 | cd chocolatey/$(NAME) && choco pack 131 | 132 | choco: 133 | cd chocolatey/$(NAME) && choco push $(NAME).$(TAG).nupkg -s https://chocolatey.org/ 134 | 135 | .PHONY: fmt clean cover acceptance lint docker test vet watch windows linux darwin build check checksum-windows-386 checksum-windows-amd64 checksum-darwin choco 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _Kubetest was an interesting experiment, but I've mainly moved my focus to [Conftest](https://github.com/instrumenta/conftest) which has a similar, but broader, goal. Conftest also uses the much more powerful, and supported, Rego language from Open Polciy Agent. Given this, I'm archiving this repository._ 2 | 3 | # Kubetest 4 | 5 | `kubetest` is a tool for running tests against a Kubernetes YAML or JSON configuration file. 6 | These tests can be used to enforce local or global best-practices, for example: 7 | 8 | * Ensuring certain labels are set 9 | * Prevent usage of images with the `latest` tag 10 | * Prohibit privileged containers 11 | * Enforce a naming convention for different resources 12 | 13 | [![Build 14 | Status](https://travis-ci.org/garethr/kubetest.svg)](https://travis-ci.org/garethr/kubetest) 15 | [![Go Report 16 | Card](https://goreportcard.com/badge/github.com/garethr/kubetest)](https://goreportcard.com/report/github.com/garethr/kubetest) 17 | [![GoDoc](https://godoc.org/github.com/garethr/kubetest?status.svg)](https://godoc.org/github.com/garethr/kubetest) 18 | [![Coverage 19 | Status](https://coveralls.io/repos/github/garethr/kubetest/badge.svg?branch=master)](https://coveralls.io/github/garethr/kubetest?branch=master) 20 | 21 | 22 | `kubetest` is currently alpha quality and undoutedly has a few issues. Things will change, hopefully for the better. Please open issues if you have feedback when trying it out. 23 | 24 | 25 | ## Writing tests 26 | 27 | Tests are written in [Skylark](https://github.com/google/skylark), which is a small dialect of Python suitable for embedding in other programmes. This means you do not need an additional interpreter installed to run tests with `kubetest`. `kubetest` prioritises interopability over flexibility in this regard. Tests for Kubetest just require the `kubetest` binary to run. Let's take a look at an example test: 28 | 29 | ```python 30 | #// vim: set ft=python: 31 | def test_for_team_label(): 32 | if spec["kind"] == "Deployment": 33 | labels = spec["spec"]["template"]["metadata"]["labels"] 34 | assert_contains(labels, "team", "should indicate which team owns the deployment") 35 | 36 | test_for_team_label() 37 | ``` 38 | 39 | Save the test file in a directory called `tests`, with an extension of `.sky`. You can change the default directory name using the `--tests` flag. You can now run `kubetest` against your configuration files. 40 | 41 | ```bash 42 | $ kubetest my-deployment.yaml 43 | WARN my-deployment.yaml Deployment should have at least 4 replicas 44 | $ echo $? 45 | 1 46 | ``` 47 | 48 | If any of the tests fail then `kubetest` will return a non-zero exit code. 49 | 50 | By default `kubetest` outputs information about failing tests only, but you can pass `--verbose` to get information about passing tests as well. 51 | 52 | ```bash 53 | $ kubetest rc.yaml --verbose 54 | INFO rc.yaml should not use latest images 55 | WARN rc.yaml ReplicationController should have at least 4 replicas 56 | ``` 57 | 58 | ## The `spec` variable 59 | 60 | `spec` is a global variable passed into the Skylark code which contains the structure of the Kubernetes configuration passed in to `kubetest`. You'll need to be reasonably familiar with the structure of the Kubernetes API objects to write tests, but it is possible to write helper methods for common assertions. 61 | 62 | 63 | ## Assertions 64 | 65 | `kubetest` automatically makes available a set of assertions to make writing tests in Skylark more pleasant. A failed assertion results in `kubetest` exiting with a non-zero exit code, and assertions output results as shown above. 66 | 67 | * assert_equal 68 | * assert_contains 69 | * assert_not_contains 70 | * assert_not_equal 71 | * assert_nil 72 | * assert_not_nil 73 | * fail 74 | * fail_now 75 | * assert_empty 76 | * assert_not_empty 77 | * assert_true 78 | * assert_false 79 | 80 | Assertions take zero, one or two arguments (noted above) depending on what they are comparing. They then take an additional message argument which is output when the assertion runs. For example the following assertion checks whether the variable `labels` contains the value `team`. 81 | 82 | ```python 83 | assert_contains(labels, "team", "should indicate which team owns the deployment") 84 | ``` 85 | 86 | 87 | ## Installation 88 | 89 | Tagged versions of `kubetest` are built by Travis and automatically 90 | uploaded to GitHub. This means you should find `tar.gz` files under the 91 | release tab. These should contain a single `kubetest` binary for platform 92 | in the filename (ie. windows, linux, darwin). Either execute that binary 93 | directly or place it on your path. 94 | 95 | ``` 96 | wget https://github.com/garethr/kubetest/releases/download/0.1.0/kubetest-darwin-amd64.tar.gz 97 | tar xf kubetest-darwin-amd64.tar.gz 98 | cp kubetest /usr/local/bin 99 | ``` 100 | 101 | Windows users can download tar or zip files from the releases tab. 102 | 103 | 104 | ## CLI 105 | 106 | ``` 107 | $ kubetest --help 108 | Run tests against a Kubernetes YAML file 109 | 110 | Usage: 111 | kubetest [file...] [flags] 112 | 113 | Flags: 114 | -h, --help help for kubetest 115 | --json Output results as JSON 116 | -t, --tests string Test directory (default "tests") 117 | --verbose Output passes as well as failures 118 | ``` 119 | 120 | 121 | ## Thanks 122 | 123 | A big thank you goes to the authors of [stretchr/testify](https://github.com/stretchr/testify/) from where much of the assertion code has been ported. 124 | 125 | -------------------------------------------------------------------------------- /assert/assert.go: -------------------------------------------------------------------------------- 1 | package assert 2 | 3 | // The following assertions are ported directly from https://github.com/stretchr/testify 4 | // with minor modifications to allow use outside the Go test framework 5 | 6 | // Copyright (c) 2012 - 2013 Mat Ryer and Tyler Bunnell 7 | // 8 | // Please consider promoting this project if you find it useful. 9 | // 10 | // Permission is hereby granted, free of charge, to any person 11 | // obtaining a copy of this software and associated documentation 12 | // files (the "Software"), to deal in the Software without restriction, 13 | // including without limitation the rights to use, copy, modify, merge, 14 | // publish, distribute, sublicense, and/or sell copies of the Software, 15 | // and to permit persons to whom the Software is furnished to do so, 16 | // subject to the following conditions: 17 | // 18 | // The above copyright notice and this permission notice shall be included 19 | // in all copies or substantial portions of the Software. 20 | // 21 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 22 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 23 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 24 | // IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 25 | // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 26 | // OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 27 | // OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | // 29 | 30 | import ( 31 | "bytes" 32 | "errors" 33 | "fmt" 34 | "reflect" 35 | "strings" 36 | "time" 37 | ) 38 | 39 | const ( 40 | AssertionError = iota 41 | AssertionFailure = iota 42 | AssertionSuccess = iota 43 | ) 44 | 45 | var Results []assertionResult 46 | 47 | type assertionResult struct { 48 | Message string 49 | Kind int 50 | } 51 | 52 | func NotEqual(actual, expected interface{}, msg string) bool { 53 | if err := validateEqualArgs(expected, actual); err != nil { 54 | result := assertionResult{Message: fmt.Sprintf("Invalid operation: %#v == %#v (%s)", 55 | expected, actual, err), Kind: AssertionError} 56 | Results = append(Results, result) 57 | return false 58 | } 59 | 60 | if objectsAreEqual(expected, actual) { 61 | result := assertionResult{Message: fmt.Sprintf("%s but does. actual: %s", msg, actual), 62 | Kind: AssertionFailure} 63 | Results = append(Results, result) 64 | return false 65 | } 66 | 67 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 68 | Results = append(Results, result) 69 | return true 70 | } 71 | 72 | func Equal(actual, expected interface{}, msg string) bool { 73 | if err := validateEqualArgs(expected, actual); err != nil { 74 | result := assertionResult{Message: fmt.Sprintf("Invalid operation: %#v == %#v (%s)", 75 | expected, actual, err), Kind: AssertionError} 76 | Results = append(Results, result) 77 | return false 78 | } 79 | 80 | if !objectsAreEqual(expected, actual) { 81 | expected, actual = formatUnequalValues(expected, actual) 82 | result := assertionResult{Message: fmt.Sprintf("%s but doesn't. expected: %s actual: %s", msg, expected, actual), 83 | Kind: AssertionFailure} 84 | Results = append(Results, result) 85 | return false 86 | } else { 87 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 88 | Results = append(Results, result) 89 | return true 90 | } 91 | } 92 | 93 | func validateEqualArgs(expected, actual interface{}) error { 94 | if isFunction(expected) || isFunction(actual) { 95 | return errors.New("cannot take func type as argument") 96 | } 97 | return nil 98 | } 99 | 100 | func isFunction(arg interface{}) bool { 101 | if arg == nil { 102 | return false 103 | } 104 | return reflect.TypeOf(arg).Kind() == reflect.Func 105 | } 106 | 107 | func objectsAreEqual(expected, actual interface{}) bool { 108 | if expected == nil || actual == nil { 109 | return expected == actual 110 | } 111 | if exp, ok := expected.([]byte); ok { 112 | act, ok := actual.([]byte) 113 | if !ok { 114 | return false 115 | } else if exp == nil || act == nil { 116 | return exp == nil && act == nil 117 | } 118 | return bytes.Equal(exp, act) 119 | } 120 | return reflect.DeepEqual(expected, actual) 121 | } 122 | 123 | func formatUnequalValues(expected, actual interface{}) (e string, a string) { 124 | if reflect.TypeOf(expected) != reflect.TypeOf(actual) { 125 | return fmt.Sprintf("%T(%#v)", expected, expected), 126 | fmt.Sprintf("%T(%#v)", actual, actual) 127 | } 128 | 129 | return fmt.Sprintf("%#v", expected), 130 | fmt.Sprintf("%#v", actual) 131 | } 132 | 133 | func typeAndKind(v interface{}) (reflect.Type, reflect.Kind) { 134 | t := reflect.TypeOf(v) 135 | k := t.Kind() 136 | 137 | if k == reflect.Ptr { 138 | t = t.Elem() 139 | k = t.Kind() 140 | } 141 | return t, k 142 | } 143 | 144 | func includeElement(list interface{}, element interface{}) (ok, found bool) { 145 | 146 | listValue := reflect.ValueOf(list) 147 | elementValue := reflect.ValueOf(element) 148 | defer func() { 149 | if e := recover(); e != nil { 150 | ok = false 151 | found = false 152 | } 153 | }() 154 | 155 | if reflect.TypeOf(list).Kind() == reflect.String { 156 | return true, strings.Contains(listValue.String(), elementValue.String()) 157 | } 158 | 159 | if reflect.TypeOf(list).Kind() == reflect.Map { 160 | mapKeys := listValue.MapKeys() 161 | for i := 0; i < len(mapKeys); i++ { 162 | if objectsAreEqual(mapKeys[i].Interface(), element) { 163 | return true, true 164 | } 165 | } 166 | return true, false 167 | } 168 | 169 | for i := 0; i < listValue.Len(); i++ { 170 | if objectsAreEqual(listValue.Index(i).Interface(), element) { 171 | return true, true 172 | } 173 | } 174 | return true, false 175 | 176 | } 177 | 178 | func Fail(msg string) bool { 179 | result := assertionResult{Message: msg, Kind: AssertionFailure} 180 | Results = append(Results, result) 181 | return false 182 | } 183 | 184 | func FailNow(msg string) bool { 185 | result := assertionResult{Message: msg, Kind: AssertionError} 186 | Results = append(Results, result) 187 | return false 188 | } 189 | 190 | func NotNil(object interface{}, msg string) bool { 191 | if !isNil(object) { 192 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 193 | Results = append(Results, result) 194 | return true 195 | } 196 | result := assertionResult{Message: fmt.Sprintf("%s Expected value not to be nil", msg), 197 | Kind: AssertionFailure} 198 | Results = append(Results, result) 199 | return false 200 | } 201 | 202 | // isNil checks if a specified object is nil or not, without Failing. 203 | func isNil(object interface{}) bool { 204 | if object == nil { 205 | return true 206 | } 207 | 208 | value := reflect.ValueOf(object) 209 | kind := value.Kind() 210 | if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { 211 | return true 212 | } 213 | 214 | return false 215 | } 216 | 217 | // Nil asserts that the specified object is nil. 218 | // 219 | // assert.Nil(t, err) 220 | // 221 | // Returns whether the assertion was successful (true) or not (false). 222 | func Nil(object interface{}, msg string) bool { 223 | if isNil(object) { 224 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 225 | Results = append(Results, result) 226 | return true 227 | } 228 | result := assertionResult{Message: fmt.Sprintf("%s Expected nil, but got: %#v", msg, object), 229 | Kind: AssertionFailure} 230 | Results = append(Results, result) 231 | return false 232 | } 233 | 234 | var numericZeros = []interface{}{ 235 | int(0), 236 | int8(0), 237 | int16(0), 238 | int32(0), 239 | int64(0), 240 | uint(0), 241 | uint8(0), 242 | uint16(0), 243 | uint32(0), 244 | uint64(0), 245 | float32(0), 246 | float64(0), 247 | } 248 | 249 | // isEmpty gets whether the specified object is considered empty or not. 250 | func isEmpty(object interface{}) bool { 251 | 252 | if object == nil { 253 | return true 254 | } else if object == "" { 255 | return true 256 | } else if object == false { 257 | return true 258 | } 259 | 260 | for _, v := range numericZeros { 261 | if object == v { 262 | return true 263 | } 264 | } 265 | 266 | objValue := reflect.ValueOf(object) 267 | 268 | switch objValue.Kind() { 269 | case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: 270 | { 271 | return (objValue.Len() == 0) 272 | } 273 | case reflect.Struct: 274 | switch object.(type) { 275 | case time.Time: 276 | return object.(time.Time).IsZero() 277 | } 278 | case reflect.Ptr: 279 | { 280 | if objValue.IsNil() { 281 | return true 282 | } 283 | switch object.(type) { 284 | case *time.Time: 285 | return object.(*time.Time).IsZero() 286 | default: 287 | return false 288 | } 289 | } 290 | } 291 | return false 292 | } 293 | 294 | // Empty asserts that the specified object is empty. I.e. nil, "", false, 0 or either 295 | // a slice or a channel with len == 0. 296 | // 297 | // assert.Empty(t, obj) 298 | // 299 | // Returns whether the assertion was successful (true) or not (false). 300 | func Empty(object interface{}, msg string) bool { 301 | 302 | pass := isEmpty(object) 303 | var result assertionResult 304 | if !pass { 305 | result = assertionResult{Message: fmt.Sprintf("%s Should be empty, but was %v", msg, object), 306 | Kind: AssertionError} 307 | } else { 308 | result = assertionResult{Message: msg, Kind: AssertionSuccess} 309 | } 310 | Results = append(Results, result) 311 | return pass 312 | 313 | } 314 | 315 | // NotEmpty asserts that the specified object is NOT empty. I.e. not nil, "", false, 0 or either 316 | // a slice or a channel with len == 0. 317 | // 318 | // if assert.NotEmpty(t, obj) { 319 | // assert.Equal(t, "two", obj[1]) 320 | // } 321 | // 322 | // Returns whether the assertion was successful (true) or not (false). 323 | func NotEmpty(object interface{}, msg string) bool { 324 | 325 | pass := !isEmpty(object) 326 | if !pass { 327 | result := assertionResult{Message: fmt.Sprintf("%s Should NOT be empty, but was %v", msg, object), 328 | Kind: AssertionError} 329 | Results = append(Results, result) 330 | return false 331 | } 332 | 333 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 334 | Results = append(Results, result) 335 | return pass 336 | } 337 | 338 | func True(value bool, msg string) bool { 339 | if value != true { 340 | result := assertionResult{Message: msg, Kind: AssertionFailure} 341 | Results = append(Results, result) 342 | return false 343 | } 344 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 345 | Results = append(Results, result) 346 | return true 347 | } 348 | 349 | func False(value bool, msg string) bool { 350 | if value != false { 351 | result := assertionResult{Message: msg, Kind: AssertionFailure} 352 | Results = append(Results, result) 353 | return false 354 | } 355 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 356 | Results = append(Results, result) 357 | return true 358 | } 359 | 360 | func Contains(s, contains interface{}, msg string) bool { 361 | ok, found := includeElement(s, contains) 362 | if !ok { 363 | result := assertionResult{Message: fmt.Sprintf("An error occured with %s", msg), 364 | Kind: AssertionError} 365 | Results = append(Results, result) 366 | return false 367 | } 368 | if !found { 369 | result := assertionResult{Message: fmt.Sprintf("\"%s\" does not contain \"%s\"", s, contains), 370 | Kind: AssertionFailure} 371 | Results = append(Results, result) 372 | return false 373 | } 374 | 375 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 376 | Results = append(Results, result) 377 | return true 378 | } 379 | 380 | // NotContains asserts that the specified string, list(array, slice...) or map does NOT contain the 381 | // specified substring or element. 382 | // 383 | // assert.NotContains(t, "Hello World", "Earth") 384 | // assert.NotContains(t, ["Hello", "World"], "Earth") 385 | // assert.NotContains(t, {"Hello": "World"}, "Earth") 386 | // 387 | // Returns whether the assertion was successful (true) or not (false). 388 | func NotContains(s, contains interface{}, msg string) bool { 389 | 390 | ok, found := includeElement(s, contains) 391 | if !ok { 392 | result := assertionResult{Message: fmt.Sprintf("An error occured with %s", msg), 393 | Kind: AssertionError} 394 | Results = append(Results, result) 395 | return false 396 | } 397 | if found { 398 | result := assertionResult{Message: fmt.Sprintf("\"%s\" should not contain \"%s\"", s, contains), 399 | Kind: AssertionFailure} 400 | Results = append(Results, result) 401 | return false 402 | } 403 | 404 | result := assertionResult{Message: msg, Kind: AssertionSuccess} 405 | Results = append(Results, result) 406 | return true 407 | } 408 | --------------------------------------------------------------------------------