├── .bumpversion.cfg ├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── annotator ├── handler.go ├── handler_test.go ├── helper.go ├── init.go ├── mutator.go └── mutator_test.go ├── config ├── config.go ├── log.go ├── rules.go ├── rules_test.go ├── testdata │ └── rules.yaml ├── tls.go └── version.go ├── example ├── configmap.yaml ├── deployment.yaml ├── service.yaml └── webhookconfiguration.yaml ├── main.go └── web ├── health.go └── health_test.go /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.4 3 | commit = True 4 | tag = True 5 | message = bump version: {current_version} → {new_version} 6 | 7 | [bumpversion:file:VERSION] 8 | 9 | [bumpversion:file:example/deployment.yaml] 10 | 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .dockerignore 3 | Dockerfile 4 | /vendor/ 5 | /example/ 6 | /kube/ 7 | /bin/ 8 | coverage.txt 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go 3 | # Edit at https://www.gitignore.io/?templates=go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, build with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | ### Go Patch ### 20 | /vendor/ 21 | /Godeps/ 22 | 23 | # End of https://www.gitignore.io/api/go 24 | 25 | /bin/ 26 | coverage.txt 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.11.x 4 | 5 | services: 6 | - docker 7 | 8 | env: 9 | - DEP_VERSION=0.5.0 10 | 11 | before_script: 12 | - curl -L -s https://github.com/golang/dep/releases/download/v${DEP_VERSION}/dep-linux-amd64 13 | -o $GOPATH/bin/dep 14 | - chmod +x $GOPATH/bin/dep 15 | - make deps 16 | 17 | script: 18 | - make test 19 | - make clean build-all-platforms 20 | 21 | after_success: 22 | - bash <(curl -s https://codecov.io/bash) 23 | 24 | deploy: 25 | - provider: releases 26 | skip_cleanup: true 27 | api_key: 28 | secure: o0CE3ZGtH1a/9YRLAF2SaObYlrOE4ZxM8yb4Hj5T45RfnA9myCyNeOPByrs3bhkwmlfOFPYvoCp/dtZuhV+YU5LGbZJEKmFhHEv3n57M6D7TLGZW2i7L1btn643c5/QsPLKceL8/7xUiFEeZcEBkR5g5TYxJnhZpujtEB4e6ol3W2g2qQnBiKW0BySJu101PVTKWBSew9FOLiIojM7wU62cS/1eiTA8HPI6/lD3f8AXVtNoZnUPSKW5tf85t0W8PkR9OcLPdx2gggrsSjcV/gqhZZHEO0TC6Ps/6DO/k6cMDQaMBjIyRjkTHoGNLBWYBV0E7Yu0kjtInw1D+GoHWRwhiQZC1ooWedgC7C7Y4VRds60vOs17ymdL1iYyU5p0m+1ZPSMAApeXA4/6MzpuStLKTmigz3y0TU/nLY3LO7dqhLu6HSJUVVlGZDio4eQxG6Yxoo/IKnwntYBRFpy50iIYVpTdFfbywIYm8/mdurUN8/NZ5M1HeYC5viQIE4+z5zvErgrWbNU7eozsdTMyHunvvhezrSxl1/KM8BwazjRMCi0AJrJMyPxq1UlgHOkj1k/14rKD4WuUjjqlAYfI68/0RZWA5PCbJJPyHjk3qM79MT/DomqxrFpJ/YxgATaCmxy0gqCi8fusUpGS+qCCBkMsXU5fb01ag00LHAmSz/40= 29 | file: bin/kube-annotate-* 30 | file_glob: true 31 | on: 32 | tags: true 33 | - provider: script 34 | skip_cleanup: true 35 | script: 36 | (echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin) && 37 | DOCKER_TAG=$TRAVIS_TAG make docker-build docker-push 38 | on: 39 | tags: true 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11.2-alpine AS builder 2 | 3 | WORKDIR /go/src/github.com/chickenzord/kube-annotate 4 | RUN apk add -U --no-cache git curl wget make && \ 5 | curl -L -s https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 -o $GOPATH/bin/dep && \ 6 | chmod +x $GOPATH/bin/dep 7 | COPY Gopkg.* Makefile ./ 8 | RUN make deps 9 | COPY . ./ 10 | RUN BUILD_OUTPUT=/bin/kube-annotate make build 11 | 12 | 13 | FROM alpine:3.8 AS runtime 14 | 15 | RUN apk add -U --no-cache curl wget bash 16 | COPY --from=builder /bin/kube-annotate /bin/kube-annotate 17 | CMD ["kube-annotate"] 18 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/beorn7/perks" 7 | packages = ["quantile"] 8 | revision = "3a771d992973f24aa725d07868b467d1ddfceafb" 9 | 10 | [[projects]] 11 | name = "github.com/gogo/protobuf" 12 | packages = [ 13 | "proto", 14 | "sortkeys" 15 | ] 16 | revision = "4cbf7e384e768b4e01799441fdf2a706a5635ae7" 17 | version = "v1.2.0" 18 | 19 | [[projects]] 20 | name = "github.com/golang/protobuf" 21 | packages = ["proto"] 22 | revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" 23 | version = "v1.2.0" 24 | 25 | [[projects]] 26 | branch = "master" 27 | name = "github.com/google/gofuzz" 28 | packages = ["."] 29 | revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" 30 | 31 | [[projects]] 32 | name = "github.com/gorilla/context" 33 | packages = ["."] 34 | revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" 35 | version = "v1.1.1" 36 | 37 | [[projects]] 38 | name = "github.com/gorilla/mux" 39 | packages = ["."] 40 | revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf" 41 | version = "v1.6.2" 42 | 43 | [[projects]] 44 | name = "github.com/json-iterator/go" 45 | packages = ["."] 46 | revision = "1624edc4454b8682399def8740d46db5e4362ba4" 47 | version = "v1.1.5" 48 | 49 | [[projects]] 50 | name = "github.com/konsorten/go-windows-terminal-sequences" 51 | packages = ["."] 52 | revision = "5c8c8bd35d3832f5d134ae1e1e375b69a4d25242" 53 | version = "v1.0.1" 54 | 55 | [[projects]] 56 | name = "github.com/matttproud/golang_protobuf_extensions" 57 | packages = ["pbutil"] 58 | revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" 59 | version = "v1.0.1" 60 | 61 | [[projects]] 62 | branch = "master" 63 | name = "github.com/meatballhat/negroni-logrus" 64 | packages = ["."] 65 | revision = "31067281800f66f57548a7a32d9c6c5f963fef83" 66 | 67 | [[projects]] 68 | name = "github.com/modern-go/concurrent" 69 | packages = ["."] 70 | revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" 71 | version = "1.0.3" 72 | 73 | [[projects]] 74 | name = "github.com/modern-go/reflect2" 75 | packages = ["."] 76 | revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" 77 | version = "1.0.1" 78 | 79 | [[projects]] 80 | name = "github.com/prometheus/client_golang" 81 | packages = [ 82 | "prometheus", 83 | "prometheus/internal", 84 | "prometheus/promhttp" 85 | ] 86 | revision = "505eaef017263e299324067d40ca2c48f6a2cf50" 87 | version = "v0.9.2" 88 | 89 | [[projects]] 90 | branch = "master" 91 | name = "github.com/prometheus/client_model" 92 | packages = ["go"] 93 | revision = "5c3871d89910bfb32f5fcab2aa4b9ec68e65a99f" 94 | 95 | [[projects]] 96 | branch = "master" 97 | name = "github.com/prometheus/common" 98 | packages = [ 99 | "expfmt", 100 | "internal/bitbucket.org/ww/goautoneg", 101 | "model" 102 | ] 103 | revision = "4724e9255275ce38f7179b2478abeae4e28c904f" 104 | 105 | [[projects]] 106 | branch = "master" 107 | name = "github.com/prometheus/procfs" 108 | packages = [ 109 | ".", 110 | "internal/util", 111 | "nfs", 112 | "xfs" 113 | ] 114 | revision = "1dc9a6cbc91aacc3e8b2d63db4d2e957a5394ac4" 115 | 116 | [[projects]] 117 | name = "github.com/sirupsen/logrus" 118 | packages = ["."] 119 | revision = "bcd833dfe83d3cebad139e4a29ed79cb2318bf95" 120 | version = "v1.2.0" 121 | 122 | [[projects]] 123 | name = "github.com/slok/go-prometheus-middleware" 124 | packages = [ 125 | ".", 126 | "negroni" 127 | ] 128 | revision = "42915359f61ab1d6e31e832fb89465f7b30e614b" 129 | version = "v0.4.0" 130 | 131 | [[projects]] 132 | name = "github.com/urfave/negroni" 133 | packages = ["."] 134 | revision = "c6a59be0ce122566695fbd5e48a77f8f10c8a63a" 135 | version = "v1.0.0" 136 | 137 | [[projects]] 138 | branch = "master" 139 | name = "golang.org/x/crypto" 140 | packages = ["ssh/terminal"] 141 | revision = "505ab145d0a99da450461ae2c1a9f6cd10d1f447" 142 | 143 | [[projects]] 144 | branch = "master" 145 | name = "golang.org/x/net" 146 | packages = [ 147 | "http/httpguts", 148 | "http2", 149 | "http2/hpack", 150 | "idna" 151 | ] 152 | revision = "610586996380ceef02dd726cc09df7e00a3f8e56" 153 | 154 | [[projects]] 155 | branch = "master" 156 | name = "golang.org/x/sys" 157 | packages = [ 158 | "unix", 159 | "windows" 160 | ] 161 | revision = "7da8ea5c81829e397bdf930c5d8ba4b703616f33" 162 | 163 | [[projects]] 164 | name = "golang.org/x/text" 165 | packages = [ 166 | "collate", 167 | "collate/build", 168 | "internal/colltab", 169 | "internal/gen", 170 | "internal/tag", 171 | "internal/triegen", 172 | "internal/ucd", 173 | "language", 174 | "secure/bidirule", 175 | "transform", 176 | "unicode/bidi", 177 | "unicode/cldr", 178 | "unicode/norm", 179 | "unicode/rangetable" 180 | ] 181 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 182 | version = "v0.3.0" 183 | 184 | [[projects]] 185 | name = "gopkg.in/inf.v0" 186 | packages = ["."] 187 | revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" 188 | version = "v0.9.1" 189 | 190 | [[projects]] 191 | name = "gopkg.in/yaml.v2" 192 | packages = ["."] 193 | revision = "51d6538a90f86fe93ac480b35f37b2be17fef232" 194 | version = "v2.2.2" 195 | 196 | [[projects]] 197 | branch = "release-1.12" 198 | name = "k8s.io/api" 199 | packages = [ 200 | "admission/v1beta1", 201 | "admissionregistration/v1beta1", 202 | "apps/v1", 203 | "authentication/v1", 204 | "core/v1" 205 | ] 206 | revision = "6db15a15d2d3874a6c3ddb2140ac9f3bc7058428" 207 | 208 | [[projects]] 209 | branch = "master" 210 | name = "k8s.io/apimachinery" 211 | packages = [ 212 | "pkg/api/resource", 213 | "pkg/apis/meta/v1", 214 | "pkg/apis/meta/v1/unstructured", 215 | "pkg/conversion", 216 | "pkg/conversion/queryparams", 217 | "pkg/fields", 218 | "pkg/labels", 219 | "pkg/runtime", 220 | "pkg/runtime/schema", 221 | "pkg/runtime/serializer", 222 | "pkg/runtime/serializer/json", 223 | "pkg/runtime/serializer/protobuf", 224 | "pkg/runtime/serializer/recognizer", 225 | "pkg/runtime/serializer/versioning", 226 | "pkg/selection", 227 | "pkg/types", 228 | "pkg/util/errors", 229 | "pkg/util/framer", 230 | "pkg/util/intstr", 231 | "pkg/util/json", 232 | "pkg/util/naming", 233 | "pkg/util/net", 234 | "pkg/util/runtime", 235 | "pkg/util/sets", 236 | "pkg/util/validation", 237 | "pkg/util/validation/field", 238 | "pkg/util/yaml", 239 | "pkg/watch", 240 | "third_party/forked/golang/reflect" 241 | ] 242 | revision = "57dc7e687b5426ecb5df4cabdcdce30fddb74f22" 243 | 244 | [[projects]] 245 | name = "k8s.io/klog" 246 | packages = ["."] 247 | revision = "a5bc97fbc634d635061f3146511332c7e313a55a" 248 | version = "v0.1.0" 249 | 250 | [[projects]] 251 | name = "sigs.k8s.io/yaml" 252 | packages = ["."] 253 | revision = "fd68e9863619f6ec2fdd8625fe1f02e7c877e480" 254 | version = "v1.1.0" 255 | 256 | [solve-meta] 257 | analyzer-name = "dep" 258 | analyzer-version = 1 259 | inputs-digest = "9d4640410d7fa84f4c749f59351c3c1426cab3beee41cf810f80b4ae5483d7fe" 260 | solver-name = "gps-cdcl" 261 | solver-version = 1 262 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [prune] 29 | go-tests = true 30 | unused-packages = true 31 | 32 | [[constraint]] 33 | name = "github.com/gorilla/mux" 34 | version = "1.6.2" 35 | 36 | [[constraint]] 37 | name = "github.com/sirupsen/logrus" 38 | version = "1.2.0" 39 | 40 | [[constraint]] 41 | branch = "release-1.12" 42 | name = "k8s.io/api" 43 | 44 | [[constraint]] 45 | name = "github.com/gorilla/handlers" 46 | version = "1.4.0" 47 | 48 | [[constraint]] 49 | name = "github.com/urfave/negroni" 50 | version = "1.0.0" 51 | 52 | [[constraint]] 53 | branch = "master" 54 | name = "github.com/meatballhat/negroni-logrus" 55 | 56 | [[constraint]] 57 | name = "gopkg.in/tylerb/graceful.v1" 58 | version = "1.2.15" 59 | 60 | [[constraint]] 61 | name = "github.com/slok/go-prometheus-middleware" 62 | version = "0.4.0" 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 chickenzord 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_TAG ?= local 2 | DOCKER_IMAGE ?= chickenzord/kube-annotate:$(DOCKER_TAG) 3 | PACKAGE ?= github.com/chickenzord/kube-annotate 4 | BUILD_OUTPUT ?= bin/kube-annotate 5 | BUILD_GOOS ?= darwin linux 6 | BUILD_GOARCH ?= 386 amd64 7 | 8 | clean: 9 | mkdir -p bin 10 | rm -f bin/* 11 | 12 | deps: 13 | go get -v github.com/stretchr/testify 14 | go get -v github.com/ahmetb/govvv 15 | dep ensure -v -vendor-only 16 | 17 | test: 18 | go test -v -cover -coverprofile=coverage.txt -covermode=atomic ./... 19 | 20 | build: 21 | GOOS=$(GOOS) GOARCH=$(GOARCH) \ 22 | go build -o $(BUILD_OUTPUT) -ldflags="$$(govvv -flags -pkg $(PACKAGE)/config)" . 23 | 24 | build-all-platforms: 25 | for GOOS in $(BUILD_GOOS); do \ 26 | for GOARCH in $(BUILD_GOARCH); do \ 27 | GOOS=$$GOOS \ 28 | GOARCH=$$GOARCH \ 29 | BUILD_OUTPUT="bin/kube-annotate-$$GOOS-$$GOARCH" \ 30 | CGO_ENABLED=0 \ 31 | $(MAKE) build; \ 32 | done; \ 33 | done; 34 | 35 | run: 36 | go run . 37 | 38 | docker-build: 39 | docker build -t $(DOCKER_IMAGE) . 40 | 41 | docker-push: 42 | docker push $(DOCKER_IMAGE) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kube-annotate 2 | 3 | [![Build Status](https://travis-ci.org/chickenzord/kube-annotate.svg?branch=master)](https://travis-ci.org/chickenzord/kube-annotate) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/chickenzord/kube-annotate)](https://goreportcard.com/report/github.com/chickenzord/kube-annotate) 5 | [![codecov](https://codecov.io/gh/chickenzord/kube-annotate/branch/master/graph/badge.svg)](https://codecov.io/gh/chickenzord/kube-annotate) 6 | [![Automated Docker Build](https://img.shields.io/docker/automated/chickenzord/kube-annotate.svg)](https://hub.docker.com/r/chickenzord/kube-annotate/) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/chickenzord/kube-annotate.svg)](https://hub.docker.com/r/chickenzord/kube-annotate/) 8 | 9 | Kubernetes mutating admission webhook to automatically annotate pods. 10 | 11 | Features: 12 | - Automatically annotate new pods with certain labels 13 | - YAML-based configuration for multiple rules 14 | - Built-in Prometheus metrics exporter 15 | 16 | Configurations: 17 | 18 | - LOG_FORMAT: json/text 19 | - LOG_LEVEL: trace/debug/info/warning/error/fatal/panic 20 | - RULES_FILE: path to `config.yaml` 21 | - TLS_ENABLED: must be `true` when running inside Kubernetes cluster as admission controller 22 | - TLS_CRT: path to certfile for TLS config 23 | - TLS_KEY: path to keyfile for TLS config 24 | 25 | Rules config sample: 26 | 27 | ```yaml 28 | # config.yaml 29 | - selector: 30 | app: http-service 31 | annotations: 32 | log.config.scalyr.com/include: true 33 | - selector: 34 | app: postgresql 35 | annotations: 36 | log.config.scalyr.com/include: false 37 | ``` 38 | 39 | Setup: 40 | 41 | 1. Make sure the cluster support admission controller (at least Kubernetes 1.9) 42 | 2. Prepare TLS certificate (see Medium post below, you need cluster-admin permission) 43 | 3. Create kubernetes resources (see `examples` directory, please read the comments especially about CA bundle and certificates) 44 | 4. Label the namespace you want to enable (`kubectl label namespace ${namespace} kube-annotate=enabled`) 45 | 46 | --- 47 | 48 | TODO: 49 | - ~~bind internal endpoints (health, metrics) to separate port~~ 50 | - proper request/response logging 51 | - ~~prometheus exporter~~ 52 | - helm chart for easier setup 53 | 54 | --- 55 | References: 56 | - https://medium.com/ibm-cloud/diving-into-kubernetes-mutatingadmissionwebhook-6ef3c5695f74 57 | - https://github.com/morvencao/kube-mutating-webhook-tutorial -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.4 2 | -------------------------------------------------------------------------------- /annotator/handler.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/chickenzord/kube-annotate/config" 9 | ) 10 | 11 | //MutateHandler handles admission mutation 12 | func MutateHandler(w http.ResponseWriter, r *http.Request) { 13 | logRequest(r) 14 | 15 | admissionReview, err := parseBody(r) 16 | if err != nil { 17 | log.WithError(err).Error("cannot parse body") 18 | http.Error(w, "cannot parse body", http.StatusBadRequest) 19 | return 20 | } 21 | 22 | var result = mutate(admissionReview) 23 | resp, err := json.Marshal(result) 24 | if err != nil { 25 | log.WithError(err).Error("cannot encode response") 26 | http.Error(w, fmt.Sprintf("cannot encode response: %v", err), http.StatusInternalServerError) 27 | return 28 | } 29 | if _, err := w.Write(resp); err != nil { 30 | log.WithError(err).Error("cannot write response") 31 | http.Error(w, fmt.Sprintf("cannot write response: %v", err), http.StatusInternalServerError) 32 | return 33 | } 34 | } 35 | 36 | //RulesHandler handles rules 37 | func RulesHandler(w http.ResponseWriter, r *http.Request) { 38 | logRequest(r) 39 | 40 | payload, err := json.Marshal(config.Rules) 41 | if err != nil { 42 | log.WithError(err).Error("cannot encode rules") 43 | http.Error(w, fmt.Sprintf("cannot encode rules: %v", err), http.StatusInternalServerError) 44 | return 45 | } 46 | w.Header().Set("Content-Type", "application/json") 47 | fmt.Fprint(w, string(payload)) 48 | } 49 | -------------------------------------------------------------------------------- /annotator/handler_test.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRulesHandler(t *testing.T) { 13 | req, err := http.NewRequest("GET", "/rules", nil) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | rr := httptest.NewRecorder() 19 | handler := http.HandlerFunc(RulesHandler) 20 | handler.ServeHTTP(rr, req) 21 | 22 | assert.Equal(t, http.StatusOK, rr.Code) 23 | assert.Equal(t, "application/json", rr.HeaderMap.Get("Content-Type")) 24 | } 25 | 26 | func TestMutateHandlerEmptyBody(t *testing.T) { 27 | req, err := http.NewRequest("POST", "/mutate", nil) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | rr := httptest.NewRecorder() 33 | handler := http.HandlerFunc(MutateHandler) 34 | handler.ServeHTTP(rr, req) 35 | 36 | assert.Equal(t, http.StatusBadRequest, rr.Code) 37 | } 38 | 39 | func TestMutateHandlerWrongContentType(t *testing.T) { 40 | body := bytes.NewBufferString("{}") 41 | req, err := http.NewRequest("POST", "/mutate", body) 42 | req.Header.Set("Content-Type", "text/plain") 43 | if err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | rr := httptest.NewRecorder() 48 | handler := http.HandlerFunc(MutateHandler) 49 | handler.ServeHTTP(rr, req) 50 | 51 | assert.Equal(t, http.StatusBadRequest, rr.Code) 52 | } 53 | -------------------------------------------------------------------------------- /annotator/helper.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | func logRequest(r *http.Request) { 10 | if r.Body == nil { 11 | return 12 | } 13 | 14 | body, _ := ioutil.ReadAll(r.Body) 15 | 16 | obj := make(map[string]interface{}) 17 | if err := json.Unmarshal(body, &obj); err == nil { 18 | log.WithData(obj).Debugf("%s: %s", r.Method, r.RequestURI) 19 | return 20 | } 21 | 22 | arr := make([]interface{}, 0) 23 | if err := json.Unmarshal(body, &arr); err == nil { 24 | log.WithData(arr).Debugf("%s: %s", r.Method, r.RequestURI) 25 | return 26 | } 27 | 28 | log.WithData(string(body)).Debugf("%s: %s", r.Method, r.RequestURI) 29 | } 30 | -------------------------------------------------------------------------------- /annotator/init.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "github.com/chickenzord/kube-annotate/config" 5 | ) 6 | 7 | var log = config.AppLogger 8 | -------------------------------------------------------------------------------- /annotator/mutator.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/chickenzord/kube-annotate/config" 11 | "k8s.io/api/admission/v1beta1" 12 | admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" 13 | v1 "k8s.io/api/apps/v1" 14 | corev1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/labels" 17 | "k8s.io/apimachinery/pkg/runtime" 18 | "k8s.io/apimachinery/pkg/runtime/serializer" 19 | ) 20 | 21 | //Patch patching operation 22 | type Patch struct { 23 | Op string `json:"op"` 24 | Path string `json:"path"` 25 | Value interface{} `json:"value,omitempty"` 26 | } 27 | 28 | var ( 29 | runtimeScheme = runtime.NewScheme() 30 | codecs = serializer.NewCodecFactory(runtimeScheme) 31 | deserializer = codecs.UniversalDeserializer() 32 | 33 | // (https://github.com/kubernetes/kubernetes/issues/57982) 34 | defaulter = runtime.ObjectDefaulter(runtimeScheme) 35 | ) 36 | 37 | func init() { 38 | _ = corev1.AddToScheme(runtimeScheme) 39 | _ = admissionregistrationv1beta1.AddToScheme(runtimeScheme) 40 | // defaulting with webhooks: 41 | // https://github.com/kubernetes/kubernetes/issues/57982 42 | _ = v1.AddToScheme(runtimeScheme) 43 | } 44 | 45 | func parseBody(r *http.Request) (*v1beta1.AdmissionReview, error) { 46 | if r.ContentLength == 0 { 47 | return nil, errors.New("empty body") 48 | } 49 | 50 | if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { 51 | return nil, fmt.Errorf("invalid content type: %s", contentType) 52 | } 53 | 54 | data, err := ioutil.ReadAll(r.Body) 55 | if err != nil { 56 | return nil, fmt.Errorf("cannot read body: %v", err) 57 | } 58 | 59 | result := v1beta1.AdmissionReview{} 60 | if _, _, err := deserializer.Decode(data, nil, &result); err != nil { 61 | return nil, fmt.Errorf("cannot deserialize data to AdmissionReview: %v", err) 62 | } 63 | 64 | return &result, nil 65 | } 66 | 67 | func respond(review *v1beta1.AdmissionReview, response *v1beta1.AdmissionResponse) *v1beta1.AdmissionReview { 68 | result := &v1beta1.AdmissionReview{} 69 | if response != nil { 70 | result.Response = response 71 | if review.Request != nil { 72 | result.Response.UID = review.Request.UID 73 | } 74 | } 75 | return result 76 | } 77 | 78 | func respondWithError(review *v1beta1.AdmissionReview, err error) *v1beta1.AdmissionReview { 79 | return respond(review, &v1beta1.AdmissionResponse{ 80 | Result: &metav1.Status{ 81 | Message: err.Error(), 82 | }, 83 | }) 84 | } 85 | 86 | func respondWithSkip(review *v1beta1.AdmissionReview) *v1beta1.AdmissionReview { 87 | return respond(review, &v1beta1.AdmissionResponse{ 88 | Allowed: true, 89 | }) 90 | } 91 | 92 | func respondWithPatches(review *v1beta1.AdmissionReview, patches []Patch) *v1beta1.AdmissionReview { 93 | patchesBytes, err := json.Marshal(patches) 94 | if err != nil { 95 | return respondWithError(review, fmt.Errorf("cannot serialize patches: %v", err)) 96 | } 97 | 98 | return respond(review, &v1beta1.AdmissionResponse{ 99 | Allowed: true, 100 | Patch: patchesBytes, 101 | PatchType: func() *v1beta1.PatchType { 102 | pt := v1beta1.PatchTypeJSONPatch 103 | return &pt 104 | }(), 105 | }) 106 | } 107 | 108 | func createPatchFromAnnotations(base, extra map[string]string) Patch { 109 | if base == nil { 110 | return Patch{ 111 | Op: "add", 112 | Path: "/metadata/annotations", 113 | Value: extra, 114 | } 115 | } 116 | 117 | annotations := make(map[string]string) 118 | for k, v := range base { 119 | annotations[k] = v 120 | } 121 | if extra != nil { 122 | for k, v := range extra { 123 | annotations[k] = v 124 | } 125 | } 126 | 127 | return Patch{ 128 | Op: "replace", 129 | Path: "/metadata/annotations", 130 | Value: annotations, 131 | } 132 | } 133 | 134 | func mutate(review *v1beta1.AdmissionReview) *v1beta1.AdmissionReview { 135 | //deserialize pod 136 | var pod corev1.Pod 137 | if err := json.Unmarshal(review.Request.Object.Raw, &pod); err != nil { 138 | log.WithData(review).WithError(err).Errorf("error mutating pod") 139 | return respondWithError(review, errors.New("cannot deserialize pod from AdmissionRequest")) 140 | } 141 | 142 | //create patches based on rules 143 | log.WithData(review).Debug("processing AdmissionReview") 144 | patches := make([]Patch, 0) 145 | for _, rule := range config.Rules { 146 | if rule.Selector.AsSelector().Matches(labels.Set(pod.Labels)) { 147 | patch := createPatchFromAnnotations(pod.Annotations, rule.Annotations) 148 | patches = append(patches, patch) 149 | } 150 | } 151 | 152 | if len(patches) > 0 { 153 | log.WithData(review).Infof("mutating Pod with %d patch(es)", len(patches)) 154 | return respondWithPatches(review, patches) 155 | } 156 | 157 | log.Infof("skipping Pod") 158 | return respondWithSkip(review) 159 | } 160 | -------------------------------------------------------------------------------- /annotator/mutator_test.go: -------------------------------------------------------------------------------- 1 | package annotator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreatePatchFromAnnotations(t *testing.T) { 10 | podAnnotations := map[string]string{ 11 | "hello": "world", 12 | } 13 | rulesAnnotations := map[string]string{ 14 | "log": "enabled", 15 | } 16 | patch := createPatchFromAnnotations(podAnnotations, rulesAnnotations) 17 | assert.Equal(t, "replace", patch.Op) 18 | assert.Equal(t, "/metadata/annotations", patch.Path) 19 | assert.IsType(t, podAnnotations, patch.Value) 20 | 21 | valuesAsMap := patch.Value.(map[string]string) 22 | assert.Len(t, valuesAsMap, 2) 23 | assert.Equal(t, "world", valuesAsMap["hello"]) 24 | assert.Equal(t, "enabled", valuesAsMap["log"]) 25 | } 26 | 27 | func TestCreatePatchFromNilAnnotations(t *testing.T) { 28 | rulesAnnotations := map[string]string{ 29 | "log": "enabled", 30 | } 31 | patch := createPatchFromAnnotations(nil, rulesAnnotations) 32 | assert.Equal(t, "add", patch.Op) 33 | assert.Equal(t, "/metadata/annotations", patch.Path) 34 | assert.IsType(t, make(map[string]string), patch.Value) 35 | 36 | valuesAsMap := patch.Value.(map[string]string) 37 | assert.Len(t, valuesAsMap, 1) 38 | assert.Equal(t, "enabled", valuesAsMap["log"]) 39 | } 40 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var ( 8 | //AppName name of the app 9 | AppName string 10 | 11 | //BindAddress where app server should listen 12 | BindAddress string 13 | 14 | //BindAddressInternal where internal server should listen 15 | BindAddressInternal string 16 | 17 | //TLSEnabled is TLS enabled 18 | TLSEnabled bool 19 | 20 | //TLSCert TLS cert file to use 21 | TLSCert string 22 | 23 | //TLSKey TLS key file to use 24 | TLSKey string 25 | 26 | //Rules rules 27 | Rules []Rule 28 | ) 29 | 30 | func init() { 31 | if val, ok := os.LookupEnv("APP_NAME"); ok { 32 | AppName = val 33 | } else { 34 | AppName = "kube-annotate" 35 | } 36 | 37 | TLSEnabled = os.Getenv("TLS_ENABLED") == "true" 38 | TLSCert = os.Getenv("TLS_CRT") 39 | TLSKey = os.Getenv("TLS_KEY") 40 | Rules = []Rule{} 41 | 42 | if TLSEnabled { 43 | BindAddress = ":8443" 44 | } else { 45 | BindAddress = ":8080" 46 | } 47 | BindAddressInternal = ":8081" 48 | } 49 | -------------------------------------------------------------------------------- /config/log.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | //CustomLogger logrus logger with helper methods 10 | type CustomLogger struct{ *logrus.Logger } 11 | 12 | var ( 13 | //LogLevel app-wide logger level 14 | LogLevel logrus.Level 15 | 16 | //LogFormat app-wide log formatter 17 | LogFormat logrus.Formatter 18 | 19 | //AppLogger app-wide logger 20 | AppLogger *CustomLogger 21 | ) 22 | 23 | func init() { 24 | LogLevel, err := logrus.ParseLevel(os.Getenv("LOG_LEVEL")) 25 | if err != nil { 26 | LogLevel = logrus.InfoLevel 27 | } 28 | 29 | if format := os.Getenv("LOG_FORMAT"); format == "json" { 30 | LogFormat = &logrus.JSONFormatter{} 31 | } else { 32 | LogFormat = &logrus.TextFormatter{} 33 | } 34 | 35 | AppLogger = &CustomLogger{logrus.New()} 36 | AppLogger.SetLevel(LogLevel) 37 | AppLogger.SetFormatter(LogFormat) 38 | } 39 | 40 | //WithData embed data field 41 | func (l *CustomLogger) WithData(data interface{}) *logrus.Entry { 42 | return l.WithField("data", data) 43 | } 44 | -------------------------------------------------------------------------------- /config/rules.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | yaml "gopkg.in/yaml.v2" 8 | "k8s.io/apimachinery/pkg/labels" 9 | ) 10 | 11 | //Rule defines annotation rule 12 | type Rule struct { 13 | Selector labels.Set `yaml:"selector" json:"selector"` 14 | Annotations map[string]string `yaml:"annotations" json:"annotations"` 15 | } 16 | 17 | //LoadRules load rules from config source 18 | func LoadRules(path string) ([]Rule, error) { 19 | rules := make([]Rule, 0) 20 | if path == "" { 21 | return rules, nil 22 | } 23 | 24 | rulesBytes, err := ioutil.ReadFile(path) 25 | if err != nil { 26 | return nil, err 27 | } 28 | yaml.Unmarshal(rulesBytes, &rules) 29 | 30 | return rules, nil 31 | } 32 | 33 | //InitRules initialize rules from config source 34 | func InitRules() error { 35 | Rules = make([]Rule, 0) 36 | rulesFile := os.Getenv("RULES_FILE") 37 | 38 | if len(rulesFile) == 0 { 39 | AppLogger.Warn("no rules file set") 40 | return nil 41 | } 42 | 43 | rules, err := LoadRules(rulesFile) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | AppLogger.Infof("loaded %d rule(s) from %s", len(rules), rulesFile) 49 | Rules = rules 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /config/rules_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestInitRules(t *testing.T) { 11 | rulesFile := os.Getenv("RULES_FILE") 12 | 13 | os.Setenv("RULES_FILE", "testdata/rules.yaml") 14 | err := InitRules() 15 | assert.Nil(t, err) 16 | assert.Len(t, Rules, 2) 17 | os.Setenv("RULES_FILE", rulesFile) 18 | } 19 | 20 | func TestInitRulesEmpty(t *testing.T) { 21 | rulesFile := os.Getenv("RULES_FILE") 22 | 23 | os.Setenv("RULES_FILE", "") 24 | err := InitRules() 25 | assert.Nil(t, err) 26 | assert.Len(t, Rules, 0) 27 | os.Setenv("RULES_FILE", rulesFile) 28 | } 29 | 30 | func TestInitRulesError(t *testing.T) { 31 | rulesFile := os.Getenv("RULES_FILE") 32 | 33 | os.Setenv("RULES_FILE", "testdata/non-existing-file") 34 | err := InitRules() 35 | assert.NotNil(t, err) 36 | assert.Len(t, Rules, 0) 37 | os.Setenv("RULES_FILE", rulesFile) 38 | } 39 | 40 | func TestLoadRules(t *testing.T) { 41 | rules, err := LoadRules("testdata/rules.yaml") 42 | 43 | assert.Nil(t, err) 44 | assert.Len(t, rules, 2) 45 | } 46 | 47 | func TestLoadRulesEmpty(t *testing.T) { 48 | rules, err := LoadRules("") 49 | 50 | assert.Nil(t, err) 51 | assert.Len(t, rules, 0) 52 | } 53 | func TestLoadRulesError(t *testing.T) { 54 | rules, err := LoadRules("testdata/non-existing-file") 55 | 56 | assert.NotNil(t, err) 57 | assert.Len(t, rules, 0) 58 | } 59 | -------------------------------------------------------------------------------- /config/testdata/rules.yaml: -------------------------------------------------------------------------------- 1 | - selector: 2 | app: postgres 3 | annotations: 4 | log.config.scalyr.com/include: 'false' 5 | - selector: 6 | app: internal-app 7 | annotations: 8 | prometheus.io/port: '8081' 9 | prometheus.io/scrape: 'true' 10 | -------------------------------------------------------------------------------- /config/tls.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | ) 7 | 8 | //TLSConfig returns HTTP TLS config 9 | func TLSConfig() (*tls.Config, error) { 10 | if !TLSEnabled { 11 | return nil, nil 12 | } 13 | 14 | pair, err := tls.LoadX509KeyPair(TLSCert, TLSKey) 15 | if err != nil { 16 | return nil, fmt.Errorf("failed to load key pair: %v", err) 17 | } 18 | 19 | return &tls.Config{Certificates: []tls.Certificate{pair}}, nil 20 | } 21 | -------------------------------------------------------------------------------- /config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | //Version app version 5 | Version = "undefined" 6 | 7 | //GitCommit git commit 8 | GitCommit = "undefined" 9 | 10 | //GitState git state 11 | GitState = "undefined" 12 | 13 | //BuildDate build date 14 | BuildDate = "undefined" 15 | ) 16 | -------------------------------------------------------------------------------- /example/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | rules.yaml: | 4 | - selector: 5 | app: http-app 6 | annotations: 7 | log.config.scalyr.com/include: true 8 | kind: ConfigMap 9 | metadata: 10 | labels: 11 | app: kube-annotate 12 | name: kube-annotate-config 13 | -------------------------------------------------------------------------------- /example/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: kube-annotate 6 | name: kube-annotate 7 | spec: 8 | replicas: 3 9 | selector: 10 | matchLabels: 11 | app: kube-annotate 12 | template: 13 | metadata: 14 | labels: 15 | app: kube-annotate 16 | annotations: 17 | prometheus.io/scrape: 'true' 18 | prometheus.io/port: '8081' 19 | spec: 20 | containers: 21 | - name: kube-annotate 22 | image: docker.io/chickenzord/kube-annotate:v0.3.4 23 | imagePullPolicy: Always 24 | env: 25 | - name: TLS_ENABLED 26 | value: 'true' 27 | - name: TLS_CRT 28 | value: /var/run/secrets/tls/tls.crt 29 | - name: TLS_KEY 30 | value: /var/run/secrets/tls/tls.key 31 | - name: RULES_FILE 32 | value: /etc/kube-annotate/rules.yaml 33 | - name: LOG_FORMAT 34 | value: json 35 | - name: LOG_LEVEL 36 | value: info 37 | ports: 38 | - name: https 39 | containerPort: 8443 40 | - name: http-internal 41 | containerPort: 8081 42 | readinessProbe: 43 | httpGet: 44 | port: http-internal 45 | path: /health 46 | scheme: HTTP 47 | livenessProbe: 48 | httpGet: 49 | port: http-internal 50 | path: /health 51 | scheme: HTTP 52 | volumeMounts: 53 | - name: tls 54 | mountPath: /var/run/secrets/tls 55 | - name: config 56 | mountPath: /etc/kube-annotate 57 | volumes: 58 | - name: tls 59 | secret: 60 | # NOTE: this certificate must be created beforehand 61 | secretName: kube-annotate-tls 62 | - name: config 63 | configMap: 64 | name: kube-annotate-config 65 | -------------------------------------------------------------------------------- /example/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: kube-annotate 6 | name: kube-annotate 7 | spec: 8 | type: ClusterIP 9 | ports: 10 | - name: https 11 | port: 443 12 | targetPort: https 13 | selector: 14 | app: kube-annotate 15 | 16 | -------------------------------------------------------------------------------- /example/webhookconfiguration.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: admissionregistration.k8s.io/v1beta1 2 | kind: MutatingWebhookConfiguration 3 | metadata: 4 | name: kube-annotate 5 | labels: 6 | app: kube-annotate 7 | webhooks: 8 | - name: kube-annotate.example.com 9 | clientConfig: 10 | service: 11 | name: kube-annotate 12 | namespace: kube-apps # NOTE: replace this with namespace where you deploy `kube-annotate` 13 | path: "/mutate" 14 | # NOTE: this CA_BUNDLE must be replaced with base64-encoded bundle from your cluster 15 | caBundle: ${CA_BUNDLE} 16 | rules: 17 | - operations: [ "CREATE" ] 18 | apiGroups: [""] 19 | apiVersions: ["v1"] 20 | resources: ["pods"] 21 | # NOTE: comment out lines below if you want to allow all namespaces 22 | namespaceSelector: 23 | matchLabels: 24 | kube-annotate: 'enabled' 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/chickenzord/kube-annotate/annotator" 12 | "github.com/chickenzord/kube-annotate/config" 13 | "github.com/chickenzord/kube-annotate/web" 14 | "github.com/gorilla/mux" 15 | negronilogrus "github.com/meatballhat/negroni-logrus" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | prommiddleware "github.com/slok/go-prometheus-middleware" 18 | promnegroni "github.com/slok/go-prometheus-middleware/negroni" 19 | "github.com/urfave/negroni" 20 | ) 21 | 22 | var log = config.AppLogger 23 | 24 | func main() { 25 | log.Infof("starting kube-annotate version %s (%s)", config.Version, config.GitCommit) 26 | 27 | stop := make(chan os.Signal) 28 | signal.Notify(stop, os.Interrupt, syscall.SIGTERM) 29 | 30 | if err := config.InitRules(); err != nil { 31 | log.Fatalf("cannot initialize rules: %v", err) 32 | } 33 | 34 | tlsConfig, err := config.TLSConfig() 35 | if err != nil { 36 | log.WithError(err).Fatal("invalid TLS config") 37 | } 38 | 39 | rInternal := mux.NewRouter() 40 | rInternal.HandleFunc("/health", web.HealthHandler) 41 | rInternal.Handle("/metrics", promhttp.Handler()) 42 | nInternal := negroni.New() 43 | nInternal.UseHandler(rInternal) 44 | internal := &http.Server{ 45 | Handler: nInternal, 46 | Addr: config.BindAddressInternal, 47 | WriteTimeout: 5 * time.Second, 48 | ReadTimeout: 5 * time.Second, 49 | } 50 | 51 | mLogger := negronilogrus.NewMiddlewareFromLogger(log.Logger, config.AppName) 52 | mProm := promnegroni.Handler("", prommiddleware.NewDefault()) 53 | 54 | rServer := mux.NewRouter() 55 | rServer.HandleFunc("/mutate", annotator.MutateHandler) 56 | rServer.HandleFunc("/rules", annotator.RulesHandler) 57 | nServer := negroni.New(mLogger, mProm) 58 | nServer.UseHandler(rServer) 59 | server := &http.Server{ 60 | Handler: nServer, 61 | Addr: config.BindAddress, 62 | WriteTimeout: 15 * time.Second, 63 | ReadTimeout: 15 * time.Second, 64 | } 65 | 66 | go func() { 67 | log.Infof("API server is listening on %s", server.Addr) 68 | var err error 69 | if tlsConfig == nil { 70 | log.Debug("API server TLS is disabled") 71 | err = server.ListenAndServe() 72 | } else { 73 | log.Debug("API server TLS is enabled") 74 | err = server.ListenAndServeTLS(config.TLSCert, config.TLSKey) 75 | } 76 | if err != http.ErrServerClosed { 77 | log.WithError(err).Fatalf("API server failed to listen on %s", server.Addr) 78 | } 79 | }() 80 | 81 | go func() { 82 | log.Infof("internal server is listening on %s", internal.Addr) 83 | if err := internal.ListenAndServe(); err != http.ErrServerClosed { 84 | log.WithError(err).Fatalf("internal server failed to listen on %s", internal.Addr) 85 | } 86 | }() 87 | 88 | <-stop 89 | 90 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 91 | defer cancel() 92 | 93 | log.Infof("stopping gracefully") 94 | if err := internal.Shutdown(ctx); err != nil { 95 | log.WithError(err). 96 | Infof("failed to stop internal server gracefully") 97 | } else { 98 | log.Infof("internal server gracefully stopped") 99 | } 100 | if err := server.Shutdown(ctx); err != nil { 101 | log.WithError(err). 102 | Infof("failed to stop API server gracefully") 103 | } else { 104 | log.Infof("API server gracefully stopped") 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /web/health.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | //HealthHandler handles health checks 9 | func HealthHandler(w http.ResponseWriter, r *http.Request) { 10 | fmt.Fprint(w, "OK") 11 | } 12 | -------------------------------------------------------------------------------- /web/health_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHealthHandler(t *testing.T) { 12 | req, err := http.NewRequest("GET", "/health", nil) 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | rr := httptest.NewRecorder() 18 | handler := http.HandlerFunc(HealthHandler) 19 | handler.ServeHTTP(rr, req) 20 | 21 | assert.Equal(t, http.StatusOK, rr.Code) 22 | } 23 | --------------------------------------------------------------------------------