├── example ├── serviceaccount.yaml ├── service.yaml ├── clusterrolebinding.yaml ├── admissionregistration.yaml └── deployment.yaml ├── logger_test.go ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── glide.yaml ├── logger.go ├── LICENSE ├── README.md ├── main.go ├── listener.go └── listener_test.go /example/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app: k8s-namespace-guard 6 | name: k8s-namespace-guard 7 | namespace: default 8 | -------------------------------------------------------------------------------- /example/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: k8s-namespace-guard 6 | name: k8s-namespace-guard 7 | namespace: default 8 | spec: 9 | ports: 10 | - port: 443 11 | targetPort: 443 12 | name: https 13 | selector: 14 | app: k8s-namespace-guard 15 | -------------------------------------------------------------------------------- /example/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | # ReadOnly access for the webhook to list resources 2 | apiVersion: rbac.authorization.k8s.io/v1beta1 3 | kind: ClusterRoleBinding 4 | metadata: 5 | name: view 6 | roleRef: 7 | apiGroup: rbac.authorization.k8s.io 8 | kind: ClusterRole 9 | name: view 10 | subjects: 11 | - kind: ServiceAccount 12 | name: k8s-namespace-guard 13 | namespace: default 14 | -------------------------------------------------------------------------------- /logger_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Yahoo Holdings Inc. 2 | // Licensed under the terms of the 3-Clause BSD License. 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "io" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestLogger(t *testing.T) { 14 | var buf1 bytes.Buffer 15 | writer := io.MultiWriter(&buf1) 16 | testLogger := createLogger(writer, "info") 17 | 18 | testLogger.Info("test") 19 | testLogger.Warn("test") 20 | 21 | assert.Regexp(t, "INFO .* test\nWARNING .* test", buf1.String()) 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps to Reproduce** 14 | A list of steps with the inputs to reproduce the issue. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Provide the environment context and component versions** 20 | - Kubernetes version 21 | - Linux OS/Kernel version 22 | - Istio/Envoy version 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Please describe the problem that you are trying to solve** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /example/admissionregistration.yaml: -------------------------------------------------------------------------------- 1 | ######################################################## 2 | # k8s-namespace-guard Admission webhook registration 3 | ######################################################## 4 | # Please update the CABundle with valid CA 5 | 6 | apiVersion: admissionregistration.k8s.io/v1alpha1 7 | kind: ExternalAdmissionHookConfiguration 8 | metadata: 9 | name: k8s-namespace-guard 10 | externalAdmissionHooks: 11 | - name: k8s-namespace-guard.yahoo.io 12 | rules: 13 | - operations: 14 | - DELETE 15 | apiGroups: 16 | - "" 17 | apiVersions: 18 | - v1 19 | resources: 20 | - namespaces 21 | failurePolicy: Fail 22 | clientConfig: 23 | service: 24 | namespace: default 25 | name: k8s-namespace-guard 26 | caBundle: 27 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/yahoo/k8s-namespace-guard 2 | import: 3 | - package: github.com/Sirupsen/logrus 4 | version: ^0.11.0 5 | - package: gopkg.in/natefinch/lumberjack.v2 6 | version: ^2.0.0 7 | - package: k8s.io/api 8 | subpackages: 9 | - admission/v1alpha1 10 | - package: k8s.io/client-go 11 | version: ^v4.0.0 12 | subpackages: 13 | - kubernetes 14 | - rest 15 | - package: k8s.io/apimachinery 16 | version: release-1.7 17 | subpackages: 18 | - pkg/apis/meta/v1 19 | testImport: 20 | - package: k8s.io/api 21 | subpackages: 22 | - authentication/v1 23 | - package: k8s.io/apimachinery 24 | version: release-1.7 25 | subpackages: 26 | - pkg/api/errors 27 | - pkg/runtime 28 | - package: k8s.io/client-go 29 | version: ^v4.0.0 30 | subpackages: 31 | - kubernetes/fake 32 | - pkg/api 33 | - pkg/api/v1 34 | - pkg/apis/apps/v1beta1 35 | - pkg/apis/autoscaling/v1 36 | - pkg/apis/extensions/v1beta1 37 | - package: github.com/stretchr/testify 38 | version: ^1.1.4 39 | subpackages: 40 | - assert 41 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Yahoo Holdings Inc. 2 | // Licensed under the terms of the 3-Clause BSD License. 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "github.com/Sirupsen/logrus" 8 | "io" 9 | "os" 10 | "strings" 11 | 12 | "gopkg.in/natefinch/lumberjack.v2" 13 | ) 14 | 15 | type Formatter struct { 16 | } 17 | 18 | func (f *Formatter) Format(entry *logrus.Entry) ([]byte, error) { 19 | b := &bytes.Buffer{} 20 | s := strings.ToUpper(entry.Level.String()) + " [" + entry.Time.Format("2006-01-02 15:04:05") + "] " + entry.Message 21 | 22 | b.WriteString(s) 23 | b.WriteByte('\n') 24 | return b.Bytes(), nil 25 | } 26 | 27 | func createLogger(writer io.Writer, level string) *logrus.Logger { 28 | logLevel, _ := logrus.ParseLevel(level) 29 | 30 | myLogger := &logrus.Logger{ 31 | Out: writer, 32 | Formatter: new(Formatter), 33 | Level: logLevel, 34 | } 35 | return myLogger 36 | 37 | } 38 | 39 | func getLogger(logFilename string, level string) *logrus.Logger { 40 | fileWriter := io.MultiWriter(os.Stdout, &lumberjack.Logger{ 41 | Filename: logFilename, 42 | MaxSize: 1, // Mb 43 | MaxBackups: 5, 44 | MaxAge: 28, // Days 45 | }) 46 | 47 | myLogger := createLogger(fileWriter, level) 48 | return myLogger 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Yahoo Holdings Inc. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | * Redistributions of source code must retain the above copyright 6 | notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright 8 | notice, this list of conditions and the following disclaimer in the 9 | documentation and/or other materials provided with the distribution. 10 | * Neither the name of the Yahoo! Inc. nor the 11 | names of its contributors may be used to endorse or promote products 12 | derived from this software without specific prior written permission. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY 18 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # k8s-namespace-guard 2 | 3 | k8s-namespace-guard provides an admission control policy that safeguards against accidental deletion of cluster namespaces. 4 | 5 | ## Implementation 6 | 7 | This is implemented as an [External Admission Webhook](https://kubernetes.io/docs/admin/extensible-admission-controllers/#external-admission-webhooks) with the k8s-namespace-guard service running as a deployment on each cluster. 8 | 9 | The webhook is configured to send admission review requests for *DELETE* operations on `namespace` resources to the k8s-namespace-guard service. 10 | The k8s-namespace-guard service listens on a HTTPS port and on receiving such requests, it lists the workload resources defined under that namespace. 11 | The DELETE operation is allowed to proceed only when the namespace does NOT contain such workload resources. 12 | 13 | The following resources are currently checked for existence: 14 | - pods 15 | - services 16 | - replicasets 17 | - deployments 18 | - statefulsets 19 | - daemonsets 20 | - ingresses 21 | - horizontalpodautoscalers 22 | 23 | The k8s-namespace-guard policy implementation enforces that the above listed resources under the namespace should be deleted before it can be removed. 24 | 25 | ## Basic Dev Setup 26 | 27 | 1. Git clone to your local directory. 28 | 2. Build binary: 29 | - Mac os: `go build -i -o k8s-namespace-guard` 30 | - Rhel: `env GOOS=linux GOARCH=amd64 go build -i -o k8s-namespace-guard` 31 | 3. Run binary: `./k8s-namespace-guard`. 32 | 4. Follow standard Go code format: `gofmt -w *.go` 33 | 34 | ## Command Line Args 35 | 36 | ``` 37 | USAGE: 38 | --admitAll bool True to admit all namespace deletions without validation. (default false) 39 | --certFile string The cert file for the https server. (default "/var/lib/kubernetes/kubernetes.pem") 40 | --clientAuth bool True to verify client cert/auth during TLS handshake. (default false) 41 | --clientCAFile string The cluster root CA that signs the apiserver cert (default "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") 42 | --keyFile string The key file for the https server. (default "/var/lib/kubernetes/kubernetes-key.pem") 43 | --logFile string Log file name and full path. (default "/var/log/nslifecycle.log") 44 | --logLevel string The log level. (default "info") 45 | --port string Server port. (default "443") 46 | ``` 47 | 48 | Copyright 2017 Yahoo Holdings Inc. Licensed under the terms of the 3-Clause BSD License. 49 | -------------------------------------------------------------------------------- /example/deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################## 2 | # k8s-namespace-guard Deployment 3 | ######################################################## 4 | # k8s-namespace-guard need to be TLS enabled, Please create a cert/key pair and upload to k8s secret 5 | apiVersion: apps/v1beta1 6 | kind: Deployment 7 | metadata: 8 | labels: 9 | app: k8s-namespace-guard 10 | name: k8s-namespace-guard 11 | namespace: default 12 | spec: 13 | replicas: 1 14 | selector: 15 | matchLabels: 16 | app: k8s-namespace-guard 17 | revisionHistoryLimit: 3 18 | strategy: 19 | rollingUpdate: 20 | maxSurge: 50% 21 | maxUnavailable: 50% 22 | type: RollingUpdate 23 | template: 24 | metadata: 25 | labels: 26 | app: k8s-namespace-guard 27 | spec: 28 | serviceAccountName: k8s-namespace-guard 29 | terminationGracePeriodSeconds: 0 30 | containers: 31 | - name: k8s-namespace-guard 32 | resources: 33 | requests: 34 | memory: 128Mi 35 | cpu: 100m 36 | limits: 37 | memory: 512Mi 38 | cpu: 500m 39 | image: "k8s-namespace-guard" 40 | args: 41 | - --admitAll=false 42 | - --keyFile=/etc/ssl/certs/k8s-namespace-guard/server-key.pem 43 | - --certFile=/etc/ssl/certs/k8s-namespace-guard/server.crt 44 | - --clientAuth=false 45 | - --logFile=/var/log/k8s-namespace-guard.log 46 | - --logLevel=info 47 | - --port=443 48 | command: 49 | - /usr/bin/k8s-namespace-guard 50 | ports: 51 | - containerPort: 443 52 | env: 53 | - name: POD_IP 54 | valueFrom: 55 | fieldRef: 56 | fieldPath: status.podIP 57 | livenessProbe: 58 | httpGet: 59 | path: /status.html 60 | port: 443 61 | scheme: HTTPS 62 | initialDelaySeconds: 30 63 | timeoutSeconds: 2 64 | readinessProbe: 65 | httpGet: 66 | path: /status.html 67 | port: 443 68 | scheme: HTTPS 69 | initialDelaySeconds: 10 70 | timeoutSeconds: 2 71 | volumeMounts: 72 | - name: tls 73 | mountPath: "/etc/ssl/certs/k8s-namespace-guard" 74 | readOnly: true 75 | volumes: 76 | - name: tls 77 | secret: 78 | defaultMode: 420 79 | items: 80 | - key: tls.key 81 | path: server-key.pem 82 | - key: tls.crt 83 | path: server.crt 84 | secretName: k8s-namespace-guard-tls-certs 85 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Yahoo Holdings Inc. 2 | // Licensed under the terms of the 3-Clause BSD License. 3 | package main 4 | 5 | import ( 6 | "crypto/tls" 7 | "crypto/x509" 8 | "flag" 9 | "io" 10 | "net/http" 11 | 12 | "io/ioutil" 13 | "os" 14 | "os/signal" 15 | "syscall" 16 | 17 | "github.com/Sirupsen/logrus" 18 | "k8s.io/client-go/kubernetes" 19 | "k8s.io/client-go/rest" 20 | ) 21 | 22 | var ( 23 | port = flag.String("port", "443", "Server port.") 24 | logFilename = flag.String("logFile", "/var/log/nslifecycle.log", "Log file name and full path.") 25 | logLevel = flag.String("logLevel", "info", "The log level.") 26 | httpsCertFile = flag.String("certFile", "/var/lib/kubernetes/kubernetes.pem", "The cert file for the https server.") 27 | httpsKeyFile = flag.String("keyFile", "/var/lib/kubernetes/kubernetes-key.pem", "The key file for the https server.") 28 | clientCAFile = flag.String("clientCAFile", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "The cluster root CA that signs the apiserver cert") 29 | clientAuth = flag.Bool("clientAuth", false, "True to verify client cert/auth during TLS handshake.") 30 | admitAll = flag.Bool("admitAll", false, "True to admit all namespace deletions without validation.") 31 | 32 | clientset kubernetes.Interface 33 | 34 | log *logrus.Logger 35 | ) 36 | 37 | func init() { 38 | flag.Parse() 39 | log = getLogger(*logFilename, *logLevel) 40 | } 41 | 42 | // statusHandler serves the /status.html response which is always 200. 43 | func statusHandler(rw http.ResponseWriter, req *http.Request) { 44 | log.Infof("Serving %s %s request for client: %s", req.Method, req.URL.Path, req.RemoteAddr) 45 | io.WriteString(rw, "OK") 46 | } 47 | 48 | func main() { 49 | 50 | // creates the k8s in-cluster config 51 | config, err := rest.InClusterConfig() 52 | if err != nil { 53 | log.Fatalf("Error occurred while building the in-cluster kube-config: %s", err.Error()) 54 | } 55 | 56 | // creates the clientset 57 | clientset, err = kubernetes.NewForConfig(config) 58 | if err != nil { 59 | log.Fatalf("Error occurred while initializing the client set: %s", err.Error()) 60 | } 61 | 62 | // add the serving path handlers 63 | mux := http.NewServeMux() 64 | mux.HandleFunc("/status.html", statusHandler) 65 | mux.HandleFunc("/", webhookHandler) 66 | 67 | // load the https server cert and key 68 | xcert, err := tls.LoadX509KeyPair(*httpsCertFile, *httpsKeyFile) 69 | if err != nil { 70 | log.Fatalf("Unable to read the server cert and/or key file: %s", err.Error()) 71 | } 72 | 73 | // load the cluster CA that signs the client(apiserver) cert 74 | caCert, err := ioutil.ReadFile(*clientCAFile) 75 | if err != nil { 76 | log.Fatalf("Couldn't load file: %s", err.Error()) 77 | } 78 | 79 | caCertPool := x509.NewCertPool() 80 | caCertPool.AppendCertsFromPEM(caCert) 81 | 82 | // create the TLS config for the https server 83 | tlsConfig := &tls.Config{ 84 | RootCAs: caCertPool, 85 | Certificates: []tls.Certificate{xcert}, 86 | ClientCAs: caCertPool, 87 | } 88 | // enable client(apiserver) certificate verification if --clientAuth=true 89 | if *clientAuth { 90 | tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert 91 | } 92 | 93 | // create the https server object 94 | srv := &http.Server{ 95 | Addr: ":" + *port, 96 | Handler: mux, 97 | TLSConfig: tlsConfig, 98 | } 99 | 100 | // start the https server 101 | go func() { 102 | err = srv.ListenAndServeTLS("", "") 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | }() 107 | log.Infof("HTTPS server listening on port: %s with ClientAuthEnabled: %t ", *port, *clientAuth) 108 | 109 | // graceful shutdown.. 110 | signalChan := make(chan os.Signal, 2) 111 | signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) 112 | for { 113 | select { 114 | case <-signalChan: 115 | log.Printf("Shutdown signal received, exiting...") 116 | os.Exit(0) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /listener.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Yahoo Holdings Inc. 2 | // Licensed under the terms of the 3-Clause BSD License. 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | 13 | "k8s.io/api/admission/v1alpha1" 14 | apiErrors "k8s.io/apimachinery/pkg/api/errors" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | ) 17 | 18 | const ( 19 | bypassAnnotationKey = "k8s-namespace-guard.admission.yahoo.com/allow-cascade-delete" 20 | ) 21 | 22 | var ( 23 | namespaceResourceType = v1.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} 24 | ) 25 | 26 | // writeResponse writes the admissionReviewStatus object to the response body 27 | func writeResponse(rw http.ResponseWriter, admReview *v1alpha1.AdmissionReview, allowed bool, errorMsg string) { 28 | log.Infof("Responding Allowed: %t for %s on Namespace: %s by user: %s", allowed, 29 | admReview.Spec.Operation, 30 | admReview.Spec.Name, 31 | admReview.Spec.UserInfo.Username) 32 | 33 | if !allowed { 34 | log.Errorf("Rejection reason: %s", errorMsg) 35 | } 36 | 37 | admReview.Status = v1alpha1.AdmissionReviewStatus{ 38 | Allowed: allowed, 39 | Result: &v1.Status{ 40 | Reason: v1.StatusReason(errorMsg), 41 | }, 42 | } 43 | 44 | body := new(bytes.Buffer) 45 | err := json.NewEncoder(body).Encode(admReview) 46 | if err != nil { 47 | io.WriteString(rw, "Error occurred while encoding the admission review status into json: "+err.Error()) 48 | return 49 | } 50 | rw.Write(body.Bytes()) 51 | } 52 | 53 | func podCounter(namespace string) (int, error) { 54 | list, err := clientset.CoreV1().Pods(namespace).List(v1.ListOptions{}) 55 | if err != nil { 56 | return 0, err 57 | } 58 | return len(list.Items), nil 59 | } 60 | 61 | func serviceCounter(namespace string) (int, error) { 62 | list, err := clientset.CoreV1().Services(namespace).List(v1.ListOptions{}) 63 | if err != nil { 64 | return 0, err 65 | } 66 | return len(list.Items), nil 67 | } 68 | 69 | func replicasetCounter(namespace string) (int, error) { 70 | list, err := clientset.ExtensionsV1beta1().ReplicaSets(namespace).List(v1.ListOptions{}) 71 | if err != nil { 72 | return 0, err 73 | } 74 | return len(list.Items), nil 75 | } 76 | 77 | func deploymentCounter(namespace string) (int, error) { 78 | list, err := clientset.AppsV1beta1().Deployments(namespace).List(v1.ListOptions{}) 79 | if err != nil { 80 | return 0, err 81 | } 82 | return len(list.Items), nil 83 | } 84 | 85 | func statefulsetCounter(namespace string) (int, error) { 86 | list, err := clientset.AppsV1beta1().StatefulSets(namespace).List(v1.ListOptions{}) 87 | if err != nil { 88 | return 0, err 89 | } 90 | return len(list.Items), nil 91 | } 92 | 93 | func daemonsetCounter(namespace string) (int, error) { 94 | list, err := clientset.ExtensionsV1beta1().DaemonSets(namespace).List(v1.ListOptions{}) 95 | if err != nil { 96 | return 0, err 97 | } 98 | return len(list.Items), nil 99 | } 100 | 101 | func ingressCounter(namespace string) (int, error) { 102 | list, err := clientset.ExtensionsV1beta1().Ingresses(namespace).List(v1.ListOptions{}) 103 | if err != nil { 104 | return 0, err 105 | } 106 | return len(list.Items), nil 107 | } 108 | 109 | func autoScaleCounter(namespace string) (int, error) { 110 | list, err := clientset.AutoscalingV1().HorizontalPodAutoscalers(namespace).List(v1.ListOptions{}) 111 | if err != nil { 112 | return 0, err 113 | } 114 | return len(list.Items), nil 115 | } 116 | 117 | // validateNamespaceDeletion returns an error if the namespace contains any workload resources 118 | func validateNamespaceDeletion(namespace string) (err error) { 119 | 120 | counters := []struct { 121 | kind string 122 | counter func(namespace string) (int, error) 123 | }{ 124 | {"pods", podCounter}, 125 | {"services", serviceCounter}, 126 | {"replicasets", replicasetCounter}, 127 | {"deployments", deploymentCounter}, 128 | {"statefulsets", statefulsetCounter}, 129 | {"daemonsets", daemonsetCounter}, 130 | {"ingresses", ingressCounter}, 131 | {"horizontalpodautoscalers", autoScaleCounter}, 132 | } 133 | 134 | var errList []error 135 | var nonEmptyList []string 136 | 137 | for _, c := range counters { 138 | num, err := c.counter(namespace) 139 | if err != nil { 140 | errList = append(errList, fmt.Errorf("error listing %s, %v", c.kind, err)) 141 | continue 142 | } 143 | if num > 0 { 144 | nonEmptyList = append(nonEmptyList, fmt.Sprintf("%s(%d)", c.kind, num)) 145 | } 146 | } 147 | 148 | errStr := "" 149 | if len(nonEmptyList) > 0 { 150 | errStr += fmt.Sprintf("The namespace %s you are trying to remove contains one or more of these resources: %v. Please delete them and try again.", namespace, nonEmptyList) 151 | } 152 | if len(errList) > 0 { 153 | errStr += fmt.Sprintf("The following error(s) occurred while validating the DELETE operation on the namespace %s: %v.", namespace, errList) 154 | } 155 | if errStr != "" { 156 | errStr += fmt.Sprintf(" WARNING: If you know what you are doing, run `kubectl annotate namespace %s %s=true` to bypass this policy check.", namespace, bypassAnnotationKey) 157 | return errors.New(errStr) 158 | } 159 | return nil 160 | } 161 | 162 | // webhookHandler handles the namespace deletion guard admission webhook 163 | func webhookHandler(rw http.ResponseWriter, req *http.Request) { 164 | log.Infof("Serving %s %s request for client: %s", req.Method, req.URL.Path, req.RemoteAddr) 165 | 166 | if req.Method != http.MethodPost { 167 | http.Error(rw, fmt.Sprintf("Incoming request method %s is not supported, only POST is supported", req.Method), http.StatusMethodNotAllowed) 168 | return 169 | } 170 | 171 | if req.URL.Path != "/" { 172 | http.Error(rw, fmt.Sprintf("%s 404 Not Found", req.URL.Path), http.StatusNotFound) 173 | return 174 | } 175 | 176 | admReview := v1alpha1.AdmissionReview{} 177 | err := json.NewDecoder(req.Body).Decode(&admReview) 178 | if err != nil { 179 | errorMsg := fmt.Sprintf("Failed to decode the request body json into an AdmissionReview resource: %s", err.Error()) 180 | writeResponse(rw, &v1alpha1.AdmissionReview{}, false, errorMsg) 181 | return 182 | } 183 | log.Debugf("Incoming AdmissionReview for %s on resource: %v, kind: %v", admReview.Spec.Operation, admReview.Spec.Resource, admReview.Spec.Kind) 184 | 185 | if *admitAll == true { 186 | log.Warnf("admitAll flag is set to true. Allowing Namespace admission review request to pass without validation.") 187 | writeResponse(rw, &admReview, true, "") 188 | return 189 | } 190 | 191 | if admReview.Spec.Resource != namespaceResourceType { 192 | errorMsg := fmt.Sprintf("Incoming resource is not a Namespace: %v", admReview.Spec.Resource) 193 | writeResponse(rw, &admReview, false, errorMsg) 194 | return 195 | } 196 | 197 | if admReview.Spec.Operation != v1alpha1.Delete { 198 | errorMsg := fmt.Sprintf("Incoming operation is %v on namespace %s. Only DELETE is currently supported.", admReview.Spec.Operation, admReview.Spec.Name) 199 | writeResponse(rw, &admReview, false, errorMsg) 200 | return 201 | } 202 | 203 | namespace, err := clientset.CoreV1().Namespaces().Get(admReview.Spec.Name, v1.GetOptions{}) 204 | if err != nil { 205 | // If the namespace is not found, approve the request and let apiserver handle the case 206 | // For any other error, reject the request 207 | if apiErrors.IsNotFound(err) { 208 | log.Debugf("Namespace %s not found, let apiserver handle the error: %s", admReview.Spec.Name, err.Error()) 209 | writeResponse(rw, &admReview, true, "") 210 | } else { 211 | errorMsg := fmt.Sprintf("Error occurred while retrieving the namespace %s: %s", admReview.Spec.Name, err.Error()) 212 | writeResponse(rw, &admReview, false, errorMsg) 213 | } 214 | return 215 | } 216 | 217 | if annotations := namespace.GetAnnotations(); annotations != nil { 218 | if annotations[bypassAnnotationKey] == "true" { 219 | log.Infof("Namespace %s has the bypass annotation set[%s:true]. OK to DELETE.", admReview.Spec.Name, bypassAnnotationKey) 220 | writeResponse(rw, &admReview, true, "") 221 | return 222 | } 223 | } 224 | 225 | err = validateNamespaceDeletion(admReview.Spec.Name) 226 | if err != nil { 227 | writeResponse(rw, &admReview, false, err.Error()) 228 | return 229 | } 230 | 231 | log.Infof("Namespace %s does not contain any workload resources. OK to DELETE.", admReview.Spec.Name) 232 | writeResponse(rw, &admReview, true, "") 233 | } 234 | -------------------------------------------------------------------------------- /listener_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Yahoo Holdings Inc. 2 | // Licensed under the terms of the 3-Clause BSD License. 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/httptest" 13 | "os/user" 14 | "testing" 15 | 16 | "k8s.io/api/admission/v1alpha1" 17 | authenticationv1 "k8s.io/api/authentication/v1" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | "k8s.io/client-go/kubernetes/fake" 21 | "k8s.io/client-go/pkg/api" 22 | corev1 "k8s.io/client-go/pkg/api/v1" 23 | appsv1beta1 "k8s.io/client-go/pkg/apis/apps/v1beta1" 24 | autoscalingv1 "k8s.io/client-go/pkg/apis/autoscaling/v1" 25 | extensionsv1beta1 "k8s.io/client-go/pkg/apis/extensions/v1beta1" 26 | 27 | "github.com/stretchr/testify/assert" 28 | ) 29 | 30 | var ( 31 | templateNamespace = &corev1.Namespace{ 32 | ObjectMeta: v1.ObjectMeta{ 33 | Name: "test-namespace", 34 | ResourceVersion: "1", 35 | }, 36 | Spec: corev1.NamespaceSpec{ 37 | Finalizers: []corev1.FinalizerName{"kubernetes"}, 38 | }, 39 | } 40 | templateAdmReview = &v1alpha1.AdmissionReview{ 41 | Spec: v1alpha1.AdmissionReviewSpec{ 42 | Resource: v1.GroupVersionResource{ 43 | Group: "", 44 | Version: "v1", 45 | Resource: "namespaces", 46 | }, 47 | Kind: v1.GroupVersionKind{ 48 | Kind: "Namespace", 49 | }, 50 | Object: runtime.RawExtension{ 51 | Raw: []byte("{}"), 52 | }, 53 | Name: "test-namespace", 54 | Namespace: "test-namespace", 55 | Operation: "DELETE", 56 | UserInfo: authenticationv1.UserInfo{ 57 | Username: (func() string { 58 | user, err := user.Current() 59 | if err != nil { 60 | panic(err) 61 | } 62 | return user.Name 63 | })(), 64 | }, 65 | }, 66 | } 67 | ) 68 | 69 | func cloneNamespace(templateNamespace *corev1.Namespace) *corev1.Namespace { 70 | testNamespaceObj, err := api.Scheme.DeepCopy(templateNamespace) 71 | testNamespace, ok := testNamespaceObj.(*corev1.Namespace) 72 | if err != nil || !ok { 73 | panic(fmt.Sprintf("Cloning Namespace failed with err: %v, ok: %t", err, ok)) 74 | } 75 | return testNamespace 76 | } 77 | 78 | func cloneAdmissionReview(templateAdmReview *v1alpha1.AdmissionReview) *v1alpha1.AdmissionReview { 79 | testAdmReviewObj, err := api.Scheme.DeepCopy(templateAdmReview) 80 | testAdmReview, ok := testAdmReviewObj.(*v1alpha1.AdmissionReview) 81 | if err != nil || !ok { 82 | panic(fmt.Sprintf("Cloning test AdmissionReview spec failed with err: %v, ok: %t", err, ok)) 83 | } 84 | return testAdmReview 85 | } 86 | 87 | func getAdmissionReview(rw *httptest.ResponseRecorder) *v1alpha1.AdmissionReview { 88 | admReview := &v1alpha1.AdmissionReview{} 89 | err := json.NewDecoder(rw.Result().Body).Decode(admReview) 90 | if err != nil { 91 | panic(err.Error()) 92 | } 93 | return admReview 94 | } 95 | 96 | func constructPostBody(admReview *v1alpha1.AdmissionReview) io.Reader { 97 | body := new(bytes.Buffer) 98 | err := json.NewEncoder(body).Encode(admReview) 99 | if err != nil { 100 | panic(err.Error()) 101 | } 102 | return body 103 | } 104 | 105 | func TestAllowedWriteResponse(t *testing.T) { 106 | rw := httptest.NewRecorder() 107 | review := &v1alpha1.AdmissionReview{} 108 | writeResponse(rw, review, true, "") 109 | 110 | admReview := getAdmissionReview(rw) 111 | 112 | expectedAdmReview := &v1alpha1.AdmissionReview{ 113 | Status: v1alpha1.AdmissionReviewStatus{ 114 | Allowed: true, 115 | Result: &v1.Status{ 116 | Reason: v1.StatusReason(""), 117 | }, 118 | }, 119 | } 120 | assert.Equal(t, 121 | expectedAdmReview.Status, 122 | admReview.Status, 123 | "writeResponse should write Allowed: true for AdmissionReviewStatus") 124 | } 125 | 126 | func TestNotAllowedWriteResponse(t *testing.T) { 127 | rw := httptest.NewRecorder() 128 | review := &v1alpha1.AdmissionReview{} 129 | writeResponse(rw, review, false, "Namespace test-namespace contains one or more resources") 130 | 131 | admReview := getAdmissionReview(rw) 132 | 133 | expectedAdmReview := &v1alpha1.AdmissionReview{ 134 | Status: v1alpha1.AdmissionReviewStatus{ 135 | Allowed: false, 136 | Result: &v1.Status{ 137 | Reason: v1.StatusReason("Namespace test-namespace contains one or more resources"), 138 | }, 139 | }, 140 | } 141 | assert.Equal(t, 142 | expectedAdmReview.Status, 143 | admReview.Status, 144 | "writeResponse should write Allowed: false for AdmissionReviewStatus") 145 | } 146 | 147 | func TestWrongMethodWebhookHandler(t *testing.T) { 148 | rw := httptest.NewRecorder() 149 | req := httptest.NewRequest("GET", "http://localhost:8080/namespaces", nil) 150 | 151 | webhookHandler(rw, req) 152 | 153 | assert.Equal(t, rw.Code, 405) 154 | } 155 | 156 | func TestWrongPathWebhookHandler(t *testing.T) { 157 | rw := httptest.NewRecorder() 158 | req := httptest.NewRequest("POST", "http://localhost:8080/namespaces", nil) 159 | 160 | webhookHandler(rw, req) 161 | 162 | assert.Equal(t, rw.Code, 404) 163 | body, err := ioutil.ReadAll(rw.Result().Body) 164 | assert.Nil(t, err, "Error should be nil") 165 | assert.Contains(t, string(body), "/namespaces 404 Not Found") 166 | } 167 | 168 | func TestWrongReqBodyWebhookHandler(t *testing.T) { 169 | rw := httptest.NewRecorder() 170 | req := httptest.NewRequest("POST", "http://localhost:8080/", nil) 171 | 172 | webhookHandler(rw, req) 173 | 174 | admReview := getAdmissionReview(rw) 175 | 176 | assert.False(t, admReview.Status.Allowed, "should fail if request doesn't have a body") 177 | assert.Contains(t, admReview.Status.Result.Reason, "Failed to decode the request body json into an AdmissionReview resource: ") 178 | } 179 | 180 | func TestAdmitAllWebhookHandler(t *testing.T) { 181 | rw := httptest.NewRecorder() 182 | 183 | testSpec := cloneAdmissionReview(templateAdmReview) 184 | 185 | *admitAll = true 186 | 187 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 188 | webhookHandler(rw, req) 189 | 190 | admReview := getAdmissionReview(rw) 191 | 192 | assert.True(t, admReview.Status.Allowed, "should allow namespace delete to pass through if admitAll flag is set") 193 | *admitAll = false 194 | } 195 | 196 | func TestNamespaceResourceTypeWebhookHandler(t *testing.T) { 197 | rw := httptest.NewRecorder() 198 | 199 | testSpec := &v1alpha1.AdmissionReview{ 200 | Spec: v1alpha1.AdmissionReviewSpec{ 201 | Resource: v1.GroupVersionResource{ 202 | Group: "", 203 | Version: "v1", 204 | Resource: "pods", 205 | }, 206 | }, 207 | } 208 | 209 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 210 | webhookHandler(rw, req) 211 | 212 | admReview := getAdmissionReview(rw) 213 | 214 | assert.False(t, admReview.Status.Allowed, "should reject if the resource is not Namespace type") 215 | assert.Contains(t, admReview.Status.Result.Reason, "Incoming resource is not a Namespace: { v1 pods}") 216 | } 217 | 218 | func TestWrongOperationWebhookHandler(t *testing.T) { 219 | rw := httptest.NewRecorder() 220 | 221 | testSpec := cloneAdmissionReview(templateAdmReview) 222 | 223 | testSpec.Spec.Operation = v1alpha1.Create 224 | 225 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 226 | webhookHandler(rw, req) 227 | 228 | admReview := getAdmissionReview(rw) 229 | 230 | assert.False(t, admReview.Status.Allowed, "should reject if the operation is NOT DELETE") 231 | assert.Contains(t, admReview.Status.Result.Reason, "Incoming operation is CREATE on namespace test-namespace. Only DELETE is currently supported.") 232 | } 233 | 234 | func TestNonExistingNamespaceWebhookHandler(t *testing.T) { 235 | rw := httptest.NewRecorder() 236 | 237 | testSpec := cloneAdmissionReview(templateAdmReview) 238 | clientset = &fake.Clientset{} 239 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 240 | webhookHandler(rw, req) 241 | 242 | admReview := getAdmissionReview(rw) 243 | 244 | assert.True(t, admReview.Status.Allowed, "should approve if the namespace does not exist") 245 | } 246 | 247 | func TestBypassAnnotationTrueWebhookHandler(t *testing.T) { 248 | rw := httptest.NewRecorder() 249 | 250 | testPod := &corev1.Pod{ 251 | ObjectMeta: v1.ObjectMeta{ 252 | Name: "test-pod", 253 | Namespace: "test-namespace", 254 | }, 255 | Spec: corev1.PodSpec{ 256 | Hostname: "test-pod.yahoo.com", 257 | }, 258 | } 259 | testNamespace := cloneNamespace(templateNamespace) 260 | testNamespace.Annotations = map[string]string{bypassAnnotationKey: "true"} 261 | clientset = fake.NewSimpleClientset(testPod, testNamespace) 262 | 263 | testSpec := cloneAdmissionReview(templateAdmReview) 264 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 265 | 266 | webhookHandler(rw, req) 267 | 268 | admReview := getAdmissionReview(rw) 269 | 270 | assert.True(t, admReview.Status.Allowed, "should approve if the bypass annotation is set to true") 271 | } 272 | 273 | func TestBypassAnnotationFalseWebhookHandler(t *testing.T) { 274 | rw := httptest.NewRecorder() 275 | 276 | testPod := &corev1.Pod{ 277 | ObjectMeta: v1.ObjectMeta{ 278 | Name: "test-pod", 279 | Namespace: "test-namespace", 280 | }, 281 | Spec: corev1.PodSpec{ 282 | Hostname: "test-pod.yahoo.com", 283 | }, 284 | } 285 | testNamespace := cloneNamespace(templateNamespace) 286 | testNamespace.Annotations = map[string]string{bypassAnnotationKey: "false"} 287 | clientset = fake.NewSimpleClientset(testPod, testNamespace) 288 | 289 | testSpec := cloneAdmissionReview(templateAdmReview) 290 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 291 | 292 | webhookHandler(rw, req) 293 | 294 | admReview := getAdmissionReview(rw) 295 | 296 | assert.False(t, admReview.Status.Allowed, "should reject if the namespace has pod resources and bypass annotation is set to false") 297 | assert.Contains(t, admReview.Status.Result.Reason, "The namespace test-namespace you are trying to remove contains one or more of these resources: [pods(1)]. Please delete them and try again.") 298 | } 299 | 300 | func TestEmptyNamespaceWebhookHandler(t *testing.T) { 301 | rw := httptest.NewRecorder() 302 | 303 | testNamespace := cloneNamespace(templateNamespace) 304 | clientset = fake.NewSimpleClientset(testNamespace) 305 | testSpec := cloneAdmissionReview(templateAdmReview) 306 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 307 | webhookHandler(rw, req) 308 | 309 | admReview := getAdmissionReview(rw) 310 | 311 | assert.True(t, admReview.Status.Allowed, "should approve if the namespace has no workload resources") 312 | } 313 | 314 | func TestNonEmptyNamespaceWebhookHandler(t *testing.T) { 315 | rw := httptest.NewRecorder() 316 | 317 | testPod := &corev1.Pod{ 318 | ObjectMeta: v1.ObjectMeta{ 319 | Name: "test-pod", 320 | Namespace: "test-namespace", 321 | }, 322 | Spec: corev1.PodSpec{ 323 | Hostname: "test-pod.yahoo.com", 324 | }, 325 | } 326 | testNamespace := cloneNamespace(templateNamespace) 327 | testSpec := cloneAdmissionReview(templateAdmReview) 328 | clientset = fake.NewSimpleClientset(testPod, testNamespace) 329 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 330 | webhookHandler(rw, req) 331 | 332 | admReview := getAdmissionReview(rw) 333 | 334 | assert.False(t, admReview.Status.Allowed, "should reject if the namespace has pod resources") 335 | assert.Contains(t, admReview.Status.Result.Reason, "The namespace test-namespace you are trying to remove contains one or more of these resources: [pods(1)]. Please delete them and try again.") 336 | } 337 | 338 | func TestNonEmptyNamespaceWithMoreResourcesWebhookHandler(t *testing.T) { 339 | rw := httptest.NewRecorder() 340 | 341 | testPod := &corev1.Pod{ 342 | ObjectMeta: v1.ObjectMeta{ 343 | Name: "test-pod", 344 | Namespace: "test-namespace", 345 | }, 346 | Spec: corev1.PodSpec{ 347 | Hostname: "test-pod.yahoo.com", 348 | }, 349 | } 350 | testSvc := &corev1.Service{ 351 | ObjectMeta: v1.ObjectMeta{ 352 | Name: "test-svc", 353 | Namespace: "test-namespace", 354 | }, 355 | Spec: corev1.ServiceSpec{ 356 | ExternalName: "test-svc.yahoo.com", 357 | }, 358 | } 359 | testReplicaSet := &extensionsv1beta1.ReplicaSet{ 360 | ObjectMeta: v1.ObjectMeta{ 361 | Name: "test-replicaset", 362 | Namespace: "test-namespace", 363 | }, 364 | Spec: extensionsv1beta1.ReplicaSetSpec{ 365 | Replicas: new(int32), 366 | }, 367 | } 368 | testDeployment := &appsv1beta1.Deployment{ 369 | ObjectMeta: v1.ObjectMeta{ 370 | Name: "test-deploy", 371 | Namespace: "test-namespace", 372 | }, 373 | Spec: appsv1beta1.DeploymentSpec{ 374 | Replicas: new(int32), 375 | }, 376 | } 377 | testStatefulSet := &appsv1beta1.StatefulSet{ 378 | ObjectMeta: v1.ObjectMeta{ 379 | Name: "test-statefulset", 380 | Namespace: "test-namespace", 381 | }, 382 | Spec: appsv1beta1.StatefulSetSpec{ 383 | Replicas: new(int32), 384 | }, 385 | } 386 | testDaemonSet := &extensionsv1beta1.DaemonSet{ 387 | ObjectMeta: v1.ObjectMeta{ 388 | Name: "test-daemonset", 389 | Namespace: "test-namespace", 390 | }, 391 | Spec: extensionsv1beta1.DaemonSetSpec{ 392 | RevisionHistoryLimit: new(int32), 393 | }, 394 | } 395 | testIngress := &extensionsv1beta1.Ingress{ 396 | ObjectMeta: v1.ObjectMeta{ 397 | Name: "test-ingress", 398 | Namespace: "test-namespace", 399 | }, 400 | Spec: extensionsv1beta1.IngressSpec{ 401 | Rules: []extensionsv1beta1.IngressRule{}, 402 | }, 403 | } 404 | testHpa := &autoscalingv1.HorizontalPodAutoscaler{ 405 | ObjectMeta: v1.ObjectMeta{ 406 | Name: "test-hpa", 407 | Namespace: "test-namespace", 408 | }, 409 | Spec: autoscalingv1.HorizontalPodAutoscalerSpec{ 410 | MinReplicas: new(int32), 411 | }, 412 | } 413 | testCm := &corev1.ConfigMap{ 414 | ObjectMeta: v1.ObjectMeta{ 415 | Name: "test-configmap", 416 | Namespace: "test-namespace", 417 | }, 418 | } 419 | testSecret := &corev1.Secret{ 420 | ObjectMeta: v1.ObjectMeta{ 421 | Name: "test-secret", 422 | Namespace: "test-namespace", 423 | }, 424 | } 425 | testNamespace := cloneNamespace(templateNamespace) 426 | testSpec := cloneAdmissionReview(templateAdmReview) 427 | clientset = fake.NewSimpleClientset(testNamespace, testPod, testSvc, testReplicaSet, testDeployment, testStatefulSet, testDaemonSet, testIngress, testHpa, testCm, testSecret) 428 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 429 | webhookHandler(rw, req) 430 | 431 | admReview := getAdmissionReview(rw) 432 | 433 | assert.False(t, admReview.Status.Allowed, "should reject if the namespace has workload resources") 434 | assert.Contains(t, admReview.Status.Result.Reason, "The namespace test-namespace you are trying to remove contains one or more of these resources: [pods(1) services(1) replicasets(1) deployments(1) statefulsets(1) daemonsets(1) ingresses(1) horizontalpodautoscalers(1)]. Please delete them and try again.") 435 | } 436 | 437 | func TestNonEmptyNamespaceWithIgnoredResourcesWebhookHandler(t *testing.T) { 438 | rw := httptest.NewRecorder() 439 | 440 | testCm := &corev1.ConfigMap{ 441 | ObjectMeta: v1.ObjectMeta{ 442 | Name: "test-configmap", 443 | Namespace: "test-namespace", 444 | }, 445 | } 446 | testSecret := &corev1.Secret{ 447 | ObjectMeta: v1.ObjectMeta{ 448 | Name: "test-secret", 449 | Namespace: "test-namespace", 450 | }, 451 | } 452 | testNamespace := cloneNamespace(templateNamespace) 453 | testSpec := cloneAdmissionReview(templateAdmReview) 454 | clientset = fake.NewSimpleClientset(testNamespace, testCm, testSecret) 455 | req := httptest.NewRequest("POST", "http://localhost:8080/", constructPostBody(testSpec)) 456 | webhookHandler(rw, req) 457 | 458 | admReview := getAdmissionReview(rw) 459 | 460 | assert.True(t, admReview.Status.Allowed, "should approve if the namespace has ignored resources") 461 | } 462 | 463 | func TestStatusHandler200(t *testing.T) { 464 | rw := httptest.NewRecorder() 465 | req := httptest.NewRequest("GET", "http://localhost:8080/status.html", nil) 466 | statusHandler(rw, req) 467 | assert.Equal(t, http.StatusOK, rw.Code, "/status.html should return 200") 468 | } 469 | --------------------------------------------------------------------------------