├── .gitignore ├── examples ├── hello-controller-golang │ ├── Dockerfile │ ├── manifests │ │ ├── hello.yaml │ │ └── controller.yaml │ ├── README.md │ └── main.go ├── hello-controller-exec │ ├── manifests │ │ ├── hello.yaml │ │ └── controller.yaml │ ├── config.yaml │ ├── Dockerfile │ ├── reconciler.sh │ └── README.md ├── containerset-controller │ ├── manifests │ │ ├── containerset-invalid.yaml │ │ ├── containerset.yaml │ │ └── controller.yaml │ ├── Dockerfile │ ├── config.yaml │ ├── mutator.py │ ├── validator.py │ ├── README.md │ └── reconciler.py ├── pod-observer │ ├── config.yaml │ ├── Dockerfile │ ├── observer.sh │ └── manifests │ │ └── observer.yaml └── issue-injector │ ├── config.yaml │ ├── Dockerfile │ ├── injector │ ├── verify-key.pem │ └── signing-key.pem │ ├── reconciler.py │ ├── README.md │ ├── injector.py │ └── manifests │ └── controller.yaml ├── reconciler ├── state │ ├── event.go │ ├── state.go │ └── state_test.go ├── reconciler.go └── reconciler_test.go ├── handler ├── handler.go ├── common │ └── common.go ├── exec │ └── exec.go └── http │ └── http.go ├── cmd ├── whitebox-gen │ ├── main.go │ ├── manifest_crd.go │ ├── manifest_mutation_webhook_config.go │ ├── manifest_validation_webhook_config.go │ ├── manifest_certificate.go │ ├── token.go │ ├── manifest.go │ └── manifest_controller.go └── whitebox-controller │ └── main.go ├── README.md ├── .circleci └── config.yml ├── go.mod ├── manager └── manager.go ├── Taskfile.yml ├── controller ├── syncer │ └── syncer.go └── controller.go ├── Dockerfile ├── webhook ├── injection │ └── injection.go └── webhook.go ├── docs ├── implementing-controller.md ├── configuration.md └── getting-started.md ├── config ├── config.go └── config_test.go └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | /whitebox-controller 4 | /whitebox-gen 5 | /release 6 | cover.out 7 | -------------------------------------------------------------------------------- /examples/hello-controller-golang/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | COPY hello-controller-golang /bin/hello-controller 4 | 5 | ENTRYPOINT ["/bin/hello-controller"] 6 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/manifests/hello.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: whitebox.summerwind.dev/v1alpha1 2 | kind: Hello 3 | metadata: 4 | name: hello 5 | spec: 6 | message: "Hello World" 7 | -------------------------------------------------------------------------------- /examples/hello-controller-golang/manifests/hello.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: whitebox.summerwind.dev/v1alpha1 2 | kind: Hello 3 | metadata: 4 | name: hello 5 | spec: 6 | message: "Hello World" 7 | -------------------------------------------------------------------------------- /examples/containerset-controller/manifests/containerset-invalid.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: whitebox.summerwind.dev/v1alpha1 2 | kind: ContainerSet 3 | metadata: 4 | name: containerset-example-invalid 5 | spec: 6 | replicas: 1 7 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/config.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - group: whitebox.summerwind.dev 3 | version: v1alpha1 4 | kind: Hello 5 | reconciler: 6 | exec: 7 | command: ./reconciler.sh 8 | debug: true 9 | -------------------------------------------------------------------------------- /examples/containerset-controller/manifests/containerset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: whitebox.summerwind.dev/v1alpha1 2 | kind: ContainerSet 3 | metadata: 4 | name: containerset-example 5 | spec: 6 | replicas: 2 7 | image: nginx:latest 8 | -------------------------------------------------------------------------------- /examples/pod-observer/config.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - group: "" 3 | version: v1 4 | kind: Pod 5 | reconciler: 6 | observe: true 7 | exec: 8 | command: ./observer.sh 9 | timeout: 60s 10 | debug: true 11 | -------------------------------------------------------------------------------- /examples/pod-observer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM summerwind/whitebox-controller:latest AS base 2 | 3 | ####################################### 4 | 5 | FROM ubuntu:18.04 6 | 7 | RUN apt update \ 8 | && apt install -y jq \ 9 | && rm -rf /var/lib/apt/lists/\* 10 | 11 | COPY --from=base /bin/whitebox-controller /bin/whitebox-controller 12 | 13 | COPY observer.sh /observer.sh 14 | COPY config.yaml /config.yaml 15 | 16 | ENTRYPOINT ["/bin/whitebox-controller"] 17 | -------------------------------------------------------------------------------- /reconciler/state/event.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import "errors" 4 | 5 | // Event represents an event of Kubernetes. 6 | type Event struct { 7 | Type string `json:"type"` 8 | Reason string `json:"reason"` 9 | Message string `json:"message"` 10 | } 11 | 12 | // Validate validates the content of event. 13 | func (e *Event) Validate() error { 14 | if e.Type == "" { 15 | return errors.New("type must be specified") 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM summerwind/whitebox-controller:latest AS base 2 | 3 | ####################################### 4 | 5 | FROM ubuntu:18.04 6 | 7 | RUN apt update \ 8 | && apt install -y jq \ 9 | && rm -rf /var/lib/apt/lists/\* 10 | 11 | COPY --from=base /bin/whitebox-controller /bin/whitebox-controller 12 | 13 | COPY reconciler.sh /reconciler.sh 14 | COPY config.yaml /config.yaml 15 | 16 | ENTRYPOINT ["/bin/whitebox-controller"] 17 | -------------------------------------------------------------------------------- /examples/issue-injector/config.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - group: whitebox.summerwind.dev 3 | version: v1alpha1 4 | kind: Issue 5 | reconciler: 6 | exec: 7 | command: ./reconciler.py 8 | debug: true 9 | injector: 10 | exec: 11 | command: ./injector.py 12 | debug: true 13 | verifyKeyFile: /etc/injector/verify.key 14 | 15 | webhook: 16 | port: 443 17 | tls: 18 | certFile: /etc/tls/tls.crt 19 | keyFile: /etc/tls/tls.key 20 | -------------------------------------------------------------------------------- /examples/issue-injector/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM summerwind/whitebox-controller:latest AS base 2 | 3 | ####################################### 4 | 5 | FROM ubuntu:18.04 6 | 7 | RUN apt update \ 8 | && apt install -y python3 \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | COPY --from=base /bin/whitebox-controller /bin/whitebox-controller 12 | 13 | COPY reconciler.py /reconciler.py 14 | COPY injector.py /injector.py 15 | COPY config.yaml /config.yaml 16 | 17 | ENTRYPOINT ["/bin/whitebox-controller"] 18 | -------------------------------------------------------------------------------- /examples/issue-injector/injector/verify-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1NtA2ylZptz+9rcl575x 3 | nh47QVO+Fg9dLuWN8vAnJ6B5beKhglid/h3b0HA7eWDZQFFHCauojUxdKTdjkJy1 4 | EceOmPr0CYjDY6XPiCFG9pFEeXa0orFhkezpv2VwUmWSLqcmuhdvyJ/VMfpWsH1a 5 | syACADgRuji0Mg82c89L64Joz3mbhkzbEhb8enJKK0TQ0wErrN47AjnpxwG4abz2 6 | thDtBRTaxMMvhBflFjLlwzAQKtywgNPfvmlLZbNBM27bq1dfCEwVlhx1Qt/3MER+ 7 | GxSskxQCRjeS2ZMq/9mqdZuNA4Bj6THNekE0aHj56q5KGhuu+ZcuQk63zshlAaEf 8 | tQIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /examples/issue-injector/reconciler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import logging 6 | 7 | def main(): 8 | state = json.load(sys.stdin) 9 | 10 | phase = state.get("object", {}).get("status", {}).get("phase", "") 11 | if phase == "": 12 | state["object"]["status"] = {"phase": "created"} 13 | 14 | json.dump(state, sys.stdout) 15 | 16 | if __name__ == "__main__": 17 | try: 18 | main() 19 | except Exception as e: 20 | logging.exception("%s", e) 21 | sys.exit(1) 22 | -------------------------------------------------------------------------------- /examples/containerset-controller/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM summerwind/whitebox-controller:latest AS base 2 | 3 | ####################################### 4 | 5 | FROM ubuntu:18.04 6 | 7 | RUN apt update \ 8 | && apt install -y python3 \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | COPY --from=base /bin/whitebox-controller /bin/whitebox-controller 12 | 13 | COPY reconciler.py /bin/reconciler 14 | COPY validator.py /bin/validator 15 | COPY mutator.py /bin/mutator 16 | COPY config.yaml /config.yaml 17 | 18 | ENTRYPOINT ["/bin/whitebox-controller"] 19 | -------------------------------------------------------------------------------- /examples/pod-observer/observer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Read current state from stdio. 4 | STATE=`cat -` 5 | 6 | # Get current phase of the Pod. 7 | POD_NAME=`echo "${STATE}" | jq -r '.object.metadata.name'` 8 | POD_NAMESPACE=`echo "${STATE}" | jq -r '.object.metadata.namespace'` 9 | POD_PHASE=`echo "${STATE}" | jq -r '.object.status.phase'` 10 | 11 | # Generate message 12 | MESSAGE="Pod ${POD_NAMESPACE}/${POD_NAME} is ${POD_PHASE}" 13 | 14 | # Write message to the log 15 | NOW=`date +%s` 16 | echo "${NOW}: ${MESSAGE}" >> pod.log 17 | 18 | # Write message to stder 19 | echo "${MESSAGE}" >&2 20 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/reconciler.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Read current state from stdio. 4 | STATE=`cat -` 5 | 6 | # Read phase from object. 7 | PHASE=`echo "${STATE}" | jq -r '.object.status.phase'` 8 | 9 | # Reconcile object. 10 | if [ "${PHASE}" != "completed" ]; then 11 | # Write message to stder. 12 | NOW=`date "+%Y/%m/%d %H:%M:%S"` 13 | echo -n "${NOW} message: " >&2 14 | echo "${STATE}" | jq -r '.object.spec.message' >&2 15 | 16 | # Set `.status.phase` field to the resource. 17 | STATE=`echo "${STATE}" | jq -r '.object.status.phase = "completed"'` 18 | fi 19 | 20 | # Write new state to stdio. 21 | echo "${STATE}" 22 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 5 | 6 | "github.com/summerwind/whitebox-controller/reconciler/state" 7 | "github.com/summerwind/whitebox-controller/webhook/injection" 8 | ) 9 | 10 | type Handler interface { 11 | Run(buf []byte) ([]byte, error) 12 | } 13 | 14 | type StateHandler interface { 15 | HandleState(*state.State) error 16 | } 17 | 18 | type AdmissionRequestHandler interface { 19 | HandleAdmissionRequest(admission.Request) (admission.Response, error) 20 | } 21 | 22 | type InjectionRequestHandler interface { 23 | HandleInjectionRequest(injection.Request) (injection.Response, error) 24 | } 25 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var usage = `usage: whitebox-gen [] 9 | 10 | Commands: 11 | manifest Generate manifest based on config file 12 | token Generate token for injection webhook 13 | ` 14 | 15 | func main() { 16 | var err error 17 | 18 | if len(os.Args) <= 1 { 19 | fmt.Print(usage) 20 | os.Exit(1) 21 | } 22 | 23 | switch os.Args[1] { 24 | case "manifest": 25 | err = manifest(os.Args[2:]) 26 | case "token": 27 | err = token(os.Args[2:]) 28 | default: 29 | fmt.Print(usage) 30 | os.Exit(1) 31 | } 32 | 33 | if err != nil { 34 | fmt.Fprintln(os.Stderr, err) 35 | os.Exit(1) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/containerset-controller/config.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - group: whitebox.summerwind.dev 3 | version: v1alpha1 4 | kind: ContainerSet 5 | dependents: 6 | - group: apps 7 | version: v1 8 | kind: Deployment 9 | reconciler: 10 | exec: 11 | command: /bin/reconciler 12 | timeout: 60s 13 | debug: true 14 | validator: 15 | exec: 16 | command: /bin/validator 17 | timeout: 60s 18 | debug: true 19 | mutator: 20 | exec: 21 | command: /bin/mutator 22 | timeout: 60s 23 | debug: true 24 | resyncPeriod: 30m 25 | 26 | webhook: 27 | port: 443 28 | tls: 29 | certFile: /etc/tls/tls.crt 30 | keyFile: /etc/tls/tls.key 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whitebox Controller 2 | 3 | Whitebox Controller is an extensible general purpose controller for Kubernetes. 4 | 5 | This controller performs reconciliation or validation of Kubernetes resources by executing external commands or sending HTTP requests to external URLs. This allows developers to implement the Kubernetes controller simply by providing an external command or HTTP endpoint. 6 | 7 | ## Motivation 8 | 9 | - Allow developers to make controllers without various knowledge of Kubernetes 10 | - Allow developers to implement controllers in their familiar programming languages 11 | - Enable quick validation of new controller ideas 12 | 13 | ## Documentation 14 | 15 | - [Getting Started](docs/getting-started.md) 16 | - [Configuration](docs/configuration.md) 17 | - [Implementing controller](docs/implementing-controller.md) 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: summerwind/toolbox:latest 6 | steps: 7 | - checkout 8 | - setup_remote_docker: 9 | version: 18.09.3 10 | - run: 11 | name: Build container 12 | command: task build-container 13 | release: 14 | docker: 15 | - image: summerwind/toolbox:latest 16 | steps: 17 | - checkout 18 | - setup_remote_docker: 19 | version: 18.09.3 20 | - run: 21 | name: Upload release files to GitHub 22 | command: task github-release 23 | 24 | workflows: 25 | version: 2 26 | main: 27 | jobs: 28 | - build 29 | release: 30 | jobs: 31 | - release: 32 | context: global 33 | filters: 34 | branches: 35 | ignore: /.*/ 36 | tags: 37 | only: /.*/ 38 | -------------------------------------------------------------------------------- /examples/containerset-controller/mutator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import base64 6 | import logging 7 | 8 | def main(): 9 | req = json.load(sys.stdin) 10 | 11 | replace = False 12 | patch = {} 13 | 14 | replicas = req.get("object", {}).get("spec", {}).get("replicas") 15 | if replicas is None: 16 | patch = {"op": "add", "path": "/spec/replicas", "value": 0} 17 | elif replicas == 0: 18 | patch = {"op": "replace", "path": "/spec/replicas", "value": 1} 19 | 20 | res = {"allowed": True} 21 | if len(patch) != 0: 22 | res["patchType"] = "JSONPatch" 23 | res["patch"] = base64.b64encode(json.dumps([patch]).encode('utf-8')).decode() 24 | 25 | json.dump(res, sys.stdout) 26 | 27 | if __name__ == "__main__": 28 | try: 29 | main() 30 | except Exception as e: 31 | logging.exception("%s", e) 32 | sys.exit(1) 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/summerwind/whitebox-controller 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/ghodss/yaml v1.0.0 8 | github.com/go-logr/logr v0.1.0 9 | github.com/go-logr/zapr v0.1.1 // indirect 10 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect 11 | github.com/imdario/mergo v0.3.7 // indirect 12 | github.com/onsi/gomega v1.5.0 13 | github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 // indirect 14 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 // indirect 15 | github.com/prometheus/procfs v0.0.0-20190315082738-e56f2e22fc76 // indirect 16 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect 17 | k8s.io/api v0.0.0-20190918155943-95b840bb6a1f 18 | k8s.io/apiextensions-apiserver v0.0.0-20190918161926-8f644eb6e783 19 | k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655 20 | k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90 21 | sigs.k8s.io/controller-runtime v0.4.0 22 | ) 23 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/README.md: -------------------------------------------------------------------------------- 1 | # hello-controller (Exec handler) 2 | 3 | This is an example of a controller that uses an exec handler. 4 | 5 | This controller reconciles Hello resource. When create a Hello resource, this controller outputs the value of message field and update status of the resource. 6 | 7 | ## Build 8 | 9 | Build a container image. 10 | 11 | ``` 12 | $ docker build -t summerwind/hello-controller:exec . 13 | ``` 14 | 15 | ## Deploy 16 | 17 | Create controller resources that includes CRD and Dployment. 18 | 19 | ``` 20 | $ kubectl apply -f manifests/controller.yaml 21 | ``` 22 | 23 | ## Test 24 | 25 | Create a Hello resource. 26 | 27 | ``` 28 | $ kubectl apply -f manifests/hello.yaml 29 | hello.whitebox.summerwind.dev/hello created 30 | ``` 31 | 32 | Verify that the Hello resource has been created. 33 | 34 | ``` 35 | $ kubectl get hello 36 | NAME AGE 37 | hello 10s 38 | ``` 39 | 40 | hello-controller outputs the following log: 41 | 42 | ``` 43 | $ kubectl logs -n kube-system hello-controller-b85467859-fk8s5 44 | ... 45 | [exec] stderr: 2019/07/13 11:20:01 message: Hello World 46 | ... 47 | ``` 48 | -------------------------------------------------------------------------------- /examples/containerset-controller/validator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import logging 6 | 7 | def main(): 8 | req = json.load(sys.stdin) 9 | 10 | allowed = True 11 | reason = "" 12 | 13 | replicas = req.get("object", {}).get("spec", {}).get("replicas") 14 | if replicas is None: 15 | allowed = False 16 | reason = "'spec.replicas' must be specified" 17 | elif replicas == 0: 18 | allowed = False 19 | reason = "'spec.replicas' must be non-zero value" 20 | 21 | image = req.get("object", {}).get("spec", {}).get("image") 22 | if image is None: 23 | allowed = False 24 | reason = "'spec.image' must be specified" 25 | elif image == "": 26 | allowed = False 27 | reason = "'spec.image' is empty" 28 | 29 | res = { 30 | "allowed": allowed, 31 | "status": { 32 | "reason": reason 33 | } 34 | } 35 | 36 | json.dump(res, sys.stdout) 37 | 38 | if __name__ == "__main__": 39 | try: 40 | main() 41 | except Exception as e: 42 | logging.exception("%s", e) 43 | sys.exit(1) 44 | -------------------------------------------------------------------------------- /examples/issue-injector/README.md: -------------------------------------------------------------------------------- 1 | # issue-injector 2 | 3 | This is an example of a controller with injector. 4 | 5 | This controller receives webhook request from GitHub and creates an Issue resource. 6 | 7 | ## Build 8 | 9 | Build a container image. 10 | 11 | ``` 12 | $ docker build -t summerwind/issue-injector:latest . 13 | ``` 14 | 15 | ## Deploy 16 | 17 | Create controller resources that includes CRD and Dployment. 18 | 19 | ``` 20 | $ kubectl apply -f manifests/controller.yaml 21 | ``` 22 | 23 | ## Test 24 | 25 | Generate an injection token. 26 | 27 | ``` 28 | $ whitebox-gen token -name test -namespace default -signing-key injector/signing-key.pem 29 | ``` 30 | 31 | Register the following webhook URL with the generated injection token to your GitHub repository. Note that you need to set the issue of event to be received by webhook. 32 | 33 | - `https://${SERVICE_URL}/whitebox.summerwind.dev/v1alpha1/issue/inject?token=${TOKEN}` 34 | 35 | If you create an issue on GitHub repository, an Issue resource will be created in your Kubernetes. 36 | 37 | ``` 38 | $ kubectl get issue 39 | NAME AGE 40 | whitebox-controller-10 27m 41 | ``` 42 | -------------------------------------------------------------------------------- /examples/pod-observer/manifests/observer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: pod-observer 5 | namespace: kube-system 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1 8 | kind: ClusterRole 9 | metadata: 10 | name: pod-observer 11 | rules: 12 | - apiGroups: 13 | - "" 14 | resources: 15 | - pods 16 | verbs: 17 | - get 18 | - list 19 | - watch 20 | --- 21 | apiVersion: rbac.authorization.k8s.io/v1 22 | kind: ClusterRoleBinding 23 | metadata: 24 | name: pod-observer 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: ClusterRole 28 | name: pod-observer 29 | subjects: 30 | - kind: ServiceAccount 31 | name: pod-observer 32 | namespace: kube-system 33 | --- 34 | apiVersion: apps/v1 35 | kind: Deployment 36 | metadata: 37 | name: pod-observer 38 | namespace: kube-system 39 | spec: 40 | replicas: 1 41 | selector: 42 | matchLabels: 43 | app: pod-observer 44 | template: 45 | metadata: 46 | labels: 47 | app: pod-observer 48 | spec: 49 | containers: 50 | - name: pod-observer 51 | image: summerwind/pod-observer:latest 52 | imagePullPolicy: IfNotPresent 53 | serviceAccountName: pod-observer 54 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest_crd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "strings" 7 | ) 8 | 9 | func genCRD(o *Option) ([]string, error) { 10 | crds := []string{} 11 | 12 | funcMap := template.FuncMap{ 13 | "toLower": strings.ToLower, 14 | } 15 | 16 | tmpl, err := template.New("").Funcs(funcMap).Parse(crdTemplate) 17 | if err != nil { 18 | return crds, err 19 | } 20 | 21 | for _, res := range o.Config.Resources { 22 | if !strings.Contains(res.Group, ".") || strings.HasSuffix(res.Group, ".k8s.io") { 23 | continue 24 | } 25 | 26 | buf := bytes.NewBuffer([]byte{}) 27 | err = tmpl.Execute(buf, res) 28 | if err != nil { 29 | return crds, err 30 | } 31 | 32 | crds = append(crds, strings.Trim(buf.String(), "\n")) 33 | } 34 | 35 | return crds, nil 36 | } 37 | 38 | var crdTemplate = ` 39 | apiVersion: apiextensions.k8s.io/v1beta1 40 | kind: CustomResourceDefinition 41 | metadata: 42 | name: {{ .Kind | toLower }}.{{ .Group }} 43 | spec: 44 | group: {{ .Group }} 45 | versions: 46 | - name: {{ .Version }} 47 | served: true 48 | storage: true 49 | names: 50 | kind: {{ .Kind }} 51 | plural: {{ .Kind | toLower }} 52 | singular: {{ .Kind | toLower }} 53 | scope: Namespaced 54 | ` 55 | -------------------------------------------------------------------------------- /examples/issue-injector/injector.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import logging 6 | 7 | def main(): 8 | req = json.load(sys.stdin) 9 | body = json.loads(req["body"]) 10 | 11 | res = {} 12 | if body["action"] == "opened": 13 | res["object"] = { 14 | "apiVersion": "whitebox.summerwind.dev/v1alpha1", 15 | "kind": "Issue", 16 | "metadata": { 17 | "name": "%s-%d" % (body["repository"]["name"], body["issue"]["number"]), 18 | }, 19 | "spec": { 20 | "title": body["issue"]["title"], 21 | "url": body["issue"]["html_url"], 22 | "user": { 23 | "login": body["issue"]["user"]["login"], 24 | "url": body["issue"]["user"]["html_url"] 25 | }, 26 | "repository": { 27 | "name": body["repository"]["full_name"], 28 | "url": body["repository"]["html_url"] 29 | } 30 | } 31 | } 32 | 33 | json.dump(res, sys.stdout) 34 | 35 | if __name__ == "__main__": 36 | try: 37 | main() 38 | except Exception as e: 39 | logging.exception("%s", e) 40 | sys.exit(1) 41 | -------------------------------------------------------------------------------- /examples/hello-controller-golang/README.md: -------------------------------------------------------------------------------- 1 | # hello-controller (Golang implementation) 2 | 3 | This is an golang implementation of a controller intended to demonstrate how to use whitebox-controller as golang library. 4 | 5 | This controller reconciles Hello resource. When create a Hello resource, this controller outputs the value of message field and update status of the resource. 6 | 7 | ## Build 8 | 9 | Build a binary and a container image. 10 | 11 | ``` 12 | $ CGO_ENABLED=0 GOOS=linux go build -o hello-controller . 13 | $ docker build -t summerwind/hello-controller:golang . 14 | ``` 15 | 16 | ## Deploy 17 | 18 | Create controller resources that includes CRD, WebhookConfiguration, and Dployment. 19 | 20 | ``` 21 | $ kubectl apply -f manifests/controller.yaml 22 | ``` 23 | 24 | ## Test 25 | 26 | Create a Hello resource. 27 | 28 | ``` 29 | $ kubectl apply -f manifests/hello.yaml 30 | hello.whitebox.summerwind.dev/hello created 31 | ``` 32 | 33 | Verify that the Hello resource has been created. 34 | 35 | ``` 36 | $ kubectl get hello 37 | NAME AGE 38 | hello 10s 39 | ``` 40 | 41 | hello-controller outputs the following log: 42 | 43 | ``` 44 | $ kubectl logs -n kube-system hello-controller-b85467859-fk8s5 45 | ... 46 | 2019/07/13 11:09:08 message: Hello World 47 | ... 48 | ``` 49 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/client-go/rest" 7 | "sigs.k8s.io/controller-runtime/pkg/manager" 8 | 9 | "github.com/summerwind/whitebox-controller/config" 10 | "github.com/summerwind/whitebox-controller/controller" 11 | "github.com/summerwind/whitebox-controller/webhook" 12 | ) 13 | 14 | func New(c *config.Config, kc *rest.Config) (manager.Manager, error) { 15 | err := c.Validate() 16 | if err != nil { 17 | return nil, fmt.Errorf("invalid configuration: %v", err) 18 | } 19 | 20 | mgr, err := manager.New(kc, manager.Options{}) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | wh := false 26 | for _, r := range c.Resources { 27 | if r.Reconciler != nil { 28 | _, err := controller.New(r, mgr) 29 | if err != nil { 30 | return nil, err 31 | } 32 | } 33 | 34 | if r.Validator != nil || r.Mutator != nil || r.Injector != nil { 35 | wh = true 36 | } 37 | } 38 | 39 | if wh { 40 | server, err := webhook.NewServer(c.Webhook, mgr) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | for _, r := range c.Resources { 46 | if r.Validator != nil { 47 | server.AddValidator(r) 48 | } 49 | 50 | if r.Mutator != nil { 51 | server.AddMutator(r) 52 | } 53 | 54 | if r.Injector != nil { 55 | server.AddInjector(r) 56 | } 57 | } 58 | } 59 | 60 | return mgr, nil 61 | } 62 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest_mutation_webhook_config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "strings" 7 | ) 8 | 9 | func genMutatingWebhookConfig(o *Option) (string, error) { 10 | funcMap := template.FuncMap{ 11 | "toLower": strings.ToLower, 12 | } 13 | 14 | tmpl, err := template.New("").Funcs(funcMap).Parse(mutatingTemplate) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | buf := bytes.NewBuffer([]byte{}) 20 | err = tmpl.Execute(buf, o) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | return strings.Trim(buf.String(), "\n"), nil 26 | } 27 | 28 | var mutatingTemplate = ` 29 | {{ $name := .Name -}} 30 | {{ $namespace := .Namespace -}} 31 | apiVersion: admissionregistration.k8s.io/v1beta1 32 | kind: MutatingWebhookConfiguration 33 | metadata: 34 | name: {{ .Name }} 35 | annotations: 36 | certmanager.k8s.io/inject-ca-from: {{ .Namespace }}/{{ .Name }} 37 | webhooks: 38 | {{ range .Config.Resources -}} 39 | {{ if .Mutator -}} 40 | - name: {{ .Kind | toLower }}.{{ .Group }} 41 | rules: 42 | - apiGroups: 43 | - {{ .Group }} 44 | apiVersions: 45 | - {{ .Version }} 46 | resources: 47 | - {{ .Kind | toLower }} 48 | operations: 49 | - CREATE 50 | - UPDATE 51 | failurePolicy: Fail 52 | clientConfig: 53 | service: 54 | name: {{ $name }} 55 | namespace: {{ $namespace }} 56 | path: /{{ .Group }}/{{ .Version }}/{{ .Kind | toLower }}/mutate 57 | caBundle: "" 58 | {{ end -}} 59 | {{ end -}} 60 | ` 61 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest_validation_webhook_config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "strings" 7 | ) 8 | 9 | func genValidationWebhookConfig(o *Option) (string, error) { 10 | funcMap := template.FuncMap{ 11 | "toLower": strings.ToLower, 12 | } 13 | 14 | tmpl, err := template.New("").Funcs(funcMap).Parse(validationTemplate) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | buf := bytes.NewBuffer([]byte{}) 20 | err = tmpl.Execute(buf, o) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | return strings.Trim(buf.String(), "\n"), nil 26 | } 27 | 28 | var validationTemplate = ` 29 | {{ $name := .Name -}} 30 | {{ $namespace := .Namespace -}} 31 | apiVersion: admissionregistration.k8s.io/v1beta1 32 | kind: ValidatingWebhookConfiguration 33 | metadata: 34 | name: {{ .Name }} 35 | annotations: 36 | certmanager.k8s.io/inject-ca-from: {{ .Namespace }}/{{ .Name }} 37 | webhooks: 38 | {{ range .Config.Resources -}} 39 | {{ if .Validator -}} 40 | - name: {{ .Kind | toLower }}.{{ .Group }} 41 | rules: 42 | - apiGroups: 43 | - {{ .Group }} 44 | apiVersions: 45 | - {{ .Version }} 46 | resources: 47 | - {{ .Kind | toLower }} 48 | operations: 49 | - CREATE 50 | - UPDATE 51 | failurePolicy: Fail 52 | clientConfig: 53 | service: 54 | name: {{ $name }} 55 | namespace: {{ $namespace }} 56 | path: /{{ .Group }}/{{ .Version }}/{{ .Kind | toLower }}/validate 57 | caBundle: "" 58 | {{ end -}} 59 | {{ end -}} 60 | ` 61 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | vars: 4 | NAME: whitebox-controller 5 | VERSION: 0.7.1 6 | COMMIT: {sh: git rev-parse --verify HEAD} 7 | BUILD_FLAGS: -ldflags "-X main.VERSION={{.VERSION}} -X main.COMMIT={{.COMMIT}}" 8 | 9 | tasks: 10 | build: 11 | deps: [test] 12 | cmds: 13 | - CGO_ENABLED=0 go build {{.BUILD_FLAGS}} ./cmd/whitebox-controller 14 | - CGO_ENABLED=0 go build {{.BUILD_FLAGS}} ./cmd/whitebox-gen 15 | test: 16 | cmds: 17 | - go vet ./... 18 | - go test -v -coverprofile=cover.out ./... 19 | cover: 20 | deps: [test] 21 | cmds: 22 | - go tool cover -html=cover.out 23 | release: 24 | cmds: 25 | - docker build --build-arg VERSION={{.VERSION}} --build-arg COMMIT={{.COMMIT}} --target release -t summerwind/{{.NAME}}:release . 26 | - docker create --name {{.NAME}}-release summerwind/{{.NAME}}:release 27 | - docker cp {{.NAME}}-release:/workspace/release release 28 | - docker rm {{.NAME}}-release 29 | github-release: 30 | deps: [release] 31 | cmds: 32 | - ghr v{{.VERSION}} release/ 33 | clean: 34 | cmds: 35 | - rm -rf whitebox-controller whitebox-gen cover.out release 36 | build-container: 37 | cmds: 38 | - docker build --build-arg VERSION={{.VERSION}} --build-arg COMMIT={{.COMMIT}} -t summerwind/{{.NAME}}:latest -t summerwind/{{.NAME}}:{{.VERSION}} . 39 | push-container: 40 | cmds: 41 | - docker push summerwind/{{.NAME}}:latest 42 | push-release-container: 43 | cmds: 44 | - docker push summerwind/{{.NAME}}:{{.VERSION}} 45 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest_certificate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "text/template" 7 | ) 8 | 9 | func genCertificate(o *Option) (string, error) { 10 | tmpl, err := template.New("").Parse(certificateTemplate) 11 | if err != nil { 12 | return "", err 13 | } 14 | 15 | buf := bytes.NewBuffer([]byte{}) 16 | err = tmpl.Execute(buf, o) 17 | if err != nil { 18 | return "", err 19 | } 20 | 21 | return strings.Trim(buf.String(), "\n"), nil 22 | } 23 | 24 | var certificateTemplate = ` 25 | apiVersion: certmanager.k8s.io/v1alpha1 26 | kind: Issuer 27 | metadata: 28 | name: {{ .Name }}-selfsign 29 | namespace: {{ .Namespace }} 30 | spec: 31 | selfSigned: {} 32 | --- 33 | apiVersion: certmanager.k8s.io/v1alpha1 34 | kind: Certificate 35 | metadata: 36 | name: {{ .Name }}-webhook-ca 37 | namespace: {{ .Namespace }} 38 | spec: 39 | secretName: {{ .Name }}-webhook-ca 40 | issuerRef: 41 | name: {{ .Name }}-selfsign 42 | commonName: "{{ .Name }} webhook CA" 43 | duration: 43800h 44 | isCA: true 45 | --- 46 | apiVersion: certmanager.k8s.io/v1alpha1 47 | kind: Issuer 48 | metadata: 49 | name: {{ .Name }}-webhook-ca 50 | namespace: {{ .Namespace }} 51 | spec: 52 | ca: 53 | secretName: {{ .Name }}-webhook-ca 54 | --- 55 | apiVersion: certmanager.k8s.io/v1alpha1 56 | kind: Certificate 57 | metadata: 58 | name: {{ .Name }} 59 | namespace: {{ .Namespace }} 60 | spec: 61 | secretName: {{ .Name }} 62 | issuerRef: 63 | name: {{ .Name }}-webhook-ca 64 | dnsNames: 65 | - {{ .Name }} 66 | - {{ .Name }}.{{ .Namespace }} 67 | - {{ .Name }}.{{ .Namespace }}.svc 68 | duration: 8760h 69 | ` 70 | -------------------------------------------------------------------------------- /cmd/whitebox-controller/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | kconfig "sigs.k8s.io/controller-runtime/pkg/client/config" 9 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 10 | "sigs.k8s.io/controller-runtime/pkg/runtime/signals" 11 | 12 | _ "k8s.io/client-go/plugin/pkg/client/auth/azure" 13 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 14 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 15 | 16 | "github.com/summerwind/whitebox-controller/config" 17 | "github.com/summerwind/whitebox-controller/manager" 18 | ) 19 | 20 | var ( 21 | VERSION string = "dev" 22 | COMMIT string = "HEAD" 23 | ) 24 | 25 | func main() { 26 | logf.SetLogger(logf.ZapLogger(false)) 27 | log := logf.Log.WithName("whitebox-controller") 28 | 29 | var ( 30 | configPath = flag.String("c", "config.yaml", "Path to configuration file") 31 | version = flag.Bool("version", false, "Display version information and exit") 32 | ) 33 | 34 | flag.Parse() 35 | 36 | if *version { 37 | fmt.Printf("%s (%s)\n", VERSION, COMMIT) 38 | return 39 | } 40 | 41 | c, err := config.LoadFile(*configPath) 42 | if err != nil { 43 | log.Error(err, "could not load configuration file") 44 | os.Exit(1) 45 | } 46 | 47 | kc, err := kconfig.GetConfig() 48 | if err != nil { 49 | log.Error(err, "could not load kubernetes configuration") 50 | os.Exit(1) 51 | } 52 | 53 | mgr, err := manager.New(c, kc) 54 | if err != nil { 55 | log.Error(err, "could not create controller manager") 56 | os.Exit(1) 57 | } 58 | 59 | err = mgr.Start(signals.SetupSignalHandler()) 60 | if err != nil { 61 | log.Error(err, "could not start controller manager") 62 | os.Exit(1) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/issue-injector/injector/signing-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA1NtA2ylZptz+9rcl575xnh47QVO+Fg9dLuWN8vAnJ6B5beKh 3 | glid/h3b0HA7eWDZQFFHCauojUxdKTdjkJy1EceOmPr0CYjDY6XPiCFG9pFEeXa0 4 | orFhkezpv2VwUmWSLqcmuhdvyJ/VMfpWsH1asyACADgRuji0Mg82c89L64Joz3mb 5 | hkzbEhb8enJKK0TQ0wErrN47AjnpxwG4abz2thDtBRTaxMMvhBflFjLlwzAQKtyw 6 | gNPfvmlLZbNBM27bq1dfCEwVlhx1Qt/3MER+GxSskxQCRjeS2ZMq/9mqdZuNA4Bj 7 | 6THNekE0aHj56q5KGhuu+ZcuQk63zshlAaEftQIDAQABAoIBACY27t/p/pR8nLHC 8 | k+WVRWSz3MOPu1LOk1Y2FFQHVaBBqUtXItP6APN8fNhhLexOvPJVJUHRNcOYcClU 9 | LmXqHIACqdFBTMrhaOLdA/NWthzSW87KxwdmfTPrqtOX7KB+Z85EaCmEx6bnOylr 10 | 3mB1QPFjz7gmWNhsEb3jCU2la+XuAdnwia/dLt4hvhZVV0gQaqCr/KTFvDlreEKG 11 | SyA0rYDBWvckdfPyiqObbIha6yiI3JpGS+sGIq1dHlL6+DcvgYzvjfVr+f3ekiQq 12 | 0zQ8moA2K7PLOAPbYFmiQwhDg5api/bzwkDQKXGCw13lKQ7qsJMCHufXPwbVoqQy 13 | BHhrdEECgYEA90UVsXXrWVeo3ix8zFk+15V0xMbRx0WwCL3eiyRPbTZ689BJjv/E 14 | uUM+bR3pg1PfsJQ+kXmswThBjI6mun0S0S0ZkCB4jwQ5BM1I+mYEsTh2SJnicLZT 15 | veff2IPwQa89KaxXxcAKab3jia29aJSZmkUydPKJOstnYARA4h+SVM0CgYEA3F8g 16 | sQG9p/MS3Lm3DjeB486ohxVXaDksPcRIyBZkZ74vgE9k98a5TbGjuWvrJCIHzwyy 17 | yWef960Ezz49JnQMZGLNhP7Y5As9eNd6Xj2zAzfOkFDxmvtBV2yeHaUm/Ix4AT8O 18 | qBHdO+0vemnIbW888xG67ajA9CixQ9JCviQotokCgYEAjbpvYLQyHZd9FztwnMBa 19 | oL7tmwxKmWl8KMaLlD1PuU2M08sDHo/DdlzwnyKSAdBFWrTd2Axh1K4mBCaPsjyW 20 | WIb9K/swnujQtcvklIF9FwdB5QMvI2OeRC9vwm59e2iyKfy6ooIcddd9YCDRe+wQ 21 | zrfy381UkvqbWhGWDwYBndECgYAG0N5m+yHNjwQXVZsm0mqvDBZET1GrxJDsvCRw 22 | I9kpKEW51zrNOxVCJOtmccMNFAxfyAnDlvPoqi5K5qbniJFVjYTDx0oYC4z0Lt33 23 | Cjt+Lvyxk/9VBs5nkNF3I0OIbiPMDhI6+op4LwbX52uujtE4x2Sbsyt8ocGR0nAE 24 | B4RG2QKBgGixh0WZmzNoNa8MJ2wps+lR1+T7icfdlnnEdbyYiLxGLJ5MtobUR1ii 25 | RjZFwyty+sr3gZVYbahRsdtIeMNi26lOt5F7VLUrDRyO1OvyxgYWJW4JscqSW9q9 26 | zmoUnxhWpDdhr8Ipq4t80Wug0uXtTT/Mb22PYqk5iGmDe6cOQVrz 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /examples/containerset-controller/README.md: -------------------------------------------------------------------------------- 1 | # containerset-controller 2 | 3 | containerset-controller is an example controller that watches ContainerSet resource and creates Deployment resource. 4 | 5 | This controller consists of three python scripts: 6 | 7 | - `reconciler.py`: The script that reconcile resource state. 8 | - `validator.py`: The script that validate created or updated resource via Admission Webhook. 9 | - `mutator.py`: The script that defaulting a resource via Admission Webhook. 10 | 11 | These scripts are assigned to the controller in the configuration file. See `config.yaml` for more details. 12 | 13 | ## Build 14 | 15 | ``` 16 | $ docker build -t containerset-controller:latest . 17 | ``` 18 | 19 | ## Deploy 20 | 21 | ``` 22 | $ kubectl apply -f manifests/controller.yaml 23 | ``` 24 | 25 | ## Test 26 | 27 | Create a ContainerSet resource. 28 | 29 | ``` 30 | $ kubectl apply -f manifests/containerset.yaml 31 | containerset.whitebox.summerwind.dev "containerset-example" created 32 | ``` 33 | 34 | Verify that the ContainerSet resource has been created. 35 | 36 | ``` 37 | $ kubectl get containerset 38 | NAME AGE 39 | containerset-example 10s 40 | ``` 41 | 42 | The deployment resource also have been created by the containerset-controller. 43 | 44 | ``` 45 | $ kubectl get deployment containerset-example 46 | NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE 47 | containerset-example 2 2 2 2 1m 48 | ``` 49 | 50 | Invalid ContainerSet resource will be rejected by the admission webhook. 51 | 52 | ``` 53 | $ kubectl apply -f manifests/containerset-invalid.yaml 54 | Error from server ('spec.image' must be specified): error when creating "manifests/containerset-invalid.yaml": admission webhook "containerset.whitebox.summerwind.github.io" denied the request: 'spec.image' must be specified 55 | ``` 56 | 57 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/token.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/pem" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io/ioutil" 9 | "time" 10 | 11 | jwt "github.com/dgrijalva/jwt-go" 12 | ) 13 | 14 | func token(args []string) error { 15 | var ( 16 | key interface{} 17 | err error 18 | method jwt.SigningMethod 19 | ) 20 | 21 | cmd := flag.NewFlagSet("token", flag.ExitOnError) 22 | name := cmd.String("name", "", "Name of the token") 23 | namespace := cmd.String("namespace", "", "Namespace to inject resource") 24 | signingKeyPath := cmd.String("signing-key", "", "Path to PEM encoded signing key file") 25 | 26 | cmd.Parse(args) 27 | 28 | if *name == "" { 29 | return errors.New("-name must be specified") 30 | } 31 | if *namespace == "" { 32 | return errors.New("-namespace must be specified") 33 | } 34 | if *signingKeyPath == "" { 35 | return errors.New("-signing-key must be specified") 36 | } 37 | 38 | buf, err := ioutil.ReadFile(*signingKeyPath) 39 | if err != nil { 40 | return fmt.Errorf("failed to read signing key: %v", err) 41 | } 42 | 43 | block, _ := pem.Decode(buf) 44 | switch block.Type { 45 | case "RSA PRIVATE KEY": 46 | method = jwt.SigningMethodRS256 47 | key, err = jwt.ParseRSAPrivateKeyFromPEM(buf) 48 | case "EC PRIVATE KEY": 49 | method = jwt.SigningMethodES256 50 | key, err = jwt.ParseECPrivateKeyFromPEM(buf) 51 | default: 52 | err = fmt.Errorf("unsupported signing key type: %v", block.Type) 53 | } 54 | 55 | if err != nil { 56 | return fmt.Errorf("failed to parse signing key: %v", err) 57 | } 58 | 59 | token := jwt.NewWithClaims(method, jwt.MapClaims{ 60 | "name": *name, 61 | "namespace": *namespace, 62 | "iat": time.Now().Unix(), 63 | }) 64 | 65 | t, err := token.SignedString(key) 66 | if err != nil { 67 | return fmt.Errorf("failed to sign token: %v", err) 68 | } 69 | 70 | fmt.Println(t) 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /controller/syncer/syncer.go: -------------------------------------------------------------------------------- 1 | package syncer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/controller-runtime/pkg/event" 13 | "sigs.k8s.io/controller-runtime/pkg/manager" 14 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 15 | 16 | "github.com/summerwind/whitebox-controller/config" 17 | ) 18 | 19 | var log = logf.Log.WithName("syncer") 20 | 21 | type Syncer struct { 22 | client.Client 23 | C chan event.GenericEvent 24 | config *config.ResourceConfig 25 | interval time.Duration 26 | } 27 | 28 | func New(c *config.ResourceConfig, mgr manager.Manager) (*Syncer, error) { 29 | interval, err := time.ParseDuration(c.ResyncPeriod) 30 | if err != nil { 31 | return nil, fmt.Errorf("invalid resync period: %v", err) 32 | } 33 | 34 | s := &Syncer{ 35 | Client: mgr.GetClient(), 36 | C: make(chan event.GenericEvent), 37 | config: c, 38 | interval: interval, 39 | } 40 | 41 | return s, mgr.Add(s) 42 | } 43 | 44 | func (s *Syncer) Start(stop <-chan struct{}) error { 45 | t := time.NewTicker(s.interval) 46 | 47 | name := fmt.Sprintf("%s-controller", strings.ToLower(s.config.Kind)) 48 | 49 | for { 50 | select { 51 | case <-t.C: 52 | err := s.Sync() 53 | if err != nil { 54 | log.Error(err, "Sync error", "syncer", name) 55 | } 56 | log.Info("Synced", "syncer", name) 57 | case <-stop: 58 | log.Info("Stopping syncer", "syncer", name) 59 | return nil 60 | } 61 | } 62 | } 63 | 64 | func (s *Syncer) Sync() error { 65 | instanceList := &unstructured.UnstructuredList{} 66 | instanceList.SetGroupVersionKind(s.config.GroupVersionKind) 67 | 68 | err := s.List(context.TODO(), instanceList) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | for _, instance := range instanceList.Items { 74 | s.C <- event.GenericEvent{ 75 | Meta: &metav1.ObjectMeta{ 76 | Name: instance.GetName(), 77 | Namespace: instance.GetNamespace(), 78 | }, 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | "sigs.k8s.io/controller-runtime/pkg/controller" 9 | "sigs.k8s.io/controller-runtime/pkg/handler" 10 | "sigs.k8s.io/controller-runtime/pkg/manager" 11 | "sigs.k8s.io/controller-runtime/pkg/source" 12 | 13 | "github.com/summerwind/whitebox-controller/config" 14 | "github.com/summerwind/whitebox-controller/controller/syncer" 15 | "github.com/summerwind/whitebox-controller/reconciler" 16 | ) 17 | 18 | func New(c *config.ResourceConfig, mgr manager.Manager) (*controller.Controller, error) { 19 | var ( 20 | r *reconciler.Reconciler 21 | err error 22 | ) 23 | 24 | name := fmt.Sprintf("%s-controller", strings.ToLower(c.Kind)) 25 | 26 | r, err = reconciler.New(c, mgr.GetEventRecorderFor(name)) 27 | if err != nil { 28 | return nil, fmt.Errorf("could not create reconciler: %v", err) 29 | } 30 | 31 | ctrl, err := controller.New(name, mgr, controller.Options{Reconciler: r}) 32 | if err != nil { 33 | return nil, fmt.Errorf("could not create controller: %v", err) 34 | } 35 | 36 | obj := &unstructured.Unstructured{} 37 | obj.SetGroupVersionKind(c.GroupVersionKind) 38 | 39 | err = ctrl.Watch(&source.Kind{Type: obj}, &handler.EnqueueRequestForObject{}) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to watch resource: %v", err) 42 | } 43 | 44 | // No need to setup deps and syncer for observer. 45 | if r.IsObserver() { 46 | return &ctrl, nil 47 | } 48 | 49 | for _, dep := range c.Dependents { 50 | depObj := &unstructured.Unstructured{} 51 | depObj.SetGroupVersionKind(dep.GroupVersionKind) 52 | 53 | err = ctrl.Watch(&source.Kind{Type: depObj}, &handler.EnqueueRequestForOwner{ 54 | IsController: true, 55 | OwnerType: obj, 56 | }) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to watch dependent resource: %v", err) 59 | } 60 | } 61 | 62 | if c.ResyncPeriod != "" { 63 | s, err := syncer.New(c, mgr) 64 | if err != nil { 65 | return nil, fmt.Errorf("could not create syncer: %v", err) 66 | } 67 | 68 | err = ctrl.Watch(&source.Channel{Source: s.C}, &handler.EnqueueRequestForObject{}) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to watch sync channel: %v", err) 71 | } 72 | } 73 | 74 | return &ctrl, nil 75 | } 76 | -------------------------------------------------------------------------------- /handler/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/summerwind/whitebox-controller/config" 8 | "github.com/summerwind/whitebox-controller/handler" 9 | "github.com/summerwind/whitebox-controller/handler/exec" 10 | "github.com/summerwind/whitebox-controller/handler/http" 11 | ) 12 | 13 | // The name of environment variable to enable debug log. 14 | const debugEnvVar = "WHITEBOX_DEBUG" 15 | 16 | var errNoHandler = errors.New("no handler found") 17 | 18 | // NewStateHandler returns StateHandler based on specified HandlerConfig. 19 | func NewStateHandler(c *config.HandlerConfig) (handler.StateHandler, error) { 20 | var debug bool 21 | 22 | if c.StateHandler != nil { 23 | return c.StateHandler, nil 24 | } 25 | 26 | if os.Getenv(debugEnvVar) != "" { 27 | debug = true 28 | } 29 | 30 | if c.Exec != nil { 31 | c.Exec.Debug = (c.Exec.Debug || debug) 32 | return exec.New(c.Exec) 33 | } 34 | 35 | if c.HTTP != nil { 36 | c.HTTP.Debug = (c.HTTP.Debug || debug) 37 | return http.New(c.HTTP) 38 | } 39 | 40 | return nil, errNoHandler 41 | } 42 | 43 | // NewAdmissionRequestHandler returns AdmissionRequestHandler based on specified HandlerConfig. 44 | func NewAdmissionRequestHandler(c *config.HandlerConfig) (handler.AdmissionRequestHandler, error) { 45 | var debug bool 46 | 47 | if c.AdmissionRequestHandler != nil { 48 | return c.AdmissionRequestHandler, nil 49 | } 50 | 51 | if os.Getenv(debugEnvVar) != "" { 52 | debug = true 53 | } 54 | 55 | if c.Exec != nil { 56 | c.Exec.Debug = (c.Exec.Debug || debug) 57 | return exec.New(c.Exec) 58 | } 59 | 60 | if c.HTTP != nil { 61 | c.HTTP.Debug = (c.HTTP.Debug || debug) 62 | return http.New(c.HTTP) 63 | } 64 | 65 | return nil, errNoHandler 66 | } 67 | 68 | // NewInjectionRequestHandler returns InjectionRequestHandler based on specified HandlerConfig. 69 | func NewInjectionRequestHandler(c *config.HandlerConfig) (handler.InjectionRequestHandler, error) { 70 | var debug bool 71 | 72 | if c.InjectionRequestHandler != nil { 73 | return c.InjectionRequestHandler, nil 74 | } 75 | 76 | if os.Getenv(debugEnvVar) != "" { 77 | debug = true 78 | } 79 | 80 | if c.Exec != nil { 81 | c.Exec.Debug = (c.Exec.Debug || debug) 82 | return exec.New(c.Exec) 83 | } 84 | 85 | if c.HTTP != nil { 86 | c.HTTP.Debug = (c.HTTP.Debug || debug) 87 | return http.New(c.HTTP) 88 | } 89 | 90 | return nil, errNoHandler 91 | } 92 | -------------------------------------------------------------------------------- /examples/containerset-controller/reconciler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import json 5 | import logging 6 | 7 | def main(): 8 | state = json.load(sys.stdin) 9 | 10 | deps_len = len(state["dependents"]["deployment.v1.apps"]) 11 | if deps_len > 1: 12 | logging.error("too many dependents") 13 | sys.exit(1) 14 | 15 | name = state["object"]["metadata"]["name"] 16 | namespace = state["object"]["metadata"]["namespace"] 17 | replicas = state["object"]["spec"]["replicas"] 18 | image = state["object"]["spec"]["image"] 19 | 20 | if deps_len == 0: 21 | state["dependents"]["deployment.v1.apps"] = [ 22 | { 23 | "apiVersion": "apps/v1", 24 | "kind": "Deployment", 25 | "metadata": { 26 | "name": name, 27 | "namespace": namespace 28 | }, 29 | "spec": { 30 | "replicas": replicas, 31 | "selector": { 32 | "matchLabels": { 33 | "containerset": name 34 | } 35 | }, 36 | "template": { 37 | "metadata": { 38 | "labels": { 39 | "containerset": name 40 | } 41 | }, 42 | "spec": { 43 | "containers": [ 44 | { 45 | "name": name, 46 | "image": image, 47 | } 48 | ] 49 | } 50 | } 51 | } 52 | } 53 | ] 54 | 55 | state["events"] = [ 56 | {"type":"Normal", "reason":"CreateDeplpyment", "message":"deployment created"} 57 | ] 58 | else: 59 | deploy = state["dependents"]["deployment.v1.apps"][0] 60 | deploy["spec"]["replicas"] = replicas 61 | deploy["spec"]["template"]["spec"]["containers"][0]["image"] = image 62 | 63 | state["dependents"]["deployment.v1.apps"][0] = deploy 64 | 65 | availableReplicas = deploy.get("status", {}).get("availableReplicas", 0) 66 | state["object"]["status"] = {"healthyReplicas": availableReplicas} 67 | 68 | json.dump(state, sys.stdout) 69 | 70 | if __name__ == "__main__": 71 | try: 72 | main() 73 | except Exception as e: 74 | logging.exception("%s", e) 75 | sys.exit(1) 76 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.12 AS build 2 | 3 | ENV GO111MODULE=on \ 4 | GOPROXY=https://proxy.golang.org 5 | 6 | RUN curl -L -o /tmp/download-binaries.sh https://raw.githubusercontent.com/kubernetes-sigs/testing_frameworks/master/integration/scripts/download-binaries.sh \ 7 | && chmod +x /tmp/download-binaries.sh \ 8 | && mkdir -p /usr/local/kubebuilder/bin \ 9 | && /tmp/download-binaries.sh /usr/local/kubebuilder/bin 10 | 11 | WORKDIR /go/src/github.com/summerwind/whitebox-controller 12 | COPY go.mod go.sum ./ 13 | RUN go mod download 14 | 15 | COPY . /workspace 16 | WORKDIR /workspace 17 | 18 | ARG VERSION 19 | ARG COMMIT 20 | 21 | RUN go vet ./... 22 | RUN go test -v ./... 23 | RUN CGO_ENABLED=0 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-controller 24 | RUN CGO_ENABLED=0 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-gen 25 | 26 | ################### 27 | 28 | FROM build AS release 29 | 30 | ARG VERSION 31 | ARG COMMIT 32 | 33 | RUN mkdir release 34 | 35 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-controller \ 36 | && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-gen \ 37 | && tar zcf release/whitebox-controller-linux-amd64.tar.gz whitebox-controller whitebox-gen 38 | 39 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-controller \ 40 | && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-gen \ 41 | && tar zcf release/whitebox-controller-linux-arm64.tar.gz whitebox-controller whitebox-gen 42 | 43 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-controller \ 44 | && CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-gen \ 45 | && tar zcf release/whitebox-controller-linux-arm.tar.gz whitebox-controller whitebox-gen 46 | 47 | RUN CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-controller \ 48 | && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/whitebox-gen \ 49 | && tar zcf release/whitebox-controller-darwin-amd64.tar.gz whitebox-controller whitebox-gen 50 | 51 | ################### 52 | 53 | FROM scratch 54 | 55 | COPY --from=build /workspace/whitebox-* /bin/ 56 | 57 | ENTRYPOINT ["/bin/whitebox-controller"] 58 | -------------------------------------------------------------------------------- /webhook/injection/injection.go: -------------------------------------------------------------------------------- 1 | package injection 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/ioutil" 7 | "net/http" 8 | 9 | "github.com/dgrijalva/jwt-go" 10 | "github.com/go-logr/logr" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | type Request struct { 16 | Headers http.Header `json:"headers"` 17 | Body string `json:"body"` 18 | } 19 | 20 | type Response struct { 21 | Object *unstructured.Unstructured `json:"object"` 22 | } 23 | 24 | type HandlerFunc func(context.Context, Request) (Response, error) 25 | 26 | type Webhook struct { 27 | client.Client 28 | 29 | Handler HandlerFunc 30 | KeyHandler jwt.Keyfunc 31 | log logr.Logger 32 | } 33 | 34 | func (wh *Webhook) InjectClient(c client.Client) error { 35 | wh.Client = c 36 | return nil 37 | } 38 | 39 | func (wh *Webhook) InjectLogger(l logr.Logger) error { 40 | wh.log = l 41 | return nil 42 | } 43 | 44 | func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { 45 | if r.URL == nil { 46 | wh.error(w, "Unexpected URL", 500) 47 | return 48 | } 49 | 50 | tokenStr := r.URL.Query().Get("token") 51 | token, err := jwt.Parse(tokenStr, wh.KeyHandler) 52 | if err != nil { 53 | wh.error(w, "Invalid token", 400) 54 | return 55 | } 56 | 57 | if !token.Valid { 58 | wh.error(w, "Invalid token", 400) 59 | return 60 | } 61 | 62 | claims, ok := token.Claims.(jwt.MapClaims) 63 | if !ok { 64 | wh.error(w, "Invalid token claims", 400) 65 | return 66 | } 67 | 68 | namespace := claims["namespace"].(string) 69 | if namespace == "" { 70 | wh.error(w, "Invalid namespace", 400) 71 | return 72 | } 73 | 74 | buf, err := ioutil.ReadAll(r.Body) 75 | if err != nil { 76 | wh.error(w, "Failed to read request body", 500) 77 | return 78 | } 79 | defer r.Body.Close() 80 | 81 | req := Request{ 82 | Headers: r.Header, 83 | Body: string(buf), 84 | } 85 | 86 | res, err := wh.Handler(context.TODO(), req) 87 | if err != nil { 88 | wh.error(w, err.Error(), 500) 89 | return 90 | } 91 | 92 | if res.Object == nil { 93 | w.WriteHeader(http.StatusOK) 94 | return 95 | } 96 | 97 | res.Object.SetNamespace(namespace) 98 | 99 | err = wh.Create(context.TODO(), res.Object) 100 | if err != nil { 101 | msg := "Failed to create a resource" 102 | wh.log.Error(err, msg, "namespace", res.Object.GetNamespace(), "name", res.Object.GetName()) 103 | http.Error(w, msg, 500) 104 | return 105 | } 106 | 107 | w.WriteHeader(http.StatusCreated) 108 | } 109 | 110 | func (wh *Webhook) error(w http.ResponseWriter, err string, code int) { 111 | wh.log.Error(errors.New(err), "injection request error", "code", code) 112 | http.Error(w, err, code) 113 | } 114 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/summerwind/whitebox-controller/config" 9 | ) 10 | 11 | type Option struct { 12 | Name string 13 | Namespace string 14 | Image string 15 | Config *config.Config 16 | ValidationWebhook bool 17 | MutatingWebhook bool 18 | InjectionWebhook bool 19 | } 20 | 21 | func (o *Option) Validate() error { 22 | if o.Name == "" { 23 | return fmt.Errorf("name must be specified") 24 | } 25 | 26 | if o.Image == "" { 27 | return fmt.Errorf("image must be specified") 28 | } 29 | 30 | err := o.Config.Validate() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func manifest(args []string) error { 39 | cmd := flag.NewFlagSet("manifest", flag.ExitOnError) 40 | configPath := cmd.String("c", "config.yaml", "Path to configuration file") 41 | name := cmd.String("name", "", "Name of the controller") 42 | namespace := cmd.String("namespace", "default", "Namespace of the controller") 43 | image := cmd.String("image", "", "Image name of the controller") 44 | 45 | cmd.Parse(args) 46 | 47 | c, err := config.LoadFile(*configPath) 48 | if err != nil { 49 | return fmt.Errorf("could not load configuration file: %v", err) 50 | } 51 | 52 | o := &Option{ 53 | Name: *name, 54 | Namespace: *namespace, 55 | Image: *image, 56 | Config: c, 57 | ValidationWebhook: false, 58 | MutatingWebhook: false, 59 | InjectionWebhook: false, 60 | } 61 | 62 | err = o.Validate() 63 | if err != nil { 64 | return err 65 | } 66 | 67 | for _, res := range c.Resources { 68 | if res.Validator != nil { 69 | o.ValidationWebhook = true 70 | } 71 | if res.Mutator != nil { 72 | o.MutatingWebhook = true 73 | } 74 | if res.Injector != nil { 75 | o.InjectionWebhook = true 76 | } 77 | } 78 | 79 | manifests := []string{} 80 | 81 | crds, err := genCRD(o) 82 | if err != nil { 83 | return fmt.Errorf("failed to generate CRD: %v", err) 84 | } 85 | manifests = append(manifests, crds...) 86 | 87 | certs, err := genCertificate(o) 88 | if err != nil { 89 | return fmt.Errorf("failed to generate certificates: %v", err) 90 | } 91 | manifests = append(manifests, certs) 92 | 93 | controller, err := genController(o) 94 | if err != nil { 95 | return fmt.Errorf("failed to generate resources for controller: %v", err) 96 | } 97 | manifests = append(manifests, controller) 98 | 99 | if o.ValidationWebhook { 100 | vwc, err := genValidationWebhookConfig(o) 101 | if err != nil { 102 | return fmt.Errorf("failed to generate validation webhook config: %v", err) 103 | } 104 | manifests = append(manifests, vwc) 105 | } 106 | 107 | if o.MutatingWebhook { 108 | mwc, err := genMutatingWebhookConfig(o) 109 | if err != nil { 110 | return fmt.Errorf("failed to generate mutating webhook config: %v", err) 111 | } 112 | manifests = append(manifests, mwc) 113 | } 114 | 115 | fmt.Println(strings.Join(manifests, "\n---\n")) 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /cmd/whitebox-gen/manifest_controller.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "text/template" 7 | ) 8 | 9 | func genController(o *Option) (string, error) { 10 | funcMap := template.FuncMap{ 11 | "toLower": strings.ToLower, 12 | } 13 | 14 | tmpl, err := template.New("").Funcs(funcMap).Parse(controllerTemplate) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | buf := bytes.NewBuffer([]byte{}) 20 | err = tmpl.Execute(buf, o) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | return strings.Trim(buf.String(), "\n"), nil 26 | } 27 | 28 | var controllerTemplate = ` 29 | apiVersion: v1 30 | kind: ServiceAccount 31 | metadata: 32 | name: {{ .Name }} 33 | namespace: {{ .Namespace }} 34 | --- 35 | apiVersion: rbac.authorization.k8s.io/v1 36 | kind: ClusterRole 37 | metadata: 38 | name: {{ .Name }} 39 | rules: 40 | {{ range .Config.Resources -}} 41 | - apiGroups: 42 | - {{ .Group }} 43 | resources: 44 | - {{ .Kind | toLower }} 45 | verbs: 46 | - get 47 | - list 48 | - watch 49 | - create 50 | - update 51 | - patch 52 | - delete 53 | {{ range .Dependents -}} 54 | - apiGroups: 55 | - {{ .Group }} 56 | resources: 57 | - {{ .Kind | toLower }} 58 | verbs: 59 | - get 60 | - list 61 | - watch 62 | - create 63 | - update 64 | - patch 65 | - delete 66 | {{ end -}} 67 | {{ range .References -}} 68 | - apiGroups: 69 | - {{ .Group }} 70 | resources: 71 | - {{ .Kind | toLower }} 72 | verbs: 73 | - get 74 | - list 75 | - watch 76 | {{ end -}} 77 | {{ end -}} 78 | - apiGroups: 79 | - "" 80 | resources: 81 | - events 82 | verbs: 83 | - create 84 | - patch 85 | --- 86 | apiVersion: rbac.authorization.k8s.io/v1 87 | kind: ClusterRoleBinding 88 | metadata: 89 | name: {{ .Name }} 90 | roleRef: 91 | apiGroup: rbac.authorization.k8s.io 92 | kind: ClusterRole 93 | name: {{ .Name }} 94 | subjects: 95 | - kind: ServiceAccount 96 | name: {{ .Name }} 97 | namespace: {{ .Namespace }} 98 | --- 99 | apiVersion: apps/v1 100 | kind: Deployment 101 | metadata: 102 | name: {{ .Name }} 103 | namespace: {{ .Namespace }} 104 | spec: 105 | replicas: 1 106 | selector: 107 | matchLabels: 108 | app: {{ .Name }} 109 | template: 110 | metadata: 111 | labels: 112 | app: {{ .Name }} 113 | spec: 114 | containers: 115 | - name: {{ .Name }} 116 | image: {{ .Image }} 117 | imagePullPolicy: IfNotPresent 118 | volumeMounts: 119 | - name: certificates 120 | mountPath: /etc/tls 121 | ports: 122 | - containerPort: 443 123 | - containerPort: 8080 124 | volumes: 125 | - name: certificates 126 | secret: 127 | secretName: {{ .Name }} 128 | serviceAccountName: {{ .Name }} 129 | terminationGracePeriodSeconds: 60 130 | --- 131 | apiVersion: v1 132 | kind: Service 133 | metadata: 134 | name: {{ .Name }} 135 | namespace: {{ .Namespace }} 136 | spec: 137 | selector: 138 | app: {{ .Name }} 139 | ports: 140 | - protocol: TCP 141 | port: 443 142 | targetPort: 443 143 | ` 144 | -------------------------------------------------------------------------------- /examples/hello-controller-golang/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | kconfig "sigs.k8s.io/controller-runtime/pkg/client/config" 11 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 12 | "sigs.k8s.io/controller-runtime/pkg/runtime/signals" 13 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 14 | 15 | _ "k8s.io/client-go/plugin/pkg/client/auth/azure" 16 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 17 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 18 | 19 | "github.com/summerwind/whitebox-controller/config" 20 | "github.com/summerwind/whitebox-controller/manager" 21 | "github.com/summerwind/whitebox-controller/reconciler/state" 22 | "github.com/summerwind/whitebox-controller/webhook" 23 | ) 24 | 25 | type Hello struct { 26 | metav1.TypeMeta `json:",inline"` 27 | metav1.ObjectMeta `json:"metadata,omitempty"` 28 | 29 | Spec HelloSpec `json:"spec"` 30 | Status HelloStatus `json:"status"` 31 | } 32 | 33 | type HelloSpec struct { 34 | Message string `json:"message"` 35 | } 36 | 37 | type HelloStatus struct { 38 | Phase string `json:"phase"` 39 | } 40 | 41 | type State struct { 42 | Object Hello `json:"object"` 43 | } 44 | 45 | type Handler struct{} 46 | 47 | func (h *Handler) HandleState(ss *state.State) error { 48 | s := State{} 49 | 50 | err := state.Unpack(ss, &s) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if s.Object.Status.Phase != "completed" { 56 | log.Printf("message: %s", s.Object.Spec.Message) 57 | s.Object.Status.Phase = "completed" 58 | } 59 | 60 | err = state.Pack(&s, ss) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (h *Handler) HandleAdmissionRequest(req admission.Request) (admission.Response, error) { 69 | hello := &Hello{} 70 | 71 | err := webhook.Unpack(req.Object, hello) 72 | if err != nil { 73 | return admission.Response{}, err 74 | } 75 | 76 | if hello.Spec.Message == "" { 77 | return admission.Denied("message must be specified"), nil 78 | } 79 | 80 | return admission.Allowed(""), nil 81 | } 82 | 83 | func main() { 84 | logf.SetLogger(logf.ZapLogger(false)) 85 | 86 | c := &config.Config{ 87 | Resources: []*config.ResourceConfig{ 88 | { 89 | GroupVersionKind: schema.GroupVersionKind{ 90 | Group: "whitebox.summerwind.dev", 91 | Version: "v1alpha1", 92 | Kind: "Hello", 93 | }, 94 | Reconciler: &config.ReconcilerConfig{ 95 | HandlerConfig: config.HandlerConfig{ 96 | StateHandler: &Handler{}, 97 | }, 98 | }, 99 | Validator: &config.HandlerConfig{ 100 | AdmissionRequestHandler: &Handler{}, 101 | }, 102 | }, 103 | }, 104 | Webhook: &config.ServerConfig{ 105 | Host: "0.0.0.0", 106 | Port: 443, 107 | TLS: &config.TLSConfig{ 108 | CertFile: "/etc/tls/tls.crt", 109 | KeyFile: "/etc/tls/tls.key", 110 | }, 111 | }, 112 | } 113 | 114 | kc, err := kconfig.GetConfig() 115 | if err != nil { 116 | fmt.Fprintf(os.Stderr, "Failed to load kubeconfig: %s\n", err) 117 | os.Exit(1) 118 | } 119 | 120 | mgr, err := manager.New(c, kc) 121 | if err != nil { 122 | fmt.Fprintf(os.Stderr, "Failed to create controller manager: %s\n", err) 123 | os.Exit(1) 124 | } 125 | 126 | err = mgr.Start(signals.SetupSignalHandler()) 127 | if err != nil { 128 | fmt.Fprintf(os.Stderr, "Failed to start controller manager: %s\n", err) 129 | os.Exit(1) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /examples/hello-controller-exec/manifests/controller.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: hello.whitebox.summerwind.dev 5 | spec: 6 | group: whitebox.summerwind.dev 7 | versions: 8 | - name: v1alpha1 9 | served: true 10 | storage: true 11 | names: 12 | kind: Hello 13 | plural: hello 14 | singular: hello 15 | scope: Namespaced 16 | --- 17 | apiVersion: certmanager.k8s.io/v1alpha1 18 | kind: Issuer 19 | metadata: 20 | name: hello-controller-selfsign 21 | namespace: kube-system 22 | spec: 23 | selfSigned: {} 24 | --- 25 | apiVersion: certmanager.k8s.io/v1alpha1 26 | kind: Certificate 27 | metadata: 28 | name: hello-controller-webhook-ca 29 | namespace: kube-system 30 | spec: 31 | secretName: hello-controller-webhook-ca 32 | issuerRef: 33 | name: hello-controller-selfsign 34 | commonName: "hello-controller webhook CA" 35 | duration: 43800h 36 | isCA: true 37 | --- 38 | apiVersion: certmanager.k8s.io/v1alpha1 39 | kind: Issuer 40 | metadata: 41 | name: hello-controller-webhook-ca 42 | namespace: kube-system 43 | spec: 44 | ca: 45 | secretName: hello-controller-webhook-ca 46 | --- 47 | apiVersion: certmanager.k8s.io/v1alpha1 48 | kind: Certificate 49 | metadata: 50 | name: hello-controller 51 | namespace: kube-system 52 | spec: 53 | secretName: hello-controller 54 | issuerRef: 55 | name: hello-controller-webhook-ca 56 | dnsNames: 57 | - hello-controller 58 | - hello-controller.kube-system 59 | - hello-controller.kube-system.svc 60 | duration: 8760h 61 | --- 62 | apiVersion: v1 63 | kind: ServiceAccount 64 | metadata: 65 | name: hello-controller 66 | namespace: kube-system 67 | --- 68 | apiVersion: rbac.authorization.k8s.io/v1 69 | kind: ClusterRole 70 | metadata: 71 | name: hello-controller 72 | rules: 73 | - apiGroups: 74 | - whitebox.summerwind.dev 75 | resources: 76 | - hello 77 | verbs: 78 | - get 79 | - list 80 | - watch 81 | - create 82 | - update 83 | - patch 84 | - delete 85 | - apiGroups: 86 | - "" 87 | resources: 88 | - events 89 | verbs: 90 | - create 91 | - patch 92 | --- 93 | apiVersion: rbac.authorization.k8s.io/v1 94 | kind: ClusterRoleBinding 95 | metadata: 96 | name: hello-controller 97 | roleRef: 98 | apiGroup: rbac.authorization.k8s.io 99 | kind: ClusterRole 100 | name: hello-controller 101 | subjects: 102 | - kind: ServiceAccount 103 | name: hello-controller 104 | namespace: kube-system 105 | --- 106 | apiVersion: apps/v1 107 | kind: Deployment 108 | metadata: 109 | name: hello-controller 110 | namespace: kube-system 111 | spec: 112 | replicas: 1 113 | selector: 114 | matchLabels: 115 | app: hello-controller 116 | template: 117 | metadata: 118 | labels: 119 | app: hello-controller 120 | spec: 121 | containers: 122 | - name: hello-controller 123 | image: summerwind/hello-controller:exec 124 | imagePullPolicy: IfNotPresent 125 | volumeMounts: 126 | - name: certificates 127 | mountPath: /etc/tls 128 | ports: 129 | - containerPort: 443 130 | - containerPort: 8080 131 | volumes: 132 | - name: certificates 133 | secret: 134 | secretName: hello-controller 135 | serviceAccountName: hello-controller 136 | terminationGracePeriodSeconds: 60 137 | --- 138 | apiVersion: v1 139 | kind: Service 140 | metadata: 141 | name: hello-controller 142 | namespace: kube-system 143 | spec: 144 | selector: 145 | app: hello-controller 146 | ports: 147 | - protocol: TCP 148 | port: 443 149 | targetPort: 443 150 | -------------------------------------------------------------------------------- /handler/exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "time" 12 | 13 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 14 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | "github.com/summerwind/whitebox-controller/config" 17 | "github.com/summerwind/whitebox-controller/reconciler/state" 18 | "github.com/summerwind/whitebox-controller/webhook/injection" 19 | ) 20 | 21 | var log = logf.Log.WithName("handler") 22 | 23 | type ExecHandler struct { 24 | command string 25 | args []string 26 | env []string 27 | workingDir string 28 | timeout time.Duration 29 | debug bool 30 | } 31 | 32 | func New(c *config.ExecHandlerConfig) (*ExecHandler, error) { 33 | args := []string{} 34 | if c.Args != nil { 35 | args = append(args, c.Args...) 36 | } 37 | 38 | env := []string{} 39 | if c.Env != nil { 40 | for key, val := range c.Env { 41 | env = append(env, fmt.Sprintf("%s=%s", key, val)) 42 | } 43 | } 44 | 45 | timeout := 60 * time.Second 46 | if c.Timeout != "" { 47 | var err error 48 | timeout, err = time.ParseDuration(c.Timeout) 49 | if err != nil { 50 | return nil, err 51 | } 52 | } 53 | 54 | return &ExecHandler{ 55 | command: c.Command, 56 | args: args, 57 | env: env, 58 | workingDir: c.WorkingDir, 59 | timeout: timeout, 60 | debug: c.Debug, 61 | }, nil 62 | } 63 | 64 | func (h *ExecHandler) HandleState(s *state.State) error { 65 | in, err := json.Marshal(s) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | out, err := h.run(in) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if len(out) == 0 { 76 | return nil 77 | } 78 | 79 | err = json.Unmarshal(out, s) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func (h *ExecHandler) HandleAdmissionRequest(req admission.Request) (admission.Response, error) { 88 | res := admission.Response{} 89 | 90 | in, err := json.Marshal(&req) 91 | if err != nil { 92 | return res, err 93 | } 94 | 95 | out, err := h.run(in) 96 | if err != nil { 97 | return res, err 98 | } 99 | 100 | err = json.Unmarshal(out, &res) 101 | if err != nil { 102 | return res, err 103 | } 104 | 105 | return res, nil 106 | } 107 | 108 | func (h *ExecHandler) HandleInjectionRequest(req injection.Request) (injection.Response, error) { 109 | res := injection.Response{} 110 | 111 | in, err := json.Marshal(&req) 112 | if err != nil { 113 | return res, err 114 | } 115 | 116 | out, err := h.run(in) 117 | if err != nil { 118 | return res, err 119 | } 120 | 121 | err = json.Unmarshal(out, &res) 122 | if err != nil { 123 | return res, err 124 | } 125 | 126 | return res, nil 127 | } 128 | 129 | func (h *ExecHandler) run(buf []byte) ([]byte, error) { 130 | var stdout bytes.Buffer 131 | 132 | ctx, cancel := context.WithTimeout(context.Background(), h.timeout) 133 | defer cancel() 134 | 135 | cmd := exec.CommandContext(ctx, h.command, h.args...) 136 | cmd.Stdin = bytes.NewReader(buf) 137 | cmd.Stdout = &stdout 138 | cmd.Env = append(os.Environ(), h.env...) 139 | cmd.Dir = h.workingDir 140 | 141 | stderr, err := cmd.StderrPipe() 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | err = cmd.Start() 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | if h.debug { 152 | log.Info("Sending state", "state", string(buf)) 153 | } 154 | 155 | scanner := bufio.NewScanner(stderr) 156 | for scanner.Scan() { 157 | log.Info(scanner.Text()) 158 | } 159 | 160 | err = cmd.Wait() 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | if h.debug { 166 | log.Info("Received new state", "state", string(stdout.Bytes()), "code", cmd.ProcessState.ExitCode()) 167 | } 168 | 169 | return stdout.Bytes(), nil 170 | } 171 | -------------------------------------------------------------------------------- /handler/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 15 | 16 | "github.com/summerwind/whitebox-controller/config" 17 | "github.com/summerwind/whitebox-controller/reconciler/state" 18 | "github.com/summerwind/whitebox-controller/webhook/injection" 19 | ) 20 | 21 | var defaultTimeout = 60 * time.Second 22 | 23 | type HTTPHandler struct { 24 | client *http.Client 25 | url string 26 | debug bool 27 | } 28 | 29 | func New(c *config.HTTPHandlerConfig) (*HTTPHandler, error) { 30 | var ( 31 | timeout time.Duration 32 | err error 33 | ) 34 | 35 | if c.Timeout != "" { 36 | timeout, err = time.ParseDuration(c.Timeout) 37 | if err != nil { 38 | return nil, err 39 | } 40 | } else { 41 | timeout = defaultTimeout 42 | } 43 | 44 | tlsConfig := &tls.Config{} 45 | 46 | if c.TLS != nil { 47 | if c.TLS.KeyFile != "" && c.TLS.CertFile != "" { 48 | cert, err := tls.LoadX509KeyPair(c.TLS.CertFile, c.TLS.KeyFile) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | tlsConfig.Certificates = []tls.Certificate{cert} 54 | tlsConfig.BuildNameToCertificate() 55 | } 56 | 57 | if c.TLS.CACertFile != "" { 58 | caCert, err := ioutil.ReadFile(c.TLS.CACertFile) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | caCertPool := x509.NewCertPool() 64 | caCertPool.AppendCertsFromPEM(caCert) 65 | 66 | tlsConfig.RootCAs = caCertPool 67 | } 68 | } 69 | 70 | client := &http.Client{ 71 | Timeout: timeout, 72 | Transport: &http.Transport{ 73 | TLSClientConfig: tlsConfig, 74 | }, 75 | } 76 | 77 | return &HTTPHandler{ 78 | client: client, 79 | url: c.URL, 80 | debug: c.Debug, 81 | }, nil 82 | } 83 | 84 | func (h *HTTPHandler) HandleState(s *state.State) error { 85 | in, err := json.Marshal(s) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | out, err := h.run(in) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | if len(out) == 0 { 96 | return nil 97 | } 98 | 99 | err = json.Unmarshal(out, s) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (h *HTTPHandler) HandleAdmissionRequest(req admission.Request) (admission.Response, error) { 108 | res := admission.Response{} 109 | 110 | in, err := json.Marshal(&req) 111 | if err != nil { 112 | return res, err 113 | } 114 | 115 | out, err := h.run(in) 116 | if err != nil { 117 | return res, err 118 | } 119 | 120 | err = json.Unmarshal(out, &res) 121 | if err != nil { 122 | return res, err 123 | } 124 | 125 | return res, nil 126 | } 127 | 128 | func (h *HTTPHandler) HandleInjectionRequest(req injection.Request) (injection.Response, error) { 129 | res := injection.Response{} 130 | 131 | in, err := json.Marshal(&req) 132 | if err != nil { 133 | return res, err 134 | } 135 | 136 | out, err := h.run(in) 137 | if err != nil { 138 | return res, err 139 | } 140 | 141 | err = json.Unmarshal(out, &res) 142 | if err != nil { 143 | return res, err 144 | } 145 | 146 | return res, nil 147 | } 148 | 149 | func (h *HTTPHandler) run(buf []byte) ([]byte, error) { 150 | reqBody := bytes.NewBuffer(buf) 151 | 152 | req, err := http.NewRequest("POST", h.url, reqBody) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | if h.debug { 158 | log("request", string(buf)) 159 | } 160 | 161 | req.Header.Set("Content-Type", "application/json") 162 | res, err := h.client.Do(req) 163 | if err != nil { 164 | return nil, err 165 | } 166 | defer res.Body.Close() 167 | 168 | if res.StatusCode != http.StatusOK { 169 | return nil, fmt.Errorf("invalid status: %s", res.Status) 170 | } 171 | 172 | resBody, err := ioutil.ReadAll(res.Body) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | if h.debug { 178 | log("response", string(resBody)) 179 | } 180 | 181 | return resBody, nil 182 | } 183 | 184 | func log(stream, msg string) { 185 | fmt.Fprintf(os.Stderr, "[http] %s: %s\n", stream, msg) 186 | } 187 | -------------------------------------------------------------------------------- /examples/hello-controller-golang/manifests/controller.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: hello.whitebox.summerwind.dev 5 | spec: 6 | group: whitebox.summerwind.dev 7 | versions: 8 | - name: v1alpha1 9 | served: true 10 | storage: true 11 | names: 12 | kind: Hello 13 | plural: hello 14 | singular: hello 15 | scope: Namespaced 16 | --- 17 | apiVersion: certmanager.k8s.io/v1alpha1 18 | kind: Issuer 19 | metadata: 20 | name: hello-controller-selfsign 21 | namespace: kube-system 22 | spec: 23 | selfSigned: {} 24 | --- 25 | apiVersion: certmanager.k8s.io/v1alpha1 26 | kind: Certificate 27 | metadata: 28 | name: hello-controller-webhook-ca 29 | namespace: kube-system 30 | spec: 31 | secretName: hello-controller-webhook-ca 32 | issuerRef: 33 | name: hello-controller-selfsign 34 | commonName: "hello-controller webhook CA" 35 | duration: 43800h 36 | isCA: true 37 | --- 38 | apiVersion: certmanager.k8s.io/v1alpha1 39 | kind: Issuer 40 | metadata: 41 | name: hello-controller-webhook-ca 42 | namespace: kube-system 43 | spec: 44 | ca: 45 | secretName: hello-controller-webhook-ca 46 | --- 47 | apiVersion: certmanager.k8s.io/v1alpha1 48 | kind: Certificate 49 | metadata: 50 | name: hello-controller 51 | namespace: kube-system 52 | spec: 53 | secretName: hello-controller 54 | issuerRef: 55 | name: hello-controller-webhook-ca 56 | dnsNames: 57 | - hello-controller 58 | - hello-controller.kube-system 59 | - hello-controller.kube-system.svc 60 | duration: 8760h 61 | --- 62 | apiVersion: v1 63 | kind: ServiceAccount 64 | metadata: 65 | name: hello-controller 66 | namespace: kube-system 67 | --- 68 | apiVersion: rbac.authorization.k8s.io/v1 69 | kind: ClusterRole 70 | metadata: 71 | name: hello-controller 72 | rules: 73 | - apiGroups: 74 | - whitebox.summerwind.dev 75 | resources: 76 | - hello 77 | verbs: 78 | - get 79 | - list 80 | - watch 81 | - create 82 | - update 83 | - patch 84 | - delete 85 | - apiGroups: 86 | - "" 87 | resources: 88 | - events 89 | verbs: 90 | - create 91 | - patch 92 | --- 93 | apiVersion: rbac.authorization.k8s.io/v1 94 | kind: ClusterRoleBinding 95 | metadata: 96 | name: hello-controller 97 | roleRef: 98 | apiGroup: rbac.authorization.k8s.io 99 | kind: ClusterRole 100 | name: hello-controller 101 | subjects: 102 | - kind: ServiceAccount 103 | name: hello-controller 104 | namespace: kube-system 105 | --- 106 | apiVersion: apps/v1 107 | kind: Deployment 108 | metadata: 109 | name: hello-controller 110 | namespace: kube-system 111 | spec: 112 | replicas: 1 113 | selector: 114 | matchLabels: 115 | app: hello-controller 116 | template: 117 | metadata: 118 | labels: 119 | app: hello-controller 120 | spec: 121 | containers: 122 | - name: hello-controller 123 | image: summerwind/hello-controller:golang 124 | imagePullPolicy: IfNotPresent 125 | volumeMounts: 126 | - name: certificates 127 | mountPath: /etc/tls 128 | ports: 129 | - containerPort: 443 130 | - containerPort: 8080 131 | volumes: 132 | - name: certificates 133 | secret: 134 | secretName: hello-controller 135 | serviceAccountName: hello-controller 136 | terminationGracePeriodSeconds: 60 137 | --- 138 | apiVersion: v1 139 | kind: Service 140 | metadata: 141 | name: hello-controller 142 | namespace: kube-system 143 | spec: 144 | selector: 145 | app: hello-controller 146 | ports: 147 | - protocol: TCP 148 | port: 443 149 | targetPort: 443 150 | --- 151 | apiVersion: admissionregistration.k8s.io/v1beta1 152 | kind: ValidatingWebhookConfiguration 153 | metadata: 154 | name: hello-controller 155 | annotations: 156 | certmanager.k8s.io/inject-ca-from: kube-system/hello-controller 157 | webhooks: 158 | - name: hello.whitebox.summerwind.dev 159 | rules: 160 | - apiGroups: 161 | - whitebox.summerwind.dev 162 | apiVersions: 163 | - v1alpha1 164 | resources: 165 | - hello 166 | operations: 167 | - CREATE 168 | - UPDATE 169 | failurePolicy: Fail 170 | clientConfig: 171 | service: 172 | name: hello-controller 173 | namespace: kube-system 174 | path: /whitebox.summerwind.dev/v1alpha1/hello/validate 175 | caBundle: "" 176 | -------------------------------------------------------------------------------- /examples/issue-injector/manifests/controller.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: issue.whitebox.summerwind.dev 5 | spec: 6 | group: whitebox.summerwind.dev 7 | versions: 8 | - name: v1alpha1 9 | served: true 10 | storage: true 11 | names: 12 | kind: Issue 13 | plural: issue 14 | singular: issue 15 | scope: Namespaced 16 | --- 17 | apiVersion: certmanager.k8s.io/v1alpha1 18 | kind: Issuer 19 | metadata: 20 | name: issue-injector-selfsign 21 | namespace: kube-system 22 | spec: 23 | selfSigned: {} 24 | --- 25 | apiVersion: certmanager.k8s.io/v1alpha1 26 | kind: Certificate 27 | metadata: 28 | name: issue-injector-webhook-ca 29 | namespace: kube-system 30 | spec: 31 | secretName: issue-injector-webhook-ca 32 | issuerRef: 33 | name: issue-injector-selfsign 34 | commonName: "issue-injector webhook CA" 35 | duration: 43800h 36 | isCA: true 37 | --- 38 | apiVersion: certmanager.k8s.io/v1alpha1 39 | kind: Issuer 40 | metadata: 41 | name: issue-injector-webhook-ca 42 | namespace: kube-system 43 | spec: 44 | ca: 45 | secretName: issue-injector-webhook-ca 46 | --- 47 | apiVersion: certmanager.k8s.io/v1alpha1 48 | kind: Certificate 49 | metadata: 50 | name: issue-injector 51 | namespace: kube-system 52 | spec: 53 | secretName: issue-injector 54 | issuerRef: 55 | name: issue-injector-webhook-ca 56 | dnsNames: 57 | - issue-injector 58 | - issue-injector.kube-system 59 | - issue-injector.kube-system.svc 60 | duration: 8760h 61 | --- 62 | apiVersion: v1 63 | kind: ServiceAccount 64 | metadata: 65 | name: issue-injector 66 | namespace: kube-system 67 | --- 68 | apiVersion: rbac.authorization.k8s.io/v1 69 | kind: ClusterRole 70 | metadata: 71 | name: issue-injector 72 | rules: 73 | - apiGroups: 74 | - whitebox.summerwind.dev 75 | resources: 76 | - issue 77 | verbs: 78 | - get 79 | - list 80 | - watch 81 | - create 82 | - update 83 | - patch 84 | - delete 85 | - apiGroups: 86 | - "" 87 | resources: 88 | - events 89 | verbs: 90 | - create 91 | - patch 92 | --- 93 | apiVersion: rbac.authorization.k8s.io/v1 94 | kind: ClusterRoleBinding 95 | metadata: 96 | name: issue-injector 97 | roleRef: 98 | apiGroup: rbac.authorization.k8s.io 99 | kind: ClusterRole 100 | name: issue-injector 101 | subjects: 102 | - kind: ServiceAccount 103 | name: issue-injector 104 | namespace: kube-system 105 | --- 106 | apiVersion: apps/v1 107 | kind: Deployment 108 | metadata: 109 | name: issue-injector 110 | namespace: kube-system 111 | spec: 112 | replicas: 1 113 | selector: 114 | matchLabels: 115 | app: issue-injector 116 | template: 117 | metadata: 118 | labels: 119 | app: issue-injector 120 | spec: 121 | containers: 122 | - name: issue-injector 123 | image: summerwind/issue-injector:latest 124 | imagePullPolicy: IfNotPresent 125 | volumeMounts: 126 | - name: certificates 127 | mountPath: /etc/tls 128 | - name: injector 129 | mountPath: /etc/injector 130 | ports: 131 | - containerPort: 443 132 | - containerPort: 8080 133 | volumes: 134 | - name: certificates 135 | secret: 136 | secretName: issue-injector 137 | - name: injector 138 | secret: 139 | secretName: issue-injector-key 140 | serviceAccountName: issue-injector 141 | terminationGracePeriodSeconds: 60 142 | --- 143 | apiVersion: v1 144 | kind: Service 145 | metadata: 146 | name: issue-injector 147 | namespace: kube-system 148 | spec: 149 | selector: 150 | app: issue-injector 151 | ports: 152 | - protocol: TCP 153 | port: 443 154 | targetPort: 443 155 | --- 156 | apiVersion: v1 157 | kind: Secret 158 | metadata: 159 | name: issue-injector-key 160 | namespace: kube-system 161 | type: Opaque 162 | data: 163 | verify.key: LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUExTnRBMnlsWnB0eis5cmNsNTc1eApuaDQ3UVZPK0ZnOWRMdVdOOHZBbko2QjViZUtoZ2xpZC9oM2IwSEE3ZVdEWlFGRkhDYXVvalV4ZEtUZGprSnkxCkVjZU9tUHIwQ1lqRFk2WFBpQ0ZHOXBGRWVYYTBvckZoa2V6cHYyVndVbVdTTHFjbXVoZHZ5Si9WTWZwV3NIMWEKc3lBQ0FEZ1J1amkwTWc4MmM4OUw2NEpvejNtYmhremJFaGI4ZW5KS0swVFEwd0Vyck40N0FqbnB4d0c0YWJ6Mgp0aER0QlJUYXhNTXZoQmZsRmpMbHd6QVFLdHl3Z05QZnZtbExaYk5CTTI3YnExZGZDRXdWbGh4MVF0LzNNRVIrCkd4U3NreFFDUmplUzJaTXEvOW1xZFp1TkE0Qmo2VEhOZWtFMGFIajU2cTVLR2h1dStaY3VRazYzenNobEFhRWYKdFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg== 164 | -------------------------------------------------------------------------------- /examples/containerset-controller/manifests/controller.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: containerset.whitebox.summerwind.dev 5 | spec: 6 | group: whitebox.summerwind.dev 7 | versions: 8 | - name: v1alpha1 9 | served: true 10 | storage: true 11 | names: 12 | kind: ContainerSet 13 | plural: containerset 14 | singular: containerset 15 | scope: Namespaced 16 | --- 17 | apiVersion: certmanager.k8s.io/v1alpha1 18 | kind: Issuer 19 | metadata: 20 | name: containerset-controller-selfsign 21 | namespace: kube-system 22 | spec: 23 | selfSigned: {} 24 | --- 25 | apiVersion: certmanager.k8s.io/v1alpha1 26 | kind: Certificate 27 | metadata: 28 | name: containerset-controller-webhook-ca 29 | namespace: kube-system 30 | spec: 31 | secretName: containerset-controller-webhook-ca 32 | issuerRef: 33 | name: containerset-controller-selfsign 34 | commonName: "containerset-controller webhook CA" 35 | duration: 43800h 36 | isCA: true 37 | --- 38 | apiVersion: certmanager.k8s.io/v1alpha1 39 | kind: Issuer 40 | metadata: 41 | name: containerset-controller-webhook-ca 42 | namespace: kube-system 43 | spec: 44 | ca: 45 | secretName: containerset-controller-webhook-ca 46 | --- 47 | apiVersion: certmanager.k8s.io/v1alpha1 48 | kind: Certificate 49 | metadata: 50 | name: containerset-controller 51 | namespace: kube-system 52 | spec: 53 | secretName: containerset-controller 54 | issuerRef: 55 | name: containerset-controller-webhook-ca 56 | dnsNames: 57 | - containerset-controller 58 | - containerset-controller.kube-system 59 | - containerset-controller.kube-system.svc 60 | duration: 8760h 61 | --- 62 | apiVersion: v1 63 | kind: ServiceAccount 64 | metadata: 65 | name: containerset-controller 66 | namespace: kube-system 67 | --- 68 | apiVersion: rbac.authorization.k8s.io/v1 69 | kind: ClusterRole 70 | metadata: 71 | name: containerset-controller 72 | rules: 73 | - apiGroups: 74 | - whitebox.summerwind.dev 75 | resources: 76 | - containerset 77 | verbs: 78 | - get 79 | - list 80 | - watch 81 | - create 82 | - update 83 | - patch 84 | - delete 85 | - apiGroups: 86 | - apps 87 | resources: 88 | - deployments 89 | verbs: 90 | - get 91 | - list 92 | - watch 93 | - create 94 | - update 95 | - patch 96 | - delete 97 | - apiGroups: 98 | - "" 99 | resources: 100 | - events 101 | verbs: 102 | - create 103 | - patch 104 | --- 105 | apiVersion: rbac.authorization.k8s.io/v1 106 | kind: ClusterRoleBinding 107 | metadata: 108 | name: containerset-controller 109 | roleRef: 110 | apiGroup: rbac.authorization.k8s.io 111 | kind: ClusterRole 112 | name: containerset-controller 113 | subjects: 114 | - kind: ServiceAccount 115 | name: containerset-controller 116 | namespace: kube-system 117 | --- 118 | apiVersion: apps/v1 119 | kind: Deployment 120 | metadata: 121 | name: containerset-controller 122 | namespace: kube-system 123 | spec: 124 | replicas: 1 125 | selector: 126 | matchLabels: 127 | app: containerset-controller 128 | template: 129 | metadata: 130 | labels: 131 | app: containerset-controller 132 | spec: 133 | containers: 134 | - name: containerset-controller 135 | image: summerwind/containerset-controller:latest 136 | imagePullPolicy: IfNotPresent 137 | volumeMounts: 138 | - name: certificates 139 | mountPath: /etc/tls 140 | ports: 141 | - containerPort: 443 142 | - containerPort: 8080 143 | volumes: 144 | - name: certificates 145 | secret: 146 | secretName: containerset-controller 147 | serviceAccountName: containerset-controller 148 | terminationGracePeriodSeconds: 60 149 | --- 150 | apiVersion: v1 151 | kind: Service 152 | metadata: 153 | name: containerset-controller 154 | namespace: kube-system 155 | spec: 156 | selector: 157 | app: containerset-controller 158 | ports: 159 | - protocol: TCP 160 | port: 443 161 | targetPort: 443 162 | --- 163 | apiVersion: admissionregistration.k8s.io/v1beta1 164 | kind: ValidatingWebhookConfiguration 165 | metadata: 166 | name: containerset-controller 167 | annotations: 168 | certmanager.k8s.io/inject-ca-from: kube-system/containerset-controller 169 | webhooks: 170 | - name: containerset.whitebox.summerwind.dev 171 | rules: 172 | - apiGroups: 173 | - whitebox.summerwind.dev 174 | apiVersions: 175 | - v1alpha1 176 | resources: 177 | - containerset 178 | operations: 179 | - CREATE 180 | - UPDATE 181 | failurePolicy: Fail 182 | clientConfig: 183 | service: 184 | name: containerset-controller 185 | namespace: kube-system 186 | path: /whitebox.summerwind.dev/v1alpha1/containerset/validate 187 | caBundle: "" 188 | --- 189 | apiVersion: admissionregistration.k8s.io/v1beta1 190 | kind: MutatingWebhookConfiguration 191 | metadata: 192 | name: containerset-controller 193 | annotations: 194 | certmanager.k8s.io/inject-ca-from: kube-system/containerset-controller 195 | webhooks: 196 | - name: containerset.whitebox.summerwind.dev 197 | rules: 198 | - apiGroups: 199 | - whitebox.summerwind.dev 200 | apiVersions: 201 | - v1alpha1 202 | resources: 203 | - containerset 204 | operations: 205 | - CREATE 206 | - UPDATE 207 | failurePolicy: Fail 208 | clientConfig: 209 | service: 210 | name: containerset-controller 211 | namespace: kube-system 212 | path: /whitebox.summerwind.dev/v1alpha1/containerset/mutate 213 | caBundle: "" 214 | -------------------------------------------------------------------------------- /reconciler/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | ) 12 | 13 | // State is passed to and received from handler. 14 | type State struct { 15 | Object *unstructured.Unstructured `json:"object"` 16 | Dependents map[string][]*unstructured.Unstructured `json:"dependents,omitempty"` 17 | References map[string][]*unstructured.Unstructured `json:"references,omitempty"` 18 | Events []Event `json:"events,omitempty"` 19 | Requeue bool `json:"requeue,omitempty"` 20 | RequeueAfter int `json:"requeueAfter,omitempty"` 21 | } 22 | 23 | // NewState returns a new state with specified object. 24 | func New(object *unstructured.Unstructured, deps, refs map[string][]*unstructured.Unstructured) *State { 25 | return &State{ 26 | Object: object, 27 | Dependents: deps, 28 | References: refs, 29 | Events: []Event{}, 30 | } 31 | } 32 | 33 | // Copy returns a copy of current state. 34 | func (s *State) Copy() *State { 35 | ns := &State{ 36 | Object: s.Object.DeepCopy(), 37 | Dependents: map[string][]*unstructured.Unstructured{}, 38 | References: map[string][]*unstructured.Unstructured{}, 39 | } 40 | 41 | if len(s.Dependents) > 0 { 42 | for key, deps := range s.Dependents { 43 | ns.Dependents[key] = make([]*unstructured.Unstructured, len(s.Dependents[key])) 44 | for i := range deps { 45 | ns.Dependents[key][i] = s.Dependents[key][i].DeepCopy() 46 | } 47 | } 48 | } 49 | 50 | if len(s.References) > 0 { 51 | for key, refs := range s.References { 52 | ns.References[key] = make([]*unstructured.Unstructured, len(s.References[key])) 53 | for i := range refs { 54 | ns.References[key][i] = s.References[key][i].DeepCopy() 55 | } 56 | } 57 | } 58 | 59 | if len(s.Events) > 0 { 60 | ns.Events = make([]Event, len(s.Events)) 61 | for i := range s.Events { 62 | ns.Events[i] = s.Events[i] 63 | } 64 | } 65 | 66 | return ns 67 | } 68 | 69 | // Diff compares two states and returns lists of modified objects. 70 | func (s *State) Diff(ns *State) ([]*unstructured.Unstructured, []*unstructured.Unstructured, []*unstructured.Unstructured) { 71 | created := []*unstructured.Unstructured{} 72 | updated := []*unstructured.Unstructured{} 73 | deleted := []*unstructured.Unstructured{} 74 | 75 | if ns.Object == nil { 76 | deleted = append(deleted, s.Object) 77 | } else { 78 | update := true 79 | if s.Object.GetNamespace() != ns.Object.GetNamespace() { 80 | update = false 81 | } 82 | if s.Object.GetName() != ns.Object.GetName() { 83 | update = false 84 | } 85 | if reflect.DeepEqual(s.Object, ns.Object) { 86 | update = false 87 | } 88 | 89 | if update { 90 | updated = append(updated, ns.Object) 91 | } 92 | } 93 | 94 | checked := map[string]struct{}{} 95 | 96 | // Search for updated or deleted dependent resources. 97 | for key := range s.Dependents { 98 | _, ok := ns.Dependents[key] 99 | if !ok { 100 | deleted = append(deleted, s.Dependents[key]...) 101 | } 102 | 103 | for i := range s.Dependents[key] { 104 | found := false 105 | dep := s.Dependents[key][i] 106 | 107 | for j := range ns.Dependents[key] { 108 | newDep := ns.Dependents[key][j] 109 | if dep.GetNamespace() != newDep.GetNamespace() { 110 | continue 111 | } 112 | if dep.GetName() != newDep.GetName() { 113 | continue 114 | } 115 | if s.Object.GetNamespace() != newDep.GetNamespace() { 116 | continue 117 | } 118 | 119 | found = true 120 | if !reflect.DeepEqual(dep, newDep) { 121 | updated = append(updated, newDep) 122 | } 123 | 124 | break 125 | } 126 | 127 | if !found { 128 | deleted = append(deleted, dep) 129 | } 130 | 131 | ck := fmt.Sprintf("%s/%s/%s", key, dep.GetNamespace(), dep.GetName()) 132 | checked[ck] = struct{}{} 133 | } 134 | } 135 | 136 | // Search for new dependent resources. 137 | for key := range ns.Dependents { 138 | _, ok := s.Dependents[key] 139 | if !ok { 140 | created = append(created, ns.Dependents[key]...) 141 | } 142 | 143 | for i := range ns.Dependents[key] { 144 | newDep := ns.Dependents[key][i] 145 | 146 | ck := fmt.Sprintf("%s/%s/%s", key, newDep.GetNamespace(), newDep.GetName()) 147 | _, ok := checked[ck] 148 | if ok { 149 | continue 150 | } 151 | 152 | if s.Object.GetNamespace() != newDep.GetNamespace() { 153 | continue 154 | } 155 | if key != ResourceKey(newDep.GroupVersionKind()) { 156 | continue 157 | } 158 | 159 | created = append(created, newDep) 160 | } 161 | } 162 | 163 | return created, updated, deleted 164 | } 165 | 166 | // Pack parses v and stores the state the value to the state. 167 | func Pack(v interface{}, state *State) error { 168 | b, err := json.Marshal(v) 169 | if err != nil { 170 | return fmt.Errorf("failed to pack: %v", err) 171 | } 172 | 173 | err = json.Unmarshal(b, state) 174 | if err != nil { 175 | return fmt.Errorf("failed to pack: %v", err) 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // Unpack parses the state and stores the value pointed to by v. 182 | func Unpack(state *State, v interface{}) error { 183 | b, err := json.Marshal(state) 184 | if err != nil { 185 | return fmt.Errorf("failed to unpack: %v", err) 186 | } 187 | 188 | err = json.Unmarshal(b, v) 189 | if err != nil { 190 | return fmt.Errorf("failed to unpack: %v", err) 191 | } 192 | 193 | return nil 194 | } 195 | 196 | func ResourceKey(gvk schema.GroupVersionKind) string { 197 | if gvk.Group == "" { 198 | return strings.ToLower(fmt.Sprintf("%s.%s", gvk.Kind, gvk.Version)) 199 | } 200 | 201 | return strings.ToLower(fmt.Sprintf("%s.%s.%s", gvk.Kind, gvk.Version, gvk.Group)) 202 | } 203 | -------------------------------------------------------------------------------- /reconciler/state/state_test.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | . "github.com/onsi/gomega" 8 | . "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/apimachinery/pkg/runtime/schema" 13 | ) 14 | 15 | func TestCopy(t *testing.T) { 16 | RegisterTestingT(t) 17 | 18 | s := &State{ 19 | Object: newObject("Resource", "test"), 20 | Dependents: map[string][]*Unstructured{ 21 | "a.v1alpha1.example.com": []*Unstructured{ 22 | newObject("A", "a1"), 23 | newObject("A", "a2"), 24 | }, 25 | "b.v1alpha1.example.com": []*Unstructured{ 26 | newObject("B", "b1"), 27 | newObject("B", "b2"), 28 | }, 29 | }, 30 | References: map[string][]*Unstructured{ 31 | "c.v1alpha1.example.com": []*Unstructured{ 32 | newObject("C", "c1"), 33 | newObject("C", "c2"), 34 | }, 35 | "d.v1alpha1.example.com": []*Unstructured{ 36 | newObject("D", "d1"), 37 | newObject("D", "d2"), 38 | }, 39 | }, 40 | } 41 | 42 | ns := s.Copy() 43 | Expect(reflect.DeepEqual(*s, *ns)).To(BeTrue()) 44 | } 45 | 46 | func TestDiff(t *testing.T) { 47 | RegisterTestingT(t) 48 | 49 | s := &State{ 50 | Object: newObject("Resource", "test"), 51 | Dependents: map[string][]*Unstructured{ 52 | "a.v1alpha1.example.com": []*Unstructured{ 53 | newObject("A", "a1"), 54 | newObject("A", "a2"), 55 | }, 56 | "b.v1alpha1.example.com": []*Unstructured{ 57 | newObject("B", "b1"), 58 | newObject("B", "b2"), 59 | }, 60 | }, 61 | } 62 | 63 | ns := &State{ 64 | Object: newObject("Resource", "test"), 65 | Dependents: map[string][]*Unstructured{ 66 | "a.v1alpha1.example.com": []*Unstructured{ 67 | newObject("A", "a1"), 68 | newObject("A", "a3"), 69 | }, 70 | "b.v1alpha1.example.com": []*Unstructured{ 71 | newObject("B", "b1"), 72 | newObject("B", "b3"), 73 | }, 74 | }, 75 | } 76 | 77 | SetNestedField(ns.Object.Object, "bye", "spec", "message") 78 | SetNestedField(ns.Dependents["a.v1alpha1.example.com"][0].Object, "bye", "spec", "message") 79 | SetNestedField(ns.Dependents["b.v1alpha1.example.com"][0].Object, "bye", "spec", "message") 80 | 81 | created, updated, deleted := s.Diff(ns) 82 | 83 | Expect(len(created)).To(Equal(2)) 84 | Expect([]string{created[0].GetName(), created[1].GetName()}).To(ConsistOf("a3", "b3")) 85 | 86 | Expect(len(updated)).To(Equal(3)) 87 | Expect([]string{updated[0].GetName(), updated[1].GetName(), updated[2].GetName()}).To(ConsistOf("test", "a1", "b1")) 88 | 89 | Expect(len(deleted)).To(Equal(2)) 90 | Expect([]string{deleted[0].GetName(), deleted[1].GetName()}).To(ConsistOf("a2", "b2")) 91 | } 92 | 93 | func TestPack(t *testing.T) { 94 | RegisterTestingT(t) 95 | 96 | ts := testState{ 97 | Object: newResource("Resource", "test"), 98 | Dependents: map[string][]*resource{ 99 | "a.v1alpha1.example.com": []*resource{ 100 | newResource("A", "test1"), 101 | newResource("A", "test2"), 102 | }, 103 | }, 104 | References: map[string][]*resource{ 105 | "b.v1alpha1.example.com": []*resource{ 106 | newResource("B", "test1"), 107 | newResource("B", "test2"), 108 | }, 109 | }, 110 | } 111 | 112 | s := &State{} 113 | err := Pack(ts, s) 114 | Expect(err).NotTo(HaveOccurred()) 115 | Expect(s.Object.GetName()).To(Equal("test")) 116 | 117 | deps := s.Dependents["a.v1alpha1.example.com"] 118 | Expect(deps[0].GetName()).To(Equal("test1")) 119 | Expect(deps[1].GetName()).To(Equal("test2")) 120 | 121 | refs := s.References["b.v1alpha1.example.com"] 122 | Expect(refs[0].GetName()).To(Equal("test1")) 123 | Expect(refs[1].GetName()).To(Equal("test2")) 124 | } 125 | 126 | func TestUnpack(t *testing.T) { 127 | RegisterTestingT(t) 128 | 129 | s := &State{ 130 | Object: newObject("Resource", "test"), 131 | Dependents: map[string][]*Unstructured{ 132 | "a.v1alpha1.example.com": []*Unstructured{ 133 | newObject("A", "test1"), 134 | newObject("A", "test2"), 135 | }, 136 | }, 137 | References: map[string][]*Unstructured{ 138 | "b.v1alpha1.example.com": []*Unstructured{ 139 | newObject("B", "test1"), 140 | newObject("B", "test2"), 141 | }, 142 | }, 143 | } 144 | 145 | ts := &testState{} 146 | err := Unpack(s, ts) 147 | Expect(err).NotTo(HaveOccurred()) 148 | Expect(ts.Object.Name).To(Equal("test")) 149 | 150 | deps := ts.Dependents["a.v1alpha1.example.com"] 151 | Expect(deps[0].Name).To(Equal("test1")) 152 | Expect(deps[1].Name).To(Equal("test2")) 153 | 154 | refs := ts.References["b.v1alpha1.example.com"] 155 | Expect(refs[0].Name).To(Equal("test1")) 156 | Expect(refs[1].Name).To(Equal("test2")) 157 | } 158 | 159 | type resource struct { 160 | metav1.TypeMeta `json:",inline"` 161 | metav1.ObjectMeta `json:"metadata,omitempty"` 162 | 163 | Spec resourceSpec `json:"spec,omitempty"` 164 | } 165 | 166 | type resourceSpec struct { 167 | Message string `json:"message"` 168 | } 169 | 170 | type testState struct { 171 | Object *resource `json:"object"` 172 | Dependents map[string][]*resource `json:"dependents"` 173 | References map[string][]*resource `json:"references"` 174 | } 175 | 176 | func newResource(kind, name string) *resource { 177 | r := &resource{ 178 | Spec: resourceSpec{ 179 | Message: "hello", 180 | }, 181 | } 182 | r.SetGroupVersionKind(schema.GroupVersionKind{ 183 | Group: "example.com", 184 | Version: "v1alpha1", 185 | Kind: kind, 186 | }) 187 | r.SetNamespace("default") 188 | r.SetName(name) 189 | 190 | return r 191 | } 192 | 193 | func newObject(kind, name string) *unstructured.Unstructured { 194 | object := &Unstructured{} 195 | object.SetGroupVersionKind(schema.GroupVersionKind{ 196 | Group: "example.com", 197 | Version: "v1alpha1", 198 | Kind: kind, 199 | }) 200 | object.SetNamespace("default") 201 | object.SetName(name) 202 | 203 | SetNestedField(object.Object, "hello", "spec", "message") 204 | 205 | return object 206 | } 207 | -------------------------------------------------------------------------------- /docs/implementing-controller.md: -------------------------------------------------------------------------------- 1 | # Implementing controller 2 | 3 | This document provides the information needed to implement a Controller. 4 | 5 | ## Overview 6 | 7 | The following two tasks are required to implement Controller. 8 | 9 | 1. Configure the resource to be watched. 10 | 2. Configure the reconciler to handle the resource changed. 11 | 12 | ## Configuring Resource 13 | 14 | *Resource* is a resource on Kubernetes for which you want to detect changes. Whitebox Controller executes reconciler when the resource is changed. 15 | 16 | *Resource* is specified in `.resources[]` of the configuration file. The following setting is an example of detecting changes of *ContainerSet* resources. 17 | 18 | ``` 19 | resources: 20 | - group: whitebox.summerwind.dev 21 | version: v1alpha1 22 | kind: ContainerSet 23 | ``` 24 | 25 | ### Dependent Resources 26 | 27 | If reconciler creates another type of resource based on the specified *Resource*, you need to specify the type of resource to be created as *Dependent Resources*. 28 | 29 | *Dependentr Resources* are specified in `.resources[].dependents` in the configuration file. Multiple types of dependent resource can be specified. The following setting is an example when Controller creates a *Deployment* resource based on *ContainerSet* resource. 30 | 31 | ``` 32 | resources: 33 | - group: whitebox.summerwind.dev 34 | version: v1alpha1 35 | kind: ContainerSet 36 | dependents: 37 | - group: apps 38 | version: v1 39 | kind: Deployment 40 | ``` 41 | 42 | ### Reference Resources 43 | 44 | If you want to refer to other related resources when processing the specified *Resource*, you need to specify the resource type as *Reference Resources*. 45 | 46 | For example, if the *Resource*'s `.spec.configMapRef` value indicates a *ConfigMap* name, you may need to get the *ConfigMap* when the reconciler processes the *Resource*. In such a case, the content of ConfigMap is passed to Reconciler by specifying *ConfigMap* in *Reference Resources*. 47 | 48 | ``` 49 | resources: 50 | - group: whitebox.summerwind.dev 51 | version: v1alpha1 52 | kind: ContainerSet 53 | references: 54 | - group: "" 55 | version: v1 56 | kind: ConfigMap 57 | nameFieldPath: ".spec.configMapRef" 58 | ``` 59 | 60 | ## Configuring Reconciler 61 | 62 | *Reconciler* is responsible for processing the changed resources and generating the next state of the resource. *Reconciler* specifies either an *Exec Handler* that executes an command or an *HTTP Handler* that sends a request to an URL. 63 | 64 | ### Exec Handler 65 | 66 | *Exec Handler* executes arbitrary command to process resources. The executed command should read the resource state from **stdin** and write the next state of the resource to **stdout**. If the process is successful, the exit code must be **0**. Otherwise, the exit code must be **nonzero**. 67 | 68 | The following example uses *Exec Handler* to specify a shell script called `reconciler.sh`. 69 | 70 | ``` 71 | resources: 72 | - group: whitebox.summerwind.dev 73 | version: v1alpha1 74 | kind: ContainerSet 75 | reconciler: 76 | exec: 77 | command: ./reconciler.sh 78 | ``` 79 | 80 | ### HTTP Handler 81 | 82 | *HTTP handler* calls an arbitrary URL to process the resource. The server of the called URL must read the state of the resource from the HTTP request body and write the next state of the resource to the response body. If the processing is successful, the status code must be **200**. Otherwise, the status code must be **other than 200**. 83 | 84 | The following example uses *HTTP Handler* to specify the URL. 85 | 86 | ``` 87 | resources: 88 | - group: whitebox.summerwind.dev 89 | version: v1alpha1 90 | kind: ContainerSet 91 | reconciler: 92 | http: 93 | url: "http://127.0.0.1/reconciler" 94 | ``` 95 | 96 | ### Input and Output 97 | 98 | Whitebox Controller inputs the changed resource as the following JSON format data into *Reconciler*, and expects the same format data to be output from *Reconciler*. Note that the value of `.events` is used only output. 99 | 100 | | Key | Type | Description | 101 | | --- | --- | --- | 102 | | `.object` | Object | JSON representation of the changed resource. | 103 | | `.dependents` | Object | Object containing dependent resource. Object key indicates resource type. | 104 | | `.dependents[*]` | Array | Array containing dependent resources by type. | 105 | | `.dependents[*][*]` | Object | JSON representation of the dependent resource. | 106 | | `.references` | Object | Object containing reference resource. Object key indicates resource type. | 107 | | `.references[*]` | Array | Array containing reference resources by type. | 108 | | `.references[*][*]` | Object | JSON representation of the reference resource. | 109 | | `.events` | Array | Array containing the events for the resource. | 110 | | `.events[*]` | Object | An event for the resource. | 111 | | `.events[*].type` | String | Types of the event ("Normal" or "Warning") | 112 | | `.events[*].reason` | String | The reason this event is generated. It should be in UpperCamelCase format. | 113 | | `.events[*].message` | String | The human readable message. | 114 | 115 | The example of the data is as follows. 116 | 117 | ``` 118 | { 119 | "object": { 120 | "apiVersion": "whitebox.summerwind.dev/v1alpha1", 121 | "kind": "ContainerSet", 122 | "metadata": { 123 | "name": "example", 124 | "namespace": "default", 125 | }, 126 | ... 127 | }, 128 | "dependents": { 129 | "deployment.v1.apps": [ 130 | { 131 | "apiVersion": "apps/v1", 132 | "kind": "Deployment", 133 | "metadata": { 134 | "name": "example", 135 | "namespace": "default", 136 | }, 137 | ... 138 | } 139 | ] 140 | }, 141 | "references": { 142 | "configmap.v1": [ 143 | { 144 | "apiVersion": "v1", 145 | "kind": "ConfigMap", 146 | "metadata": { 147 | "name": "example-config", 148 | "namespace": "default", 149 | }, 150 | ... 151 | } 152 | ] 153 | }, 154 | "events": [ 155 | {"type": "Normal", "reason":"Created", "message":"Create deployment"} 156 | ] 157 | } 158 | ``` 159 | 160 | ### Observe mode 161 | 162 | If you enable the `observe` option as follows, Whitebox Controller does not expect *Reconciler* to output the next state of the resource. This option is useful if you want to detect only changes in resources and execute processing. 163 | 164 | ``` 165 | resources: 166 | - group: whitebox.summerwind.dev 167 | version: v1alpha1 168 | kind: ContainerSet 169 | reconciler: 170 | observe: true 171 | exec: 172 | command: ./observer.sh 173 | ``` 174 | 175 | ### Resync period 176 | 177 | If you need *Reconciler* to periodically check the state of all resources, specify an interval to the `resyncPeriod` as follows. In this example, *Reconciler* will be run every 10 minutes as if all resources have changed. 178 | 179 | ``` 180 | resources: 181 | - group: whitebox.summerwind.dev 182 | version: v1alpha1 183 | kind: ContainerSet 184 | reconciler: 185 | observe: true 186 | exec: 187 | command: ./reconciler.sh 188 | resyncPeriod: 10m 189 | ``` 190 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Whitebox Controller uses YAML format configuration file. By default Whitebox Controller will read the `config.yaml` in the current directory. 4 | 5 | The configuration file consists of two parts: Resource configuration and Webhook configuration. The following sections explain these configurations in detail. 6 | 7 | ## Resource configuration 8 | 9 | The `resources` key in the configuration file defines the settings for each resource. 10 | 11 | For each single resource, Whitebox Controller prepares an internal controller and a webhook endpoint. The following is an example of a resource configuration. 12 | 13 | ```yaml 14 | resources: 15 | # This defines a resource named 'hello'. 16 | - group: whitebox.summerwind.dev 17 | version: v1alpha1 18 | kind: Hello 19 | 20 | # Optional: Dependent resources owned by this resource. 21 | # These resources are monitored for changes. If it detects a change, 22 | # the reconciler will be run. 23 | dependents: 24 | - group: "apps" 25 | version: v1 26 | kind: Deployment 27 | # Optional: If you set this value to true, reconciler will not set 28 | # the owner reference to the dependent resource. 29 | orphan: false 30 | 31 | # Optional: Resources referenced by a specified field of the resource. 32 | # The contents of the resources specified here are passed when the 33 | # reconciler is run, but they do not watched for changes. 34 | # 35 | # For `nameFieldPath`, specify the JSON path of the field name to refer 36 | # to another resource. 37 | references: 38 | - group: "" 39 | version: v1 40 | kind: ConfigMap 41 | nameFieldPath: ".spec.configMapRef.name" 42 | 43 | # Optional: A handler for Reconciler. This handler will be run 44 | # if there is a change in the resource. 45 | reconciler: 46 | exec: 47 | command: "/bin/controller" 48 | args: ["reconcile"] 49 | # Optional: Reconcile the resource again after the specified time. 50 | # The value must be the Go language's duration string. 51 | # See: https://golang.org/pkg/time/#ParseDuration 52 | requeueAfter: 60s 53 | # Optional: If you set this value to true, ignore the output of reconciler. 54 | # This setting is useful when you want to detect only changes without 55 | # managing the resource status. 56 | observe: false 57 | 58 | # Optional: A handler for Finalizer. This handler will be run 59 | # if the resource is going to be deleted. 60 | finalizer: 61 | exec: 62 | command: "/bin/controller" 63 | args: ["finalize"] 64 | 65 | # Optional: resyncPeriod is the period for reconciling all resources. 66 | # The value must be the Go language's duration string. 67 | # See: https://golang.org/pkg/time/#ParseDuration 68 | resyncPeriod: 30s 69 | 70 | # Optional: A handler for resource validation. This handler will be run 71 | # when the server received a request of validation webhook. 72 | validator: 73 | exec: 74 | command: "/bin/controller" 75 | args: ["validate"] 76 | 77 | # Optional: A handler for resource mutation. This handler will be run 78 | # when the server received a request of mutation webhook. 79 | mutator: 80 | exec: 81 | command: "/bin/controller" 82 | args: ["mutate"] 83 | 84 | # Optional: A handler for resource injection. This handler will be run 85 | # when the server received a request of injection webhook. 86 | injector: 87 | exec: 88 | command: "/bin/controller" 89 | args: ["inject"] 90 | # Required: Path of PEM encoded verification key file. 91 | verifyKeyFile: /etc/injector/verify.key 92 | ``` 93 | 94 | ## Webhook configuration 95 | 96 | The `webhook` key in the configuration file defines the settings for admission webhook server. The following is an example of a webhook configuration. 97 | 98 | ```yaml 99 | webhook: 100 | # Optional: The IP address that the webhook server listen for. 101 | # If omitted, '0.0.0.0' will be used. 102 | host: 127.0.0.1 103 | 104 | # Optional: The port number that the webhook server listen for. 105 | # If omitted, a random port wlll be assigned. 106 | port: 443 107 | 108 | # Required: Path of certificate file and private key file for TLS. 109 | # Both of 'certFile' and 'keyFile' must be specified. 110 | tls: 111 | certFile: /etc/tls/tls.crt 112 | keyFile: /etc/tls/tls.key 113 | ``` 114 | 115 | ## Group/Version/Kind 116 | 117 | Group/Version/Kind (GVK) are used in the following fields of configuration. 118 | 119 | - `.resources[*]` 120 | - `.resources[*].dependents` 121 | - `.resources[*].references` 122 | 123 | Group/Version/Kind is used to identify the type of Kubernetes resource. The meaning of each field is as follows. 124 | 125 | ```yaml 126 | # Optional: API group for the resource. 127 | # See: https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-groups 128 | group: whitebox.summerwind.dev 129 | 130 | # Required: API version for the resource. 131 | # See: https://kubernetes.io/docs/concepts/overview/kubernetes-api/#api-versioning 132 | version: v1alpha1 133 | 134 | # Required: Type of the resource. 135 | kind: Test 136 | ``` 137 | 138 | ## Handler configuration 139 | 140 | Handler configuration are used in the following fields of configuration. 141 | 142 | - `.resources[*].reconciler` 143 | - `.resources[*].finalizer` 144 | - `.resources[*].validator` 145 | - `.resources[*].mutator` 146 | - `.resources[*].injector` 147 | 148 | Handler type can be choosed from 'exec' or 'http'. 'exec' executes the specified command and uses its output. 'http' sends the request to the specified URL and uses the response. 149 | 150 | Using both handler type at the same time is not allowed. 151 | 152 | ```yaml 153 | exec: 154 | # Required: The path to command. 155 | command: "/bin/controller" 156 | 157 | # Optional: The arguments for the command. 158 | args: ["reconcile"] 159 | 160 | # Optional: The directory path where the command to be run. 161 | workingDir: /workspace 162 | 163 | # Optional: Environment variables. 164 | env: 165 | name: value 166 | 167 | # Optional: Execution timeout of the command. default is '60s'. 168 | # 169 | # This value of must be the Go language's duration string. 170 | # See: https://golang.org/pkg/time/#ParseDuration 171 | timeout: 30s 172 | 173 | # Optional: If you set this to true, stdin, stdout and stderr of the command will be logged. 174 | debug: false 175 | 176 | http: 177 | # Required: The URL to be sent a request. 178 | url: http://127.0.0.1:3000/reconcile 179 | 180 | # Optional: TLS configuration for the specified URL. 181 | tls: 182 | # Optional: Path of certificate file and private key file for 183 | # TLS client authentication. Both of 'certFile' and 'keyFile' 184 | # must be specified. 185 | certFile: tls/server.pem 186 | keyFile: tls/server-key.pem 187 | 188 | # Optional: CA certificate file to be used for server certificate 189 | # validation. 190 | caCertFile: tls/ca.pem 191 | 192 | # Optional: Execution timeout of the command. default is '60s'. 193 | # 194 | # This value of must be the Go language's duration string. 195 | # See: https://golang.org/pkg/time/#ParseDuration 196 | timeout: 30s 197 | 198 | # Optional: If you set this to true, stdin, stdout and stderr of the command will be logged. 199 | debug: false 200 | ``` 201 | 202 | -------------------------------------------------------------------------------- /webhook/webhook.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/apimachinery/pkg/runtime/schema" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | "sigs.k8s.io/controller-runtime/pkg/manager" 18 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 19 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 20 | 21 | "github.com/dgrijalva/jwt-go" 22 | "github.com/summerwind/whitebox-controller/config" 23 | "github.com/summerwind/whitebox-controller/handler/common" 24 | "github.com/summerwind/whitebox-controller/webhook/injection" 25 | ) 26 | 27 | var ( 28 | timeout = 30 * time.Second 29 | log = logf.Log.WithName("webhook") 30 | ) 31 | 32 | type Server struct { 33 | client.Client 34 | config *config.ServerConfig 35 | mux *http.ServeMux 36 | handler http.Handler 37 | } 38 | 39 | func NewServer(c *config.ServerConfig, mgr manager.Manager) (*Server, error) { 40 | mux := http.NewServeMux() 41 | 42 | if c == nil { 43 | return nil, fmt.Errorf("webhook configuration must be specified") 44 | } 45 | if c.TLS == nil { 46 | return nil, fmt.Errorf("TLS configuration must be specified") 47 | } 48 | 49 | s := &Server{ 50 | config: c, 51 | mux: mux, 52 | handler: wrap(mux), 53 | } 54 | 55 | return s, mgr.Add(s) 56 | } 57 | 58 | func (s *Server) Start(stop <-chan struct{}) error { 59 | cert, err := tls.LoadX509KeyPair(s.config.TLS.CertFile, s.config.TLS.KeyFile) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | tlsConfig := &tls.Config{ 65 | Certificates: []tls.Certificate{cert}, 66 | } 67 | 68 | port := s.config.Port 69 | if port == 0 { 70 | port = 443 71 | } 72 | addr := fmt.Sprintf("%s:%d", s.config.Host, port) 73 | 74 | listener, err := tls.Listen("tcp", addr, tlsConfig) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | server := &http.Server{ 80 | Handler: s.handler, 81 | } 82 | 83 | shutdown := make(chan struct{}) 84 | go func() { 85 | <-stop 86 | 87 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 88 | defer cancel() 89 | 90 | err := server.Shutdown(ctx) 91 | if err != nil { 92 | log.Error(err, "Failed to gracefully shutdown") 93 | } 94 | 95 | close(shutdown) 96 | }() 97 | 98 | log.Info("Starting webhook server", "address", addr) 99 | err = server.Serve(listener) 100 | if err != nil && err != http.ErrServerClosed { 101 | return err 102 | } 103 | 104 | <-shutdown 105 | return nil 106 | } 107 | 108 | func (s *Server) AddValidator(c *config.ResourceConfig) error { 109 | hook, err := newValidationHook(c.Validator) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | p := fmt.Sprintf("%s/validate", getBasePath(c.GroupVersionKind)) 115 | log.Info("Adding validation hook", "path", p) 116 | s.mux.Handle(p, hook) 117 | 118 | return nil 119 | } 120 | 121 | func (s *Server) AddMutator(c *config.ResourceConfig) error { 122 | hook, err := newMutationHook(c.Mutator) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | p := fmt.Sprintf("%s/mutate", getBasePath(c.GroupVersionKind)) 128 | log.Info("Adding mutation hook", "path", p) 129 | s.mux.Handle(p, hook) 130 | 131 | return nil 132 | } 133 | 134 | func (s *Server) AddInjector(c *config.ResourceConfig) error { 135 | hook, err := newInjectionHook(c.Injector, s.Client) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | p := fmt.Sprintf("%s/inject", getBasePath(c.GroupVersionKind)) 141 | log.Info("Adding injection hook", "path", p) 142 | s.mux.Handle(p, hook) 143 | 144 | return nil 145 | } 146 | 147 | func (s *Server) InjectClient(c client.Client) error { 148 | s.Client = c 149 | return nil 150 | } 151 | 152 | func wrap(h http.Handler) http.Handler { 153 | return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 154 | reqPath := req.URL.Path 155 | start := time.Now() 156 | 157 | defer func() { 158 | d := time.Now().Sub(start).Seconds() 159 | log.Info("Requesting webhook handler", "path", reqPath, "duration", d) 160 | }() 161 | 162 | h.ServeHTTP(resp, req) 163 | }) 164 | } 165 | 166 | func getBasePath(gvk schema.GroupVersionKind) string { 167 | if gvk.Group == "" { 168 | return fmt.Sprintf("/%s/%s", gvk.Version, strings.ToLower(gvk.Kind)) 169 | } else { 170 | return fmt.Sprintf("/%s/%s/%s", gvk.Group, gvk.Version, strings.ToLower(gvk.Kind)) 171 | } 172 | } 173 | 174 | func newValidationHook(hc *config.HandlerConfig) (http.Handler, error) { 175 | h, err := common.NewAdmissionRequestHandler(hc) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | validator := func(ctx context.Context, req admission.Request) admission.Response { 181 | res, err := h.HandleAdmissionRequest(req) 182 | if err != nil { 183 | return admission.ValidationResponse(false, fmt.Sprintf("handler error: %v", err)) 184 | } 185 | 186 | return res 187 | } 188 | 189 | hook := &admission.Webhook{Handler: admission.HandlerFunc(validator)} 190 | hook.InjectLogger(log) 191 | 192 | return hook, nil 193 | } 194 | 195 | func newMutationHook(hc *config.HandlerConfig) (http.Handler, error) { 196 | h, err := common.NewAdmissionRequestHandler(hc) 197 | if err != nil { 198 | return nil, err 199 | } 200 | 201 | mutator := func(ctx context.Context, req admission.Request) admission.Response { 202 | res, err := h.HandleAdmissionRequest(req) 203 | if err != nil { 204 | return admission.Errored(http.StatusInternalServerError, fmt.Errorf("handler error: %v", err)) 205 | } 206 | 207 | return res 208 | } 209 | 210 | hook := &admission.Webhook{Handler: admission.HandlerFunc(mutator)} 211 | hook.InjectLogger(log) 212 | 213 | return hook, nil 214 | } 215 | 216 | func newInjectionHook(ic *config.InjectorConfig, client client.Client) (http.Handler, error) { 217 | var ( 218 | key interface{} 219 | err error 220 | ) 221 | 222 | buf, err := ioutil.ReadFile(ic.VerifyKeyFile) 223 | if err != nil { 224 | return nil, fmt.Errorf("failed to read verification key: %v", err) 225 | } 226 | 227 | key, err = jwt.ParseRSAPublicKeyFromPEM(buf) 228 | if err != nil { 229 | key, err = jwt.ParseECPublicKeyFromPEM(buf) 230 | if err != nil { 231 | return nil, errors.New("unsupported verification key type") 232 | } 233 | } 234 | 235 | keyHandler := func(token *jwt.Token) (interface{}, error) { 236 | switch token.Method.Alg() { 237 | case "ES256": 238 | return key, nil 239 | case "RS256": 240 | return key, nil 241 | } 242 | 243 | return nil, errors.New("unsupported signing key type") 244 | } 245 | 246 | h, err := common.NewInjectionRequestHandler(&ic.HandlerConfig) 247 | if err != nil { 248 | return nil, err 249 | } 250 | 251 | handler := func(ctx context.Context, req injection.Request) (injection.Response, error) { 252 | res, err := h.HandleInjectionRequest(req) 253 | if err != nil { 254 | return res, errors.New("Handler error") 255 | } 256 | 257 | return res, nil 258 | } 259 | 260 | hook := &injection.Webhook{ 261 | Handler: injection.HandlerFunc(handler), 262 | KeyHandler: keyHandler, 263 | } 264 | hook.InjectClient(client) 265 | hook.InjectLogger(log) 266 | 267 | return hook, nil 268 | } 269 | 270 | func Unpack(r runtime.RawExtension, v interface{}) error { 271 | err := json.Unmarshal(r.Raw, v) 272 | if err != nil { 273 | return fmt.Errorf("failed to unpack: %v", err) 274 | } 275 | 276 | return nil 277 | } 278 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "time" 8 | 9 | "github.com/ghodss/yaml" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | 12 | "github.com/summerwind/whitebox-controller/handler" 13 | ) 14 | 15 | type Config struct { 16 | Name string `json:"name,omitempty"` 17 | Resources []*ResourceConfig `json:"resources"` 18 | Webhook *ServerConfig `json:"webhook,omitempty"` 19 | } 20 | 21 | func LoadFile(p string) (*Config, error) { 22 | buf, err := ioutil.ReadFile(p) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to read file %s: %v", p, err) 25 | } 26 | 27 | c := &Config{} 28 | err = yaml.Unmarshal(buf, c) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to parse file %s: %v", p, err) 31 | } 32 | 33 | return c, nil 34 | } 35 | 36 | func (c *Config) Validate() error { 37 | if len(c.Resources) == 0 { 38 | return errors.New("at least one resource must be specified") 39 | } 40 | 41 | for i, r := range c.Resources { 42 | err := r.Validate() 43 | if err != nil { 44 | return fmt.Errorf("resources[%d]: %v", i, err) 45 | } 46 | } 47 | 48 | if c.Webhook != nil { 49 | err := c.Webhook.Validate() 50 | if err != nil { 51 | return fmt.Errorf("webhook: %v", err) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | 58 | type ResourceConfig struct { 59 | schema.GroupVersionKind 60 | 61 | Dependents []DependentConfig `json:"dependents,omitempty"` 62 | References []ReferenceConfig `json:"references,omitempty"` 63 | 64 | Reconciler *ReconcilerConfig `json:"reconciler,omitempty"` 65 | Finalizer *HandlerConfig `json:"finalizer,omitempty"` 66 | ResyncPeriod string `json:"resyncPeriod,omitempty"` 67 | 68 | Validator *HandlerConfig `json:"validator,omitempty"` 69 | Mutator *HandlerConfig `json:"mutator,omitempty"` 70 | Injector *InjectorConfig `json:"injector,omitempty"` 71 | } 72 | 73 | func (c *ResourceConfig) Validate() error { 74 | if c.GroupVersionKind.Empty() { 75 | return errors.New("resource is empty") 76 | } 77 | 78 | for i, dep := range c.Dependents { 79 | if dep.Empty() { 80 | return fmt.Errorf("dependents[%d] is empty", i) 81 | } 82 | } 83 | 84 | for i, ref := range c.References { 85 | err := ref.Validate() 86 | if err != nil { 87 | return fmt.Errorf("references[%d]: %v", i, err) 88 | } 89 | } 90 | 91 | if c.Reconciler != nil { 92 | err := c.Reconciler.Validate() 93 | if err != nil { 94 | return fmt.Errorf("reconciler: %v", err) 95 | } 96 | } 97 | 98 | if c.Finalizer != nil { 99 | err := c.Finalizer.Validate() 100 | if err != nil { 101 | return fmt.Errorf("finalizer: %v", err) 102 | } 103 | } 104 | 105 | if c.ResyncPeriod != "" { 106 | _, err := time.ParseDuration(c.ResyncPeriod) 107 | if err != nil { 108 | return fmt.Errorf("invalid resync period: %v", err) 109 | } 110 | } 111 | 112 | if c.Validator != nil { 113 | err := c.Validator.Validate() 114 | if err != nil { 115 | return fmt.Errorf("validator: %v", err) 116 | } 117 | } 118 | 119 | if c.Mutator != nil { 120 | err := c.Mutator.Validate() 121 | if err != nil { 122 | return fmt.Errorf("mutator: %v", err) 123 | } 124 | } 125 | 126 | if c.Injector != nil { 127 | err := c.Injector.Validate() 128 | if err != nil { 129 | return fmt.Errorf("injector: %v", err) 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | type DependentConfig struct { 137 | schema.GroupVersionKind 138 | Orphan bool `json:"orphan"` 139 | } 140 | 141 | func (c *DependentConfig) Validate() error { 142 | if c.GroupVersionKind.Empty() { 143 | return errors.New("resource is empty") 144 | } 145 | 146 | return nil 147 | } 148 | 149 | type ReferenceConfig struct { 150 | schema.GroupVersionKind 151 | NameFieldPath string `json:"nameFieldPath"` 152 | } 153 | 154 | func (c *ReferenceConfig) Validate() error { 155 | if c.GroupVersionKind.Empty() { 156 | return errors.New("resource is empty") 157 | } 158 | 159 | if c.NameFieldPath == "" { 160 | return errors.New("nameFieldPath must be specified") 161 | } 162 | 163 | return nil 164 | } 165 | 166 | type ReconcilerConfig struct { 167 | HandlerConfig 168 | RequeueAfter string `json:"requeueAfter"` 169 | Observe bool `json:"observe"` 170 | } 171 | 172 | func (c *ReconcilerConfig) Validate() error { 173 | if c.RequeueAfter != "" { 174 | _, err := time.ParseDuration(c.RequeueAfter) 175 | if err != nil { 176 | return fmt.Errorf("invalid requeueAfter: %v", err) 177 | } 178 | } 179 | 180 | return c.HandlerConfig.Validate() 181 | } 182 | 183 | type InjectorConfig struct { 184 | HandlerConfig 185 | VerifyKeyFile string `json:"verifyKeyFile"` 186 | } 187 | 188 | func (c *InjectorConfig) Validate() error { 189 | if c.VerifyKeyFile == "" { 190 | return errors.New("verification key file must be specified") 191 | } 192 | 193 | return c.HandlerConfig.Validate() 194 | } 195 | 196 | type HandlerConfig struct { 197 | Exec *ExecHandlerConfig `json:"exec"` 198 | HTTP *HTTPHandlerConfig `json:"http"` 199 | 200 | StateHandler handler.StateHandler `json:"-"` 201 | AdmissionRequestHandler handler.AdmissionRequestHandler `json:"-"` 202 | InjectionRequestHandler handler.InjectionRequestHandler `json:"-"` 203 | } 204 | 205 | func (c *HandlerConfig) Validate() error { 206 | specified := 0 207 | 208 | if c.Exec != nil { 209 | specified++ 210 | } 211 | if c.HTTP != nil { 212 | specified++ 213 | } 214 | if c.StateHandler != nil || c.AdmissionRequestHandler != nil || c.InjectionRequestHandler != nil { 215 | specified++ 216 | } 217 | 218 | if specified == 0 { 219 | return errors.New("handler must be specified") 220 | } 221 | if specified > 1 { 222 | return errors.New("exactly one handler must be specified") 223 | } 224 | 225 | if c.Exec != nil { 226 | err := c.Exec.Validate() 227 | if err != nil { 228 | return err 229 | } 230 | } 231 | 232 | if c.HTTP != nil { 233 | err := c.HTTP.Validate() 234 | if err != nil { 235 | return err 236 | } 237 | } 238 | 239 | return nil 240 | } 241 | 242 | type ExecHandlerConfig struct { 243 | Command string `json:"command"` 244 | Args []string `json:"args"` 245 | WorkingDir string `json:"workingDir"` 246 | Env map[string]string `json:"env"` 247 | Timeout string `json:"timeout"` 248 | Debug bool `json:"debug"` 249 | } 250 | 251 | func (c ExecHandlerConfig) Validate() error { 252 | if c.Command == "" { 253 | return errors.New("command must be specified") 254 | } 255 | 256 | if c.Timeout != "" { 257 | _, err := time.ParseDuration(c.Timeout) 258 | if err != nil { 259 | return fmt.Errorf("invalid timeout: %v", err) 260 | } 261 | } 262 | 263 | return nil 264 | } 265 | 266 | type HTTPHandlerConfig struct { 267 | URL string `json:"url"` 268 | TLS *TLSConfig `json:"tls,omitempty"` 269 | Timeout string `json:"timeout"` 270 | Debug bool `json:"debug"` 271 | } 272 | 273 | func (c HTTPHandlerConfig) Validate() error { 274 | if c.URL == "" { 275 | return errors.New("url must be specified") 276 | } 277 | 278 | if c.Timeout != "" { 279 | _, err := time.ParseDuration(c.Timeout) 280 | if err != nil { 281 | return fmt.Errorf("invalid timeout: %v", err) 282 | } 283 | } 284 | 285 | return nil 286 | } 287 | 288 | type FuncHandlerConfig struct { 289 | Handler handler.Handler `json:"-"` 290 | } 291 | 292 | func (c *FuncHandlerConfig) Validate() error { 293 | if c.Handler == nil { 294 | return errors.New("handler must be specified") 295 | } 296 | 297 | return nil 298 | } 299 | 300 | type ServerConfig struct { 301 | Host string `json:"host"` 302 | Port int `json:"port"` 303 | TLS *TLSConfig `json:"tls"` 304 | } 305 | 306 | func (c *ServerConfig) Validate() error { 307 | if c.Port == 0 { 308 | return errors.New("port must be specified") 309 | } 310 | 311 | if c.TLS != nil { 312 | err := c.TLS.Validate() 313 | if err != nil { 314 | return fmt.Errorf("tls: %v", err) 315 | } 316 | } 317 | 318 | return nil 319 | } 320 | 321 | type TLSConfig struct { 322 | CertFile string `json:"certFile"` 323 | KeyFile string `json:"keyFile"` 324 | CACertFile string `json:"caCertFile"` 325 | } 326 | 327 | func (c *TLSConfig) Validate() error { 328 | if c.CertFile == "" && c.KeyFile != "" { 329 | return errors.New("certificate file must be specified") 330 | } 331 | 332 | if c.CertFile != "" && c.KeyFile == "" { 333 | return errors.New("certificate key file must be specified") 334 | } 335 | 336 | return nil 337 | } 338 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2019 Moto Ishizawa 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | 8 | "k8s.io/apimachinery/pkg/runtime/schema" 9 | ) 10 | 11 | func TestConfigValidate(t *testing.T) { 12 | var ( 13 | err error 14 | c *Config 15 | ) 16 | 17 | RegisterTestingT(t) 18 | 19 | // Valid 20 | c = newTestConfig() 21 | err = c.Validate() 22 | Expect(err).NotTo(HaveOccurred()) 23 | 24 | // No resources 25 | c = newTestConfig() 26 | c.Resources = []*ResourceConfig{} 27 | err = c.Validate() 28 | Expect(err).To(HaveOccurred()) 29 | 30 | // Invalid resource 31 | c = newTestConfig() 32 | c.Resources[0].GroupVersionKind = schema.GroupVersionKind{} 33 | err = c.Validate() 34 | Expect(err).To(HaveOccurred()) 35 | 36 | // Invalid webhook 37 | c = newTestConfig() 38 | c.Webhook.Port = 0 39 | err = c.Validate() 40 | Expect(err).To(HaveOccurred()) 41 | } 42 | 43 | func TestResourceConfigValidate(t *testing.T) { 44 | var ( 45 | err error 46 | c *ResourceConfig 47 | ) 48 | 49 | RegisterTestingT(t) 50 | 51 | // Valid 52 | c = newTestConfig().Resources[0] 53 | err = c.Validate() 54 | Expect(err).NotTo(HaveOccurred()) 55 | 56 | // Invalid GVK 57 | c = newTestConfig().Resources[0] 58 | c.GroupVersionKind = schema.GroupVersionKind{} 59 | err = c.Validate() 60 | Expect(err).To(HaveOccurred()) 61 | 62 | // Invalid dependents 63 | c = newTestConfig().Resources[0] 64 | c.Dependents[0].GroupVersionKind = schema.GroupVersionKind{} 65 | err = c.Validate() 66 | Expect(err).To(HaveOccurred()) 67 | 68 | // Invalid references 69 | c = newTestConfig().Resources[0] 70 | c.References[0].GroupVersionKind = schema.GroupVersionKind{} 71 | err = c.Validate() 72 | Expect(err).To(HaveOccurred()) 73 | 74 | // Invalid reconciler 75 | c = newTestConfig().Resources[0] 76 | c.Reconciler.HandlerConfig.Exec = nil 77 | err = c.Validate() 78 | Expect(err).To(HaveOccurred()) 79 | 80 | // Invalid finalizer 81 | c = newTestConfig().Resources[0] 82 | c.Finalizer.Exec = nil 83 | err = c.Validate() 84 | Expect(err).To(HaveOccurred()) 85 | 86 | // Invalid resync period 87 | c = newTestConfig().Resources[0] 88 | c.ResyncPeriod = "invalid" 89 | err = c.Validate() 90 | Expect(err).To(HaveOccurred()) 91 | 92 | // Invalid validator 93 | c = newTestConfig().Resources[0] 94 | c.Validator.Exec = nil 95 | err = c.Validate() 96 | Expect(err).To(HaveOccurred()) 97 | 98 | // Invalid mutator 99 | c = newTestConfig().Resources[0] 100 | c.Mutator.Exec = nil 101 | err = c.Validate() 102 | Expect(err).To(HaveOccurred()) 103 | 104 | // Invalid injector 105 | c = newTestConfig().Resources[0] 106 | c.Injector.Exec = nil 107 | err = c.Validate() 108 | Expect(err).To(HaveOccurred()) 109 | } 110 | 111 | func TestDependentConfigValidate(t *testing.T) { 112 | var ( 113 | err error 114 | c DependentConfig 115 | ) 116 | 117 | RegisterTestingT(t) 118 | 119 | // Valid 120 | c = newTestConfig().Resources[0].Dependents[0] 121 | err = c.Validate() 122 | Expect(err).NotTo(HaveOccurred()) 123 | 124 | // Empty GVK 125 | c = newTestConfig().Resources[0].Dependents[0] 126 | c.GroupVersionKind = schema.GroupVersionKind{} 127 | err = c.Validate() 128 | Expect(err).To(HaveOccurred()) 129 | } 130 | 131 | func TestReferenceConfigValidate(t *testing.T) { 132 | var ( 133 | err error 134 | c ReferenceConfig 135 | ) 136 | 137 | RegisterTestingT(t) 138 | 139 | // Valid 140 | c = newTestConfig().Resources[0].References[0] 141 | err = c.Validate() 142 | Expect(err).NotTo(HaveOccurred()) 143 | 144 | // Empty GVK 145 | c = newTestConfig().Resources[0].References[0] 146 | c.GroupVersionKind = schema.GroupVersionKind{} 147 | err = c.Validate() 148 | Expect(err).To(HaveOccurred()) 149 | 150 | // Empty name field path 151 | c = newTestConfig().Resources[0].References[0] 152 | c.NameFieldPath = "" 153 | err = c.Validate() 154 | Expect(err).To(HaveOccurred()) 155 | } 156 | 157 | func TestReconcilerConfigValidate(t *testing.T) { 158 | var ( 159 | err error 160 | c *ReconcilerConfig 161 | ) 162 | 163 | RegisterTestingT(t) 164 | 165 | // Valid 166 | c = newTestConfig().Resources[0].Reconciler 167 | err = c.Validate() 168 | Expect(err).NotTo(HaveOccurred()) 169 | 170 | // Invalid requeue after 171 | c = newTestConfig().Resources[0].Reconciler 172 | c.RequeueAfter = "invalid" 173 | err = c.Validate() 174 | Expect(err).To(HaveOccurred()) 175 | 176 | // Invalid handler 177 | c = newTestConfig().Resources[0].Reconciler 178 | c.HandlerConfig.Exec = nil 179 | err = c.Validate() 180 | Expect(err).To(HaveOccurred()) 181 | } 182 | 183 | func TestInjectorConfigValidate(t *testing.T) { 184 | var ( 185 | err error 186 | c *InjectorConfig 187 | ) 188 | 189 | RegisterTestingT(t) 190 | 191 | // Valid 192 | c = newTestConfig().Resources[0].Injector 193 | err = c.Validate() 194 | Expect(err).NotTo(HaveOccurred()) 195 | 196 | // Invalid verification key file 197 | c = newTestConfig().Resources[0].Injector 198 | c.VerifyKeyFile = "" 199 | err = c.Validate() 200 | Expect(err).To(HaveOccurred()) 201 | 202 | // Invalid handler 203 | c = newTestConfig().Resources[0].Injector 204 | c.HandlerConfig.Exec = nil 205 | err = c.Validate() 206 | Expect(err).To(HaveOccurred()) 207 | } 208 | 209 | func TestHandlerConfig(t *testing.T) { 210 | var ( 211 | err error 212 | c *HandlerConfig 213 | ) 214 | 215 | RegisterTestingT(t) 216 | 217 | // Valid 218 | c = &HandlerConfig{ 219 | Exec: &ExecHandlerConfig{ 220 | Command: "/bin/controller", 221 | Args: []string{"command"}, 222 | WorkingDir: "", 223 | Env: map[string]string{}, 224 | Timeout: "30s", 225 | Debug: true, 226 | }, 227 | } 228 | err = c.Validate() 229 | Expect(err).NotTo(HaveOccurred()) 230 | 231 | // No handlers 232 | c = &HandlerConfig{} 233 | err = c.Validate() 234 | Expect(err).To(HaveOccurred()) 235 | 236 | // Multiple handlers 237 | c = &HandlerConfig{ 238 | Exec: &ExecHandlerConfig{}, 239 | HTTP: &HTTPHandlerConfig{}, 240 | } 241 | err = c.Validate() 242 | Expect(err).To(HaveOccurred()) 243 | 244 | // Invalid exec handler 245 | c = &HandlerConfig{ 246 | Exec: &ExecHandlerConfig{}, 247 | } 248 | err = c.Validate() 249 | Expect(err).To(HaveOccurred()) 250 | 251 | // Invalid HTTP handler 252 | c = &HandlerConfig{ 253 | HTTP: &HTTPHandlerConfig{}, 254 | } 255 | err = c.Validate() 256 | Expect(err).To(HaveOccurred()) 257 | } 258 | 259 | func TestExecHandlerConfig(t *testing.T) { 260 | var ( 261 | err error 262 | c *ExecHandlerConfig 263 | ) 264 | 265 | // Valid 266 | c = &ExecHandlerConfig{ 267 | Command: "/bin/controller", 268 | Args: []string{"command"}, 269 | WorkingDir: "", 270 | Env: map[string]string{}, 271 | Timeout: "30s", 272 | Debug: true, 273 | } 274 | err = c.Validate() 275 | Expect(err).NotTo(HaveOccurred()) 276 | 277 | // Invalid command 278 | c = &ExecHandlerConfig{ 279 | Command: "", 280 | Timeout: "30s", 281 | } 282 | err = c.Validate() 283 | Expect(err).To(HaveOccurred()) 284 | 285 | // Invalid timeout 286 | c = &ExecHandlerConfig{ 287 | Command: "/bin/controller", 288 | Timeout: "invalid", 289 | } 290 | err = c.Validate() 291 | Expect(err).To(HaveOccurred()) 292 | } 293 | 294 | func TestHTTPHandlerConfig(t *testing.T) { 295 | var ( 296 | err error 297 | c *HTTPHandlerConfig 298 | ) 299 | 300 | // Valid 301 | c = &HTTPHandlerConfig{ 302 | URL: "http://127.0.0.1:8080", 303 | TLS: &TLSConfig{ 304 | CACertFile: "ca.pem", 305 | }, 306 | Timeout: "30s", 307 | Debug: true, 308 | } 309 | err = c.Validate() 310 | Expect(err).NotTo(HaveOccurred()) 311 | 312 | // Invalid URL 313 | c = &HTTPHandlerConfig{ 314 | URL: "", 315 | Timeout: "30s", 316 | Debug: true, 317 | } 318 | err = c.Validate() 319 | Expect(err).To(HaveOccurred()) 320 | 321 | // Invalid timeout 322 | c = &HTTPHandlerConfig{ 323 | URL: "http://127.0.0.1:8080", 324 | Timeout: "invalid", 325 | Debug: true, 326 | } 327 | err = c.Validate() 328 | Expect(err).To(HaveOccurred()) 329 | } 330 | 331 | func TestServerConfig(t *testing.T) { 332 | var ( 333 | err error 334 | c *ServerConfig 335 | ) 336 | 337 | // Valid 338 | c = &ServerConfig{ 339 | Host: "127.0.0.1", 340 | Port: 443, 341 | TLS: &TLSConfig{ 342 | CertFile: "server.pem", 343 | KeyFile: "server-key.pem", 344 | }, 345 | } 346 | err = c.Validate() 347 | Expect(err).NotTo(HaveOccurred()) 348 | 349 | // Invalid port 350 | c = &ServerConfig{ 351 | Host: "127.0.0.1", 352 | TLS: &TLSConfig{ 353 | CertFile: "server.pem", 354 | KeyFile: "server-key.pem", 355 | }, 356 | } 357 | err = c.Validate() 358 | Expect(err).To(HaveOccurred()) 359 | 360 | // Invalid TLS config 361 | c = &ServerConfig{ 362 | Host: "127.0.0.1", 363 | Port: 443, 364 | TLS: &TLSConfig{ 365 | CertFile: "server.pem", 366 | }, 367 | } 368 | err = c.Validate() 369 | Expect(err).To(HaveOccurred()) 370 | } 371 | 372 | func TestTLSConfig(t *testing.T) { 373 | var ( 374 | err error 375 | c *TLSConfig 376 | ) 377 | 378 | // Valid 379 | c = &TLSConfig{ 380 | CertFile: "server.pem", 381 | KeyFile: "server-key.pem", 382 | } 383 | err = c.Validate() 384 | Expect(err).NotTo(HaveOccurred()) 385 | 386 | // Invalid certificate file 387 | c = &TLSConfig{ 388 | KeyFile: "server-key.pem", 389 | } 390 | err = c.Validate() 391 | Expect(err).To(HaveOccurred()) 392 | 393 | // Invalid certificate key file 394 | c = &TLSConfig{ 395 | CertFile: "server.pem", 396 | } 397 | err = c.Validate() 398 | Expect(err).To(HaveOccurred()) 399 | } 400 | 401 | func newTestConfig() *Config { 402 | return &Config{ 403 | Resources: []*ResourceConfig{ 404 | { 405 | GroupVersionKind: schema.GroupVersionKind{ 406 | Group: "example.com", 407 | Version: "v1alpha1", 408 | Kind: "Test", 409 | }, 410 | Dependents: []DependentConfig{ 411 | DependentConfig{ 412 | GroupVersionKind: schema.GroupVersionKind{ 413 | Group: "example.org", 414 | Version: "v1alpha1", 415 | Kind: "ResourceA", 416 | }, 417 | Orphan: false, 418 | }, 419 | DependentConfig{ 420 | GroupVersionKind: schema.GroupVersionKind{ 421 | Group: "example.org", 422 | Version: "v1alpha1", 423 | Kind: "ResourceB", 424 | }, 425 | Orphan: false, 426 | }, 427 | }, 428 | References: []ReferenceConfig{ 429 | ReferenceConfig{ 430 | GroupVersionKind: schema.GroupVersionKind{ 431 | Group: "example.org", 432 | Version: "v1alpha1", 433 | Kind: "ResourceX", 434 | }, 435 | NameFieldPath: ".spec.x", 436 | }, 437 | ReferenceConfig{ 438 | GroupVersionKind: schema.GroupVersionKind{ 439 | Group: "example.org", 440 | Version: "v1alpha1", 441 | Kind: "ResourceY", 442 | }, 443 | NameFieldPath: ".spec.y", 444 | }, 445 | }, 446 | Reconciler: &ReconcilerConfig{ 447 | HandlerConfig: HandlerConfig{ 448 | Exec: &ExecHandlerConfig{ 449 | Command: "/bin/controller", 450 | Args: []string{"reconcile"}, 451 | WorkingDir: "", 452 | Env: map[string]string{}, 453 | Timeout: "30s", 454 | Debug: true, 455 | }, 456 | }, 457 | RequeueAfter: "30s", 458 | Observe: false, 459 | }, 460 | Finalizer: &HandlerConfig{ 461 | Exec: &ExecHandlerConfig{ 462 | Command: "/bin/controller", 463 | Args: []string{"finalize"}, 464 | WorkingDir: "", 465 | Env: map[string]string{}, 466 | Timeout: "30s", 467 | Debug: true, 468 | }, 469 | }, 470 | ResyncPeriod: "60m", 471 | Validator: &HandlerConfig{ 472 | Exec: &ExecHandlerConfig{ 473 | Command: "/bin/controller", 474 | Args: []string{"validate"}, 475 | WorkingDir: "", 476 | Env: map[string]string{}, 477 | Timeout: "30s", 478 | Debug: true, 479 | }, 480 | }, 481 | Mutator: &HandlerConfig{ 482 | Exec: &ExecHandlerConfig{ 483 | Command: "/bin/controller", 484 | Args: []string{"mutate"}, 485 | WorkingDir: "", 486 | Env: map[string]string{}, 487 | Timeout: "30s", 488 | Debug: true, 489 | }, 490 | }, 491 | Injector: &InjectorConfig{ 492 | HandlerConfig: HandlerConfig{ 493 | Exec: &ExecHandlerConfig{ 494 | Command: "/bin/controller", 495 | Args: []string{"inject"}, 496 | WorkingDir: "", 497 | Env: map[string]string{}, 498 | Timeout: "30s", 499 | Debug: true, 500 | }, 501 | }, 502 | VerifyKeyFile: "verify-key.pem", 503 | }, 504 | }, 505 | }, 506 | Webhook: &ServerConfig{ 507 | Host: "127.0.0.1", 508 | Port: 443, 509 | TLS: &TLSConfig{ 510 | CertFile: "server.pem", 511 | KeyFile: "server-key.pem", 512 | }, 513 | }, 514 | } 515 | } 516 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide shows you how to implement a controller using Whitebox Controller. 4 | 5 | ## Before you begin 6 | 7 | - Make sure you have a Kubernetes cluster 8 | - Install [jq](https://stedolan.github.io/jq/) 9 | 10 | ## What is Whitebox Controller? 11 | 12 | Whitebox Controller is an extensible general purpose controller for Kuberentes. With Whitebox Controller, you can implement a Kubernetes controller simply by creating the command that you need to proccessing resource state, and the configuration file. 13 | 14 | ## Install 15 | 16 | First install the `whitebox-controller` command. You can download the binary from [GitHub Release](https://github.com/summerwind/whitebox-controller/releases). 17 | 18 | **For Linux** 19 | 20 | ``` 21 | $ curl -L -O https://github.com/summerwind/whitebox-controller/releases/latest/download/whitebox-controller-linux-amd64.tar.gz 22 | $ tar zxvf whitebox-controller-linux-amd64.tar.gz 23 | $ mv whitebox-controller /usr/local/bin/ 24 | $ mv whitebox-gen /usr/local/bin/ 25 | ``` 26 | 27 | **For macOS** 28 | 29 | ``` 30 | $ curl -L -O https://github.com/summerwind/whitebox-controller/releases/latest/download/whitebox-controller-darwin-amd64.tar.gz 31 | $ tar zxvf whitebox-controller-darwin-amd64.tar.gz 32 | $ mv whitebox-controller /usr/local/bin/ 33 | $ mv whitebox-gen /usr/local/bin/ 34 | ``` 35 | 36 | ## Creating configuration file 37 | 38 | To begin implementing a controller, create a configuration file first. The configuration file defines which resources you want to watch the changes and which commands you want to execute when changes are made. 39 | 40 | Create a configuration file as follows. This file defines the configuration to watches the status of 'Hello' custom resource and to execute the command `reconciler.sh` when changes are made. 41 | 42 | ``` 43 | $ vim config.yaml 44 | ``` 45 | ``` 46 | resources: 47 | - group: whitebox.summerwind.dev 48 | version: v1alpha1 49 | kind: Hello 50 | reconciler: 51 | exec: 52 | command: ./reconciler.sh 53 | debug: true 54 | ``` 55 | 56 | ## Creating your custom resource 57 | 58 | Add 'Hello' custom resource on Kubernetes. To add a custom resource, you need a manifest file that contains CustomResourceDefinition resource as follows. 59 | 60 | ``` 61 | $ vim crd.yaml 62 | ``` 63 | ``` 64 | apiVersion: apiextensions.k8s.io/v1beta1 65 | kind: CustomResourceDefinition 66 | metadata: 67 | name: hello.whitebox.summerwind.dev 68 | spec: 69 | group: whitebox.summerwind.dev 70 | versions: 71 | - name: v1alpha1 72 | served: true 73 | storage: true 74 | names: 75 | kind: Hello 76 | plural: hello 77 | singular: hello 78 | scope: Namespaced 79 | ``` 80 | 81 | Once you have a manifest file, apply it to Kubernetes. 82 | 83 | ``` 84 | $ kubectl apply -f crd.yaml 85 | customresourcedefinition.apiextensions.k8s.io "hello.whitebox.summerwind.dev" created 86 | ``` 87 | 88 | Now that 'Hello' custom resource is available. Let's create a 'Hello' resource on Kubernetes. 89 | 90 | ``` 91 | $ vim hello.yaml 92 | ``` 93 | ``` 94 | apiVersion: whitebox.summerwind.dev/v1alpha1 95 | kind: Hello 96 | metadata: 97 | name: hello 98 | spec: 99 | message: "Hello World" 100 | ``` 101 | ``` 102 | $ kubectl apply -f hello.yaml 103 | hello.whitebox.summerwind.dev "hello" created 104 | ``` 105 | 106 | You can see that the resource has been created on Kubernetes. 107 | 108 | ``` 109 | $ kubectl get hello 110 | NAME AGE 111 | hello 10m 112 | ``` 113 | 114 | ## Building your reconciler 115 | 116 | Next, implement `reconciler.sh`, which is a command to be executed when the status of 'Hello' resource changes. **Reconciler** is a component in the Kubernetes controller that is responsible for coordinating the state between resources. Whitebox Controller can replace reconciler with any command. 117 | 118 | Before implementing your reconciler, let's understand the inputs and outputs. Whitebox Controller executes a command when changes are made in the watched resource, and writes the changed resource information with JSON format as follows to the stdin of the command. 119 | 120 | ``` 121 | { 122 | "object": { 123 | "apiVersion": "whitebox.summerwind.dev/v1alpha1", 124 | "kind": "Hello", 125 | "metadata": { 126 | "annotations": { 127 | "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"whitebox.summerwind.dev/v1alpha1\",\"kind\":\"Hello\",\"metadata\":{\"annotations\":{},\"name\":\"hello\",\"namespace\":\"default\"},\"spec\":{\"message\":\"Hello World\"}}\n" 128 | }, 129 | "creationTimestamp": "2019-04-01T04:02:01Z", 130 | "generation": 1, 131 | "name": "hello", 132 | "namespace": "default", 133 | "resourceVersion": "14412715", 134 | "selfLink": "/apis/whitebox.summerwind.dev/v1alpha1/namespaces/default/hello/hello", 135 | "uid": "e6f446eb-5432-11e9-afad-42010a8c01f3" 136 | }, 137 | "spec": { 138 | "message": "Hello World" 139 | } 140 | } 141 | } 142 | ``` 143 | 144 | The command should read the resource state from stdin, modify its state if necessary, and write it out with JSON format to stdout at the end. The output here is applied to the resource on Kuberenetes by Whitebox Controller. For example, if you need to add the value `completed` to the `.resource.status.phase` field of state, command will write out the following state to stdout. 145 | 146 | ``` 147 | { 148 | "object": { 149 | "apiVersion": "whitebox.summerwind.dev/v1alpha1", 150 | "kind": "Hello", 151 | "metadata": { 152 | "annotations": { 153 | "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"whitebox.summerwind.dev/v1alpha1\",\"kind\":\"Hello\",\"metadata\":{\"annotations\":{},\"name\":\"hello\",\"namespace\":\"default\"},\"spec\":{\"message\":\"Hello World\"}}\n" 154 | }, 155 | "creationTimestamp": "2019-04-01T04:02:01Z", 156 | "generation": 1, 157 | "name": "hello", 158 | "namespace": "default", 159 | "resourceVersion": "14412715", 160 | "selfLink": "/apis/whitebox.summerwind.dev/v1alpha1/namespaces/default/hello/hello", 161 | "uid": "e6f446eb-5432-11e9-afad-42010a8c01f3" 162 | }, 163 | "spec": { 164 | "message": "Hello World" 165 | }, 166 | "status": { 167 | "phase": "completed" 168 | } 169 | } 170 | } 171 | ``` 172 | 173 | Now that you understand the input and output, let's implement `reconciler.sh`. Implement a reconciler that outputs the value of the `.spec.message` field to stderr and sets the value `completed` to the `.status.phase` field. 174 | 175 | ``` 176 | $ vim reconciler.sh 177 | ``` 178 | ``` 179 | #!/bin/bash 180 | 181 | # Read current state from stdio. 182 | STATE=`cat -` 183 | 184 | # Read phase from object. 185 | PHASE=`echo "${STATE}" | jq -r '.object.status.phase'` 186 | 187 | # Reconcile object. 188 | if [ "${PHASE}" != "completed" ]; then 189 | # Write message to stder. 190 | NOW=`date "+%Y/%m/%d %H:%M:%S"` 191 | echo -n "${NOW} message: " >&2 192 | echo "${STATE}" | jq -r '.object.spec.message' >&2 193 | 194 | # Set `.status.phase` field to the resource. 195 | STATE=`echo "${STATE}" | jq -r '.object.status.phase = "completed"'` 196 | fi 197 | 198 | # Write new state to stdio. 199 | echo "${STATE}" 200 | ``` 201 | 202 | Do not forget to give execute permission to `reconciler.sh`. 203 | 204 | ``` 205 | $ chmod +x reconciler.sh 206 | ``` 207 | 208 | ## Testing your controller 209 | 210 | Now that the reconciler has been implemented, run the `whitebox-controller` command to verify that your controller works properly. 211 | 212 | ``` 213 | $ whitebox-controller 214 | {"level":"info","ts":1554099388.813269,"logger":"controller-runtime.controller","msg":"Starting EventSource","controller":"hello-controller","source":"kind source: whitebox.summerwind.dev/v1alpha1, Kind=Hello"} 215 | {"level":"info","ts":1554099388.915076,"logger":"controller-runtime.controller","msg":"Starting Controller","controller":"hello-controller"} 216 | {"level":"info","ts":1554099389.02052,"logger":"controller-runtime.controller","msg":"Starting workers","controller":"hello-controller","worker count":1} 217 | [exec] stdin: {"resource":{"apiVersion":"whitebox.summerwind.dev/v1alpha1","kind":"Hello","metadata":{"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"whitebox.summerwind.dev/v1alpha1\",\"kind\":\"Hello\",\"metadata\":{\"annotations\":{},\"name\":\"hello\",\"namespace\":\"default\"},\"spec\":{\"message\":\"Hello World\"}}\n"},"creationTimestamp":"2019-04-01T05:58:29Z","generation":1,"name":"hello","namespace":"default","resourceVersion":"14427301","selfLink":"/apis/whitebox.summerwind.dev/v1alpha1/namespaces/default/hello/hello","uid":"2c2673ab-5443-11e9-afad-42010a8c01f3"},"spec":{"message":"Hello World"},"status":{"phase":"completed"}},"dependents":[],"references":[],"events":[]} 218 | [exec] stderr: 2019/04/01 05:58:30 message: Hello World 219 | [exec] stdout: { 220 | "object": { 221 | "apiVersion": "whitebox.summerwind.dev/v1alpha1", 222 | "kind": "Hello", 223 | "metadata": { 224 | "annotations": { 225 | "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"whitebox.summerwind.dev/v1alpha1\",\"kind\":\"Hello\",\"metadata\":{\"annotations\":{},\"name\":\"hello\",\"namespace\":\"default\"},\"spec\":{\"message\":\"Hello World\"}}\n" 226 | }, 227 | "creationTimestamp": "2019-04-01T05:58:29Z", 228 | "generation": 1, 229 | "name": "hello", 230 | "namespace": "default", 231 | "resourceVersion": "14427301", 232 | "selfLink": "/apis/whitebox.summerwind.dev/v1alpha1/namespaces/default/hello/hello", 233 | "uid": "2c2673ab-5443-11e9-afad-42010a8c01f3" 234 | }, 235 | "spec": { 236 | "message": "Hello World" 237 | }, 238 | "status": { 239 | "phase": "completed" 240 | } 241 | }, 242 | "dependents": [], 243 | "references": [], 244 | "events": [] 245 | } 246 | ``` 247 | 248 | Logs with the `[exec]` prefix are for debugging. These logs are come from stdin, stdout, and stderr of the reconciler command. If you look at the log starting with `[exec] stdout:`, you can see that the `.resource.stat us.phase` field has the value `completed` as intended. 249 | 250 | You can also see that the value of the `.resource.stat us.phase` field in Kubernetes has been changed. 251 | 252 | ``` 253 | $ kubectl describe hello hello 254 | Name: hello 255 | Namespace: default 256 | Labels: 257 | Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"whitebox.summerwind.dev/v1alpha1","kind":"Hello","metadata":{"annotations":{},"name":"hello","namespace":"default"},"spec":{"messa... 258 | API Version: whitebox.summerwind.dev/v1alpha1 259 | Kind: Hello 260 | Metadata: 261 | Creation Timestamp: 2019-04-01T05:58:29Z 262 | Generation: 1 263 | Resource Version: 14427301 264 | Self Link: /apis/whitebox.summerwind.dev/v1alpha1/namespaces/default/hello/hello 265 | UID: 2c2673ab-5443-11e9-afad-42010a8c01f33 266 | Spec: 267 | Message: Hello World 268 | Status: 269 | Phase: completed 270 | Events: 271 | ``` 272 | 273 | Now we have confirmed that the controller works! 274 | 275 | ## Building container image 276 | 277 | Build a container image to deploy your controller on Kubernetes. First, create a `Dockerfile` that defines the contents of the container image. 278 | 279 | ``` 280 | $ vim Dockerfile 281 | ``` 282 | ``` 283 | FROM summerwind/whitebox-controller:latest AS base 284 | 285 | ####################################### 286 | 287 | FROM ubuntu:18.04 288 | 289 | RUN apt update \ 290 | && apt install -y jq \ 291 | && rm -rf /var/lib/apt/lists/\* 292 | 293 | COPY --from=base /bin/whitebox-controller /bin/whitebox-controller 294 | 295 | COPY reconciler.sh /reconciler.sh 296 | COPY config.yaml /config.yaml 297 | 298 | ENTRYPOINT ["/bin/whitebox-controller"] 299 | ``` 300 | 301 | Build the container image using the `docker` command. Specify an arbitrary name for your container registry to `$ {NAME}`. 302 | 303 | ``` 304 | $ docker build -t ${NAME}/hello-controller:latest . 305 | ``` 306 | 307 | The built container image is pushed to the container registry. 308 | 309 | ``` 310 | $ docker push ${NAME}/hello-controller:latest 311 | ``` 312 | 313 | ## Deploying your controller to Kubernetes 314 | 315 | Finally, let's deploy the controller to Kubernetes. This example deploys a controller to `kube-system` namespace. Create a manifest file as follows. 316 | 317 | ``` 318 | $ vim controller.yaml 319 | ``` 320 | ``` 321 | apiVersion: v1 322 | kind: ServiceAccount 323 | metadata: 324 | name: hello-controller 325 | namespace: kube-system 326 | --- 327 | apiVersion: rbac.authorization.k8s.io/v1 328 | kind: ClusterRole 329 | metadata: 330 | name: hello-controller 331 | rules: 332 | - apiGroups: 333 | - whitebox.summerwind.dev 334 | resources: 335 | - hello 336 | verbs: 337 | - get 338 | - list 339 | - watch 340 | - create 341 | - update 342 | - patch 343 | - delete 344 | --- 345 | apiVersion: rbac.authorization.k8s.io/v1 346 | kind: ClusterRoleBinding 347 | metadata: 348 | name: hello-controller 349 | roleRef: 350 | apiGroup: rbac.authorization.k8s.io 351 | kind: ClusterRole 352 | name: hello-controller 353 | subjects: 354 | - kind: ServiceAccount 355 | name: hello-controller 356 | namespace: kube-system 357 | --- 358 | apiVersion: apps/v1 359 | kind: Deployment 360 | metadata: 361 | name: hello-controller 362 | namespace: kube-system 363 | spec: 364 | replicas: 1 365 | selector: 366 | matchLabels: 367 | app: hello-controller 368 | template: 369 | metadata: 370 | labels: 371 | app: hello-controller 372 | spec: 373 | containers: 374 | - name: hello-controller 375 | image: ${NAME}/hello-controller:latest # You need to set ${NAME} ! 376 | imagePullPolicy: Always 377 | resources: 378 | requests: 379 | cpu: 100m 380 | memory: 20Mi 381 | serviceAccountName: hello-controller 382 | ``` 383 | 384 | Apply the manifest file to Kubernetes and make sure that the Controller is now running. 385 | 386 | ``` 387 | $ kubectl apply -f controller.yaml 388 | $ kubectl get -n kube-system pod 389 | NAME READY STATUS RESTARTS AGE 390 | ... 391 | hello-controller-54d9456cb4-v5swt 1/1 Running 0 10s 392 | ... 393 | ``` 394 | -------------------------------------------------------------------------------- /reconciler/reconciler.go: -------------------------------------------------------------------------------- 1 | package reconciler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "reflect" 9 | "strings" 10 | "time" 11 | 12 | apierrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "k8s.io/apimachinery/pkg/types" 16 | "k8s.io/client-go/third_party/forked/golang/template" 17 | "k8s.io/client-go/tools/record" 18 | "k8s.io/client-go/util/jsonpath" 19 | "sigs.k8s.io/controller-runtime/pkg/client" 20 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 21 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 22 | 23 | "github.com/summerwind/whitebox-controller/config" 24 | "github.com/summerwind/whitebox-controller/handler" 25 | "github.com/summerwind/whitebox-controller/handler/common" 26 | "github.com/summerwind/whitebox-controller/reconciler/state" 27 | ) 28 | 29 | var log = logf.Log.WithName("reconciler") 30 | 31 | // Reconciler represents a reconciler of controller. 32 | type Reconciler struct { 33 | client.Client 34 | config *config.ResourceConfig 35 | handler handler.StateHandler 36 | finalizer handler.StateHandler 37 | recorder record.EventRecorder 38 | requeueAfter *time.Duration 39 | } 40 | 41 | // New returns a new reconciler. 42 | func New(c *config.ResourceConfig, rec record.EventRecorder) (*Reconciler, error) { 43 | h, err := common.NewStateHandler(&c.Reconciler.HandlerConfig) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | r := &Reconciler{ 49 | config: c, 50 | handler: h, 51 | recorder: rec, 52 | } 53 | 54 | if c.Reconciler.RequeueAfter != "" { 55 | ra, err := time.ParseDuration(c.Reconciler.RequeueAfter) 56 | if err != nil { 57 | return nil, errors.New("invalid requeue after") 58 | } 59 | r.requeueAfter = &ra 60 | } 61 | 62 | if c.Finalizer != nil { 63 | fh, err := common.NewStateHandler(c.Finalizer) 64 | if err != nil { 65 | return nil, err 66 | } 67 | r.finalizer = fh 68 | } 69 | 70 | return r, nil 71 | } 72 | 73 | // InjectClient implements inject.Client interface. 74 | func (r *Reconciler) InjectClient(c client.Client) error { 75 | r.Client = c 76 | return nil 77 | } 78 | 79 | // Reconcile reconciles specified object. 80 | func (r *Reconciler) Reconcile(req reconcile.Request) (reconcile.Result, error) { 81 | var ( 82 | err error 83 | finalized bool 84 | ) 85 | 86 | if r.IsObserver() { 87 | return r.Observe(req) 88 | } 89 | 90 | namespace := req.Namespace 91 | name := req.Name 92 | log.Info("Reconcile a resource", "namespace", namespace, "name", name) 93 | 94 | instance := &unstructured.Unstructured{} 95 | instance.SetGroupVersionKind(r.config.GroupVersionKind) 96 | 97 | err = r.Get(context.TODO(), req.NamespacedName, instance) 98 | if err != nil { 99 | if apierrors.IsNotFound(err) { 100 | return reconcile.Result{}, nil 101 | } 102 | log.Error(err, "Failed to get a resource", "namespace", namespace, "name", name) 103 | return reconcile.Result{}, err 104 | } 105 | 106 | dependents, err := r.getDependents(instance) 107 | if err != nil { 108 | log.Error(err, "Failed to get dependent resources", "namespace", namespace, "name", name) 109 | return reconcile.Result{}, err 110 | } 111 | 112 | refs, err := r.getReferences(instance) 113 | if err != nil { 114 | log.Error(err, "Failed to get reference resources", "namespace", namespace, "name", name) 115 | return reconcile.Result{}, err 116 | } 117 | 118 | s := state.New(instance, dependents, refs) 119 | ns := s.Copy() 120 | 121 | if isDeleting(instance) && r.finalizer != nil { 122 | finalized = true 123 | log.Info("Starting finalizer", "namespace", namespace, "name", name) 124 | err = r.finalizer.HandleState(ns) 125 | } else { 126 | err = r.handler.HandleState(ns) 127 | } 128 | if err != nil { 129 | log.Error(err, "Handler error", "namespace", namespace, "name", name) 130 | return reconcile.Result{}, err 131 | } 132 | 133 | err = r.validateState(s, ns) 134 | if err != nil { 135 | log.Error(err, "The new state is invalid", "namespace", namespace, "name", name) 136 | return reconcile.Result{}, err 137 | } 138 | 139 | r.setOwnerReference(ns) 140 | 141 | if finalized { 142 | if !ns.Requeue && ns.RequeueAfter == 0 { 143 | r.unsetFinalizer(ns.Object) 144 | } 145 | } else if r.finalizer != nil { 146 | r.setFinalizer(ns.Object) 147 | } 148 | 149 | created, updated, deleted := s.Diff(ns) 150 | 151 | for _, res := range created { 152 | log.Info("Creating resource", "kind", res.GetKind(), "namespace", res.GetNamespace(), "name", res.GetName()) 153 | 154 | err = r.Create(context.TODO(), res) 155 | if err != nil { 156 | log.Error(err, "Failed to create a resource", "namespace", res.GetNamespace(), "name", res.GetName()) 157 | return reconcile.Result{}, err 158 | } 159 | } 160 | 161 | for _, res := range updated { 162 | log.Info("Updating resource", "kind", res.GetKind(), "namespace", res.GetNamespace(), "name", res.GetName()) 163 | 164 | err = r.Update(context.TODO(), res) 165 | if err != nil { 166 | log.Error(err, "Failed to update a resource", "namespace", res.GetNamespace(), "name", res.GetName()) 167 | return reconcile.Result{}, err 168 | } 169 | } 170 | 171 | for _, res := range deleted { 172 | log.Info("Deleting resource", "kind", res.GetKind(), "namespace", res.GetNamespace(), "name", res.GetName()) 173 | 174 | err = r.Delete(context.TODO(), res) 175 | if err != nil { 176 | log.Error(err, "Failed to delete a resource", "namespace", res.GetNamespace(), "name", res.GetName()) 177 | return reconcile.Result{}, err 178 | } 179 | } 180 | 181 | for _, ev := range ns.Events { 182 | err := ev.Validate() 183 | if err != nil { 184 | log.Info("Ignored event due to the event is invalid", "namespace", namespace, "name", name, "error", err.Error()) 185 | continue 186 | } 187 | r.recorder.Event(instance, ev.Type, ev.Reason, ev.Message) 188 | } 189 | 190 | result := reconcile.Result{} 191 | if r.requeueAfter != nil { 192 | result.RequeueAfter = *r.requeueAfter 193 | } 194 | 195 | result.Requeue = ns.Requeue 196 | if ns.RequeueAfter > 0 { 197 | result.RequeueAfter = time.Duration(ns.RequeueAfter) * time.Second 198 | } 199 | 200 | return result, nil 201 | } 202 | 203 | func (r *Reconciler) Observe(req reconcile.Request) (reconcile.Result, error) { 204 | namespace := req.Namespace 205 | name := req.Name 206 | 207 | instance := &unstructured.Unstructured{} 208 | instance.SetGroupVersionKind(r.config.GroupVersionKind) 209 | 210 | err := r.Get(context.TODO(), req.NamespacedName, instance) 211 | if err != nil && !apierrors.IsNotFound(err) { 212 | log.Error(err, "Failed to get a resource", "namespace", namespace, "name", name) 213 | return reconcile.Result{}, nil 214 | } 215 | 216 | // This allows determination of deleted resources 217 | instance.SetNamespace(namespace) 218 | instance.SetName(name) 219 | 220 | s := &state.State{ 221 | Object: instance, 222 | } 223 | 224 | err = r.handler.HandleState(s) 225 | if err != nil { 226 | log.Error(err, "Handler error", "namespace", namespace, "name", name) 227 | return reconcile.Result{}, nil 228 | } 229 | 230 | return reconcile.Result{}, nil 231 | } 232 | 233 | func (r *Reconciler) IsObserver() bool { 234 | return r.config.Reconciler.Observe 235 | } 236 | 237 | // getDependents returns a list of dependent resources with 238 | // an specified owner reference. 239 | func (r *Reconciler) getDependents(res *unstructured.Unstructured) (map[string][]*unstructured.Unstructured, error) { 240 | dependents := map[string][]*unstructured.Unstructured{} 241 | ownerRef := metav1.NewControllerRef(res, res.GroupVersionKind()) 242 | 243 | for _, dep := range r.config.Dependents { 244 | key := state.ResourceKey(dep.GroupVersionKind) 245 | dependents[key] = []*unstructured.Unstructured{} 246 | 247 | gvk := dep.GroupVersionKind 248 | gvk.Kind = gvk.Kind + "List" 249 | dependentList := &unstructured.UnstructuredList{} 250 | dependentList.SetGroupVersionKind(gvk) 251 | 252 | err := r.List(context.TODO(), dependentList, client.InNamespace(res.GetNamespace())) 253 | if err != nil { 254 | return nil, fmt.Errorf("Failed to get a list for dependent resource: %v", err) 255 | } 256 | 257 | for i := range dependentList.Items { 258 | depOwnerRefs := dependentList.Items[i].GetOwnerReferences() 259 | for _, ref := range depOwnerRefs { 260 | if !reflect.DeepEqual(ref, *ownerRef) { 261 | continue 262 | } 263 | dependents[key] = append(dependents[key], &dependentList.Items[i]) 264 | } 265 | } 266 | } 267 | 268 | return dependents, nil 269 | } 270 | 271 | // getReferences returns a list of reference resources based on 272 | // spcified field path. 273 | func (r *Reconciler) getReferences(res *unstructured.Unstructured) (map[string][]*unstructured.Unstructured, error) { 274 | refs := map[string][]*unstructured.Unstructured{} 275 | 276 | for _, ref := range r.config.References { 277 | if ref.NameFieldPath == "" { 278 | continue 279 | } 280 | 281 | key := state.ResourceKey(ref.GroupVersionKind) 282 | refs[key] = []*unstructured.Unstructured{} 283 | 284 | refNames, err := getReferenceNames(res, ref.NameFieldPath) 285 | if err != nil { 286 | return nil, fmt.Errorf("failed to get reference name list: %v", err) 287 | } 288 | 289 | if len(refNames) == 0 { 290 | continue 291 | } 292 | 293 | for i := range refNames { 294 | refRes := &unstructured.Unstructured{} 295 | refRes.SetGroupVersionKind(ref.GroupVersionKind) 296 | 297 | nn := types.NamespacedName{ 298 | Namespace: res.GetNamespace(), 299 | Name: refNames[i], 300 | } 301 | err = r.Get(context.TODO(), nn, refRes) 302 | if err != nil { 303 | if apierrors.IsNotFound(err) { 304 | continue 305 | } 306 | return nil, fmt.Errorf("failed to get a resource '%s/%s': %v", res.GetNamespace(), refNames[i], err) 307 | } 308 | 309 | refs[key] = append(refs[key], refRes) 310 | } 311 | } 312 | 313 | return refs, nil 314 | } 315 | 316 | // setFinalizer adds it's finalizer name to resource's metadata. 317 | func (r *Reconciler) setFinalizer(res *unstructured.Unstructured) { 318 | if res == nil { 319 | return 320 | } 321 | 322 | finalizers := res.GetFinalizers() 323 | name := r.getFinalizerName() 324 | 325 | exist := false 326 | for i := range finalizers { 327 | if finalizers[i] == name { 328 | exist = true 329 | } 330 | } 331 | 332 | if !exist { 333 | finalizers = append(finalizers, name) 334 | res.SetFinalizers(finalizers) 335 | } 336 | } 337 | 338 | // unsetFinalizer removes it's finalizer name from resource's metadata. 339 | func (r *Reconciler) unsetFinalizer(res *unstructured.Unstructured) { 340 | if res == nil { 341 | return 342 | } 343 | 344 | finalizers := res.GetFinalizers() 345 | name := r.getFinalizerName() 346 | 347 | list := []string{} 348 | exist := false 349 | for i := range finalizers { 350 | if finalizers[i] == name { 351 | exist = true 352 | continue 353 | } 354 | list = append(list, finalizers[i]) 355 | } 356 | 357 | if exist { 358 | res.SetFinalizers(list) 359 | } 360 | } 361 | 362 | // getFinalizerName returns controller's finalizer name. 363 | func (r *Reconciler) getFinalizerName() string { 364 | return fmt.Sprintf("%s-controller.%s", strings.ToLower(r.config.Kind), r.config.Group) 365 | } 366 | 367 | // validateState validates specified state. 368 | func (r *Reconciler) validateState(s, ns *state.State) error { 369 | if ns.Object != nil { 370 | if !reflect.DeepEqual(r.config.GroupVersionKind, ns.Object.GroupVersionKind()) { 371 | return errors.New("object: unexpected group/version/kind") 372 | } 373 | if ns.Object.GetNamespace() != s.Object.GetNamespace() { 374 | return errors.New("object: changing namespace is not allowed") 375 | } 376 | if ns.Object.GetName() != s.Object.GetName() { 377 | return errors.New("object: changing name is not allowed") 378 | } 379 | if ns.Object.GetUID() != s.Object.GetUID() { 380 | return errors.New("object: changing UID is not allowed") 381 | } 382 | } 383 | 384 | keys := map[string]struct{}{} 385 | for _, dep := range r.config.Dependents { 386 | keys[state.ResourceKey(dep.GroupVersionKind)] = struct{}{} 387 | } 388 | 389 | for key := range ns.Dependents { 390 | _, ok := keys[key] 391 | if !ok { 392 | return fmt.Errorf("dependents[%s]: unexpected group/version/kind", key) 393 | } 394 | 395 | for i, dep := range ns.Dependents[key] { 396 | if key != state.ResourceKey(dep.GroupVersionKind()) { 397 | return fmt.Errorf("dependents[%s][%d]: namespace does not match", key, i) 398 | } 399 | } 400 | } 401 | 402 | return nil 403 | } 404 | 405 | // setOwnerReference sets OwnerReference to dependent resources. 406 | func (r *Reconciler) setOwnerReference(s *state.State) { 407 | if s.Object == nil { 408 | return 409 | } 410 | 411 | ownerRef := metav1.NewControllerRef(s.Object, s.Object.GroupVersionKind()) 412 | 413 | orphans := map[string]struct{}{} 414 | for _, dep := range r.config.Dependents { 415 | if dep.Orphan { 416 | orphans[state.ResourceKey(dep.GroupVersionKind)] = struct{}{} 417 | } 418 | } 419 | 420 | for key, deps := range s.Dependents { 421 | _, ok := orphans[key] 422 | if ok { 423 | continue 424 | } 425 | 426 | for _, dep := range deps { 427 | dep.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) 428 | } 429 | } 430 | } 431 | 432 | // getReferenceNames returns a list of reference resource names based 433 | // on JSON Path and resource. 434 | func getReferenceNames(res *unstructured.Unstructured, namePath string) ([]string, error) { 435 | jp := jsonpath.New("reference") 436 | jp.AllowMissingKeys(true) 437 | 438 | err := jp.Parse(fmt.Sprintf("{%s}", namePath)) 439 | if err != nil { 440 | return []string{}, err 441 | } 442 | 443 | results, err := jp.FindResults(res.Object) 444 | if err != nil { 445 | return []string{}, err 446 | } 447 | 448 | nameMap := map[string]bool{} 449 | for x := range results { 450 | for _, v := range results[x] { 451 | val, ok := template.PrintableValue(v) 452 | if !ok { 453 | return nil, fmt.Errorf("value is not a printable type %s", v.Type()) 454 | } 455 | 456 | var buf bytes.Buffer 457 | fmt.Fprint(&buf, val) 458 | nameMap[buf.String()] = true 459 | } 460 | } 461 | 462 | names := []string{} 463 | for key, _ := range nameMap { 464 | names = append(names, key) 465 | } 466 | 467 | return names, nil 468 | } 469 | 470 | // isDeleting returns whether the specified resource is being deleted. 471 | func isDeleting(res *unstructured.Unstructured) bool { 472 | _, ok, err := unstructured.NestedString(res.UnstructuredContent(), "metadata", "deletionTimestamp") 473 | if err != nil { 474 | return false 475 | } 476 | 477 | return ok 478 | } 479 | -------------------------------------------------------------------------------- /reconciler/reconciler_test.go: -------------------------------------------------------------------------------- 1 | package reconciler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | . "github.com/onsi/gomega" 13 | . "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | 15 | corev1 "k8s.io/api/core/v1" 16 | apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 17 | apierrors "k8s.io/apimachinery/pkg/api/errors" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | "k8s.io/apimachinery/pkg/runtime/schema" 20 | "k8s.io/apimachinery/pkg/types" 21 | "k8s.io/apimachinery/pkg/util/uuid" 22 | "k8s.io/client-go/rest" 23 | "k8s.io/client-go/tools/record" 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/controller-runtime/pkg/envtest" 26 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 27 | 28 | "github.com/summerwind/whitebox-controller/config" 29 | "github.com/summerwind/whitebox-controller/reconciler/state" 30 | ) 31 | 32 | var kconfig *rest.Config 33 | 34 | func TestMain(m *testing.M) { 35 | var err error 36 | 37 | env := &envtest.Environment{ 38 | CRDs: []*apiextensionsv1beta1.CustomResourceDefinition{newCRD("Test")}, 39 | } 40 | 41 | kconfig, err = env.Start() 42 | if err != nil { 43 | fmt.Fprintf(os.Stderr, "failed to start test environment: %v\n", err) 44 | os.Exit(1) 45 | } 46 | 47 | code := m.Run() 48 | env.Stop() 49 | os.Exit(code) 50 | } 51 | 52 | func TestReconcile(t *testing.T) { 53 | var ( 54 | nn types.NamespacedName 55 | err error 56 | ) 57 | 58 | RegisterTestingT(t) 59 | 60 | rc := newResourceConfig() 61 | recorder := record.NewFakeRecorder(32) 62 | r, err := New(rc, recorder) 63 | Expect(err).NotTo(HaveOccurred()) 64 | 65 | c := newClient() 66 | r.InjectClient(c) 67 | 68 | // Create target object 69 | object := newObject(rc.GroupVersionKind, "test") 70 | err = r.Create(context.TODO(), object) 71 | Expect(err).NotTo(HaveOccurred()) 72 | defer r.Delete(context.TODO(), object) 73 | 74 | // Generate owner reference from target object 75 | ownerRef := metav1.NewControllerRef(object, object.GroupVersionKind()) 76 | 77 | // Generate dependent objects 78 | p1 := newPod("p1") 79 | p1.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) 80 | r.Create(context.TODO(), p1) 81 | defer r.Delete(context.TODO(), p1) 82 | 83 | p2 := newPod("p2") 84 | p2.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) 85 | r.Create(context.TODO(), p2) 86 | defer r.Delete(context.TODO(), p2) 87 | 88 | p3 := newPod("p3") 89 | p3.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) 90 | defer r.Delete(context.TODO(), p3) 91 | 92 | // Generate reference objects 93 | c1 := newConfigMap("c1") 94 | r.Create(context.TODO(), c1) 95 | defer r.Delete(context.TODO(), c1) 96 | 97 | // Enable test handler 98 | h := &testHandler{} 99 | r.handler = h 100 | 101 | // Set reconcile handler 102 | h.Func = func(s *state.State) error { 103 | var err error 104 | 105 | Expect(s.Object).NotTo(BeNil()) 106 | Expect(len(s.Dependents["pod.v1"])).To(Equal(2)) 107 | Expect(len(s.References["configmap.v1"])).To(Equal(1)) 108 | 109 | err = SetNestedField(s.Object.Object, "completed", "status", "phase") 110 | Expect(err).NotTo(HaveOccurred()) 111 | 112 | p1 := s.Dependents["pod.v1"][0] 113 | Expect(p1.GetName()).To(Equal("p1")) 114 | err = SetNestedField(p1.Object, int64(120), "spec", "activeDeadlineSeconds") 115 | Expect(err).NotTo(HaveOccurred()) 116 | 117 | p3 := s.Dependents["pod.v1"][1] 118 | Expect(p3.GetName()).To(Equal("p2")) 119 | p3.SetName("p3") 120 | RemoveNestedField(p3.Object, "metadata", "resourceVersion") 121 | 122 | c1 := s.References["configmap.v1"][0] 123 | Expect(c1.GetName()).To(Equal("c1")) 124 | 125 | s.Events = append(s.Events, state.Event{ 126 | Type: "Succeeded", 127 | }) 128 | s.Events = append(s.Events, state.Event{}) 129 | 130 | s.RequeueAfter = 60 131 | 132 | return nil 133 | } 134 | 135 | // Run reconcile function 136 | req := reconcile.Request{ 137 | NamespacedName: types.NamespacedName{ 138 | Namespace: object.GetNamespace(), 139 | Name: object.GetName(), 140 | }, 141 | } 142 | result, err := r.Reconcile(req) 143 | Expect(err).NotTo(HaveOccurred()) 144 | Expect(result.RequeueAfter).To(Equal(time.Duration(60) * time.Second)) 145 | 146 | // Test target object state 147 | o := &Unstructured{} 148 | o.SetGroupVersionKind(object.GroupVersionKind()) 149 | err = c.Get(context.TODO(), req.NamespacedName, o) 150 | Expect(err).NotTo(HaveOccurred()) 151 | 152 | phase, ok, err := NestedString(o.Object, "status", "phase") 153 | Expect(err).NotTo(HaveOccurred()) 154 | Expect(ok).To(BeTrue()) 155 | Expect(phase).To(Equal("completed")) 156 | 157 | // Test p1 object state 158 | nn = types.NamespacedName{ 159 | Namespace: p1.GetNamespace(), 160 | Name: p1.GetName(), 161 | } 162 | pod := &corev1.Pod{} 163 | err = c.Get(context.TODO(), nn, pod) 164 | Expect(err).NotTo(HaveOccurred()) 165 | Expect(*(pod.Spec.ActiveDeadlineSeconds)).To(Equal(int64(120))) 166 | 167 | // Test p2 object state 168 | nn = types.NamespacedName{ 169 | Namespace: p2.GetNamespace(), 170 | Name: p2.GetName(), 171 | } 172 | pod = &corev1.Pod{} 173 | err = c.Get(context.TODO(), nn, pod) 174 | Expect(apierrors.IsNotFound(err)).To(BeTrue()) 175 | 176 | // Test p3 object state 177 | nn = types.NamespacedName{ 178 | Namespace: p3.GetNamespace(), 179 | Name: p3.GetName(), 180 | } 181 | pod = &corev1.Pod{} 182 | err = c.Get(context.TODO(), nn, pod) 183 | Expect(err).NotTo(HaveOccurred()) 184 | 185 | // Test event 186 | Expect(len(recorder.Events)).To(Equal(1)) 187 | } 188 | 189 | func TestReconcileWithFinalizer(t *testing.T) { 190 | RegisterTestingT(t) 191 | 192 | rc := newResourceConfig() 193 | recorder := record.NewFakeRecorder(32) 194 | r, err := New(rc, recorder) 195 | Expect(err).NotTo(HaveOccurred()) 196 | 197 | c := newClient() 198 | r.InjectClient(c) 199 | 200 | // Create target object 201 | object := newObject(rc.GroupVersionKind, "test") 202 | err = r.Create(context.TODO(), object) 203 | Expect(err).NotTo(HaveOccurred()) 204 | defer r.Delete(context.TODO(), object) 205 | 206 | // Enable test handler 207 | h := &testHandler{} 208 | r.finalizer = h 209 | 210 | // Set finalize handler 211 | h.Func = func(s *state.State) error { 212 | s.Events = append(s.Events, state.Event{ 213 | Type: "Finalized", 214 | }) 215 | return nil 216 | } 217 | 218 | // Run reconcile function 219 | req := reconcile.Request{ 220 | NamespacedName: types.NamespacedName{ 221 | Namespace: object.GetNamespace(), 222 | Name: object.GetName(), 223 | }, 224 | } 225 | _, err = r.Reconcile(req) 226 | Expect(err).NotTo(HaveOccurred()) 227 | 228 | // Delete target object 229 | r.Delete(context.TODO(), object) 230 | 231 | // Run reconcile function again 232 | _, err = r.Reconcile(req) 233 | Expect(err).NotTo(HaveOccurred()) 234 | 235 | // Test target object state 236 | o := &Unstructured{} 237 | o.SetGroupVersionKind(object.GroupVersionKind()) 238 | err = c.Get(context.TODO(), req.NamespacedName, o) 239 | Expect(apierrors.IsNotFound(err)).To(BeTrue()) 240 | 241 | // Test event 242 | Expect(len(recorder.Events)).To(Equal(1)) 243 | } 244 | 245 | func TestReconcileWithObserve(t *testing.T) { 246 | RegisterTestingT(t) 247 | 248 | rc := newResourceConfig() 249 | rc.Reconciler.Observe = true 250 | recorder := record.NewFakeRecorder(32) 251 | r, err := New(rc, recorder) 252 | Expect(err).NotTo(HaveOccurred()) 253 | 254 | c := newClient() 255 | r.InjectClient(c) 256 | 257 | // Create target object 258 | object := newObject(rc.GroupVersionKind, "test") 259 | err = r.Create(context.TODO(), object) 260 | Expect(err).NotTo(HaveOccurred()) 261 | defer r.Delete(context.TODO(), object) 262 | 263 | // Enable test handler 264 | h := &testHandler{} 265 | r.handler = h 266 | 267 | // Set reconcile handler 268 | h.Func = func(s *state.State) error { 269 | err = SetNestedField(s.Object.Object, "completed", "status", "phase") 270 | Expect(err).NotTo(HaveOccurred()) 271 | 272 | s.Events = append(s.Events, state.Event{ 273 | Type: "Observed", 274 | }) 275 | 276 | return nil 277 | } 278 | 279 | // Run reconcile function 280 | req := reconcile.Request{ 281 | NamespacedName: types.NamespacedName{ 282 | Namespace: object.GetNamespace(), 283 | Name: object.GetName(), 284 | }, 285 | } 286 | _, err = r.Reconcile(req) 287 | Expect(err).NotTo(HaveOccurred()) 288 | 289 | // Test target object state 290 | o := &Unstructured{} 291 | o.SetGroupVersionKind(object.GroupVersionKind()) 292 | err = c.Get(context.TODO(), req.NamespacedName, o) 293 | Expect(err).NotTo(HaveOccurred()) 294 | 295 | _, ok, err := NestedString(o.Object, "status", "phase") 296 | Expect(err).NotTo(HaveOccurred()) 297 | Expect(ok).To(BeFalse()) 298 | 299 | // Test event 300 | Expect(len(recorder.Events)).To(Equal(0)) 301 | } 302 | 303 | func TestReconcileWithNoObject(t *testing.T) { 304 | RegisterTestingT(t) 305 | 306 | rc := newResourceConfig() 307 | recorder := record.NewFakeRecorder(32) 308 | r, err := New(rc, recorder) 309 | Expect(err).NotTo(HaveOccurred()) 310 | 311 | c := newClient() 312 | r.InjectClient(c) 313 | 314 | // Enable test handler 315 | h := &testHandler{} 316 | r.handler = h 317 | 318 | // Set reconcile handler 319 | h.Func = func(s *state.State) error { 320 | return errors.New("handler error") 321 | } 322 | 323 | // Run reconcile function 324 | req := reconcile.Request{ 325 | NamespacedName: types.NamespacedName{ 326 | Namespace: "default", 327 | Name: "test", 328 | }, 329 | } 330 | _, err = r.Reconcile(req) 331 | Expect(err).NotTo(HaveOccurred()) 332 | } 333 | 334 | func TestReconcileWithObjectDeletion(t *testing.T) { 335 | RegisterTestingT(t) 336 | 337 | rc := newResourceConfig() 338 | recorder := record.NewFakeRecorder(32) 339 | r, err := New(rc, recorder) 340 | Expect(err).NotTo(HaveOccurred()) 341 | 342 | c := newClient() 343 | r.InjectClient(c) 344 | 345 | // Create target object 346 | object := newObject(rc.GroupVersionKind, "test") 347 | err = r.Create(context.TODO(), object) 348 | Expect(err).NotTo(HaveOccurred()) 349 | defer r.Delete(context.TODO(), object) 350 | 351 | // Enable test handler 352 | h := &testHandler{} 353 | r.handler = h 354 | 355 | // Set reconcile handler 356 | h.Func = func(s *state.State) error { 357 | s.Object = nil 358 | return nil 359 | } 360 | 361 | // Run reconcile function 362 | req := reconcile.Request{ 363 | NamespacedName: types.NamespacedName{ 364 | Namespace: object.GetNamespace(), 365 | Name: object.GetName(), 366 | }, 367 | } 368 | _, err = r.Reconcile(req) 369 | Expect(err).NotTo(HaveOccurred()) 370 | 371 | // Test target object state 372 | o := &Unstructured{} 373 | o.SetGroupVersionKind(object.GroupVersionKind()) 374 | err = c.Get(context.TODO(), req.NamespacedName, o) 375 | Expect(apierrors.IsNotFound(err)).To(BeTrue()) 376 | } 377 | 378 | func TestReconcileWithHandlerError(t *testing.T) { 379 | RegisterTestingT(t) 380 | 381 | rc := newResourceConfig() 382 | recorder := record.NewFakeRecorder(32) 383 | r, err := New(rc, recorder) 384 | Expect(err).NotTo(HaveOccurred()) 385 | 386 | c := newClient() 387 | r.InjectClient(c) 388 | 389 | // Create target object 390 | object := newObject(rc.GroupVersionKind, "test") 391 | err = r.Create(context.TODO(), object) 392 | Expect(err).NotTo(HaveOccurred()) 393 | defer r.Delete(context.TODO(), object) 394 | 395 | // Enable test handler 396 | h := &testHandler{} 397 | r.handler = h 398 | 399 | // Set reconcile handler 400 | h.Func = func(s *state.State) error { 401 | return errors.New("handler error") 402 | } 403 | 404 | // Run reconcile function 405 | req := reconcile.Request{ 406 | NamespacedName: types.NamespacedName{ 407 | Namespace: object.GetNamespace(), 408 | Name: object.GetName(), 409 | }, 410 | } 411 | _, err = r.Reconcile(req) 412 | Expect(err).To(HaveOccurred()) 413 | } 414 | 415 | func TestReconcileWithInvalidState(t *testing.T) { 416 | RegisterTestingT(t) 417 | 418 | rc := newResourceConfig() 419 | recorder := record.NewFakeRecorder(32) 420 | r, err := New(rc, recorder) 421 | Expect(err).NotTo(HaveOccurred()) 422 | 423 | c := newClient() 424 | r.InjectClient(c) 425 | 426 | // Create target object 427 | object := newObject(rc.GroupVersionKind, "test") 428 | err = r.Create(context.TODO(), object) 429 | Expect(err).NotTo(HaveOccurred()) 430 | defer r.Delete(context.TODO(), object) 431 | 432 | // Enable test handler 433 | h := &testHandler{} 434 | r.handler = h 435 | 436 | // Set reconcile handler 437 | h.Func = func(s *state.State) error { 438 | s.Object.SetName("invalid") 439 | return nil 440 | } 441 | 442 | // Run reconcile function 443 | req := reconcile.Request{ 444 | NamespacedName: types.NamespacedName{ 445 | Namespace: object.GetNamespace(), 446 | Name: object.GetName(), 447 | }, 448 | } 449 | _, err = r.Reconcile(req) 450 | Expect(err).To(HaveOccurred()) 451 | } 452 | 453 | func TestObserve(t *testing.T) { 454 | RegisterTestingT(t) 455 | 456 | rc := newResourceConfig() 457 | r, err := New(rc, nil) 458 | Expect(err).NotTo(HaveOccurred()) 459 | 460 | h := &testHandler{} 461 | rc.Reconciler.HandlerConfig.StateHandler = h 462 | 463 | c := newClient() 464 | r.InjectClient(c) 465 | 466 | object := newObject(rc.GroupVersionKind, "test") 467 | err = c.Create(context.TODO(), object) 468 | Expect(err).NotTo(HaveOccurred()) 469 | defer c.Delete(context.TODO(), object) 470 | 471 | tests := []struct { 472 | name string 473 | err error 474 | }{ 475 | {object.GetName(), nil}, 476 | {"invalid", nil}, 477 | {object.GetName(), errors.New("error")}, 478 | } 479 | 480 | for _, test := range tests { 481 | h.Func = func(s *state.State) error { 482 | return test.err 483 | } 484 | 485 | req := reconcile.Request{ 486 | NamespacedName: types.NamespacedName{ 487 | Namespace: "default", 488 | Name: test.name, 489 | }, 490 | } 491 | 492 | _, err = r.Observe(req) 493 | Expect(err).NotTo(HaveOccurred()) 494 | } 495 | } 496 | 497 | func TestGetDependents(t *testing.T) { 498 | RegisterTestingT(t) 499 | 500 | rc := newResourceConfig() 501 | r, err := New(rc, nil) 502 | Expect(err).NotTo(HaveOccurred()) 503 | 504 | c := newClient() 505 | r.InjectClient(c) 506 | 507 | object := newObject(rc.GroupVersionKind, "test") 508 | ownerRef := metav1.NewControllerRef(object, object.GroupVersionKind()) 509 | 510 | p1 := newPod("p1") 511 | p1.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) 512 | err = c.Create(context.TODO(), p1) 513 | Expect(err).NotTo(HaveOccurred()) 514 | defer c.Delete(context.TODO(), p1) 515 | 516 | p2 := newPod("p2") 517 | err = c.Create(context.TODO(), p2) 518 | Expect(err).NotTo(HaveOccurred()) 519 | defer c.Delete(context.TODO(), p2) 520 | 521 | deps, err := r.getDependents(object) 522 | Expect(err).NotTo(HaveOccurred()) 523 | Expect(len(deps["pod.v1"])).To(Equal(1)) 524 | } 525 | 526 | func TestGetReferences(t *testing.T) { 527 | RegisterTestingT(t) 528 | 529 | rc := newResourceConfig() 530 | r, err := New(rc, nil) 531 | Expect(err).NotTo(HaveOccurred()) 532 | 533 | c := newClient() 534 | r.InjectClient(c) 535 | 536 | object := newObject(rc.GroupVersionKind, "test") 537 | 538 | c1 := newConfigMap("c1") 539 | err = c.Create(context.TODO(), c1) 540 | Expect(err).NotTo(HaveOccurred()) 541 | defer c.Delete(context.TODO(), c1) 542 | 543 | tests := []struct { 544 | nameFieldPath string 545 | length int 546 | err bool 547 | }{ 548 | {"", 0, false}, 549 | {".spec.configMapRefs[0]", 1, false}, 550 | {".spec.configMapRefs[1]", 0, false}, 551 | {".spec.refs[0]", 0, false}, 552 | {"spec.configMapRefs[0]", 0, true}, 553 | } 554 | 555 | for _, test := range tests { 556 | rc.References[0].NameFieldPath = test.nameFieldPath 557 | 558 | refs, err := r.getReferences(object) 559 | if test.err { 560 | Expect(err).To(HaveOccurred()) 561 | } else { 562 | Expect(err).NotTo(HaveOccurred()) 563 | Expect(len(refs["configmap.v1"])).To(Equal(test.length)) 564 | } 565 | } 566 | } 567 | 568 | func TestSetFinalizer(t *testing.T) { 569 | RegisterTestingT(t) 570 | 571 | rc := newResourceConfig() 572 | r, err := New(rc, nil) 573 | Expect(err).NotTo(HaveOccurred()) 574 | 575 | finalizers := []string{ 576 | "foo-controller.example.com", 577 | } 578 | 579 | object := newObject(rc.GroupVersionKind, "test") 580 | object.SetFinalizers(finalizers) 581 | 582 | r.setFinalizer(object) 583 | list := object.GetFinalizers() 584 | Expect(len(list)).To(Equal(2)) 585 | Expect(list[0]).To(Equal(finalizers[0])) 586 | } 587 | 588 | func TestUnsetFinalizer(t *testing.T) { 589 | RegisterTestingT(t) 590 | 591 | rc := newResourceConfig() 592 | r, err := New(rc, nil) 593 | Expect(err).NotTo(HaveOccurred()) 594 | 595 | finalizers := []string{ 596 | "foo-controller.example.com", 597 | "test-controller.example.com", 598 | } 599 | 600 | object := newObject(rc.GroupVersionKind, "test") 601 | object.SetFinalizers(finalizers) 602 | 603 | r.unsetFinalizer(object) 604 | list := object.GetFinalizers() 605 | Expect(len(list)).To(Equal(1)) 606 | Expect(list[0]).To(Equal(finalizers[0])) 607 | } 608 | 609 | func TestValidateState(t *testing.T) { 610 | var err error 611 | 612 | RegisterTestingT(t) 613 | 614 | rc := newResourceConfig() 615 | r, err := New(rc, nil) 616 | Expect(err).NotTo(HaveOccurred()) 617 | 618 | s := newState(rc) 619 | 620 | // Valid state 621 | s1 := s.Copy() 622 | err = r.validateState(s, s1) 623 | Expect(err).NotTo(HaveOccurred()) 624 | 625 | // Invalid state with unexpected object 626 | s2 := s.Copy() 627 | s2.Object.SetKind("Invalid") 628 | err = r.validateState(s, s2) 629 | Expect(err).To(HaveOccurred()) 630 | 631 | // Invalid state with changed namespace 632 | s3 := s.Copy() 633 | s3.Object.SetNamespace("Invalid") 634 | err = r.validateState(s, s3) 635 | Expect(err).To(HaveOccurred()) 636 | 637 | // Invalid state with changed name 638 | s4 := s.Copy() 639 | s4.Object.SetName("Invalid") 640 | err = r.validateState(s, s4) 641 | Expect(err).To(HaveOccurred()) 642 | 643 | // Invalid state with changed UID 644 | s5 := s.Copy() 645 | s5.Object.SetUID("Invalid") 646 | err = r.validateState(s, s5) 647 | Expect(err).To(HaveOccurred()) 648 | 649 | // Invalid state with unexpected dependent key 650 | s6 := s.Copy() 651 | s6.Dependents["invalid.v1alpha1.example.com"] = s2.Dependents["pod.v1"] 652 | err = r.validateState(s, s6) 653 | Expect(err).To(HaveOccurred()) 654 | 655 | // Invalid state with unexpected dependent object 656 | s7 := s.Copy() 657 | s7.Dependents["pod.v1"][0].SetKind("Invalid") 658 | err = r.validateState(s, s7) 659 | Expect(err).To(HaveOccurred()) 660 | } 661 | 662 | func TestSetOwnerReference(t *testing.T) { 663 | RegisterTestingT(t) 664 | 665 | rc := newResourceConfig() 666 | r, err := New(rc, nil) 667 | Expect(err).NotTo(HaveOccurred()) 668 | 669 | s := newState(rc) 670 | r.setOwnerReference(s) 671 | 672 | for depKey := range s.Dependents { 673 | for _, dep := range s.Dependents[depKey] { 674 | ownerRefs := dep.GetOwnerReferences() 675 | Expect(len(ownerRefs)).To(Equal(1)) 676 | Expect(ownerRefs[0].Name).To(Equal(s.Object.GetName())) 677 | Expect(ownerRefs[0].UID).To(Equal(s.Object.GetUID())) 678 | } 679 | } 680 | } 681 | 682 | func TestGetReferenceNames(t *testing.T) { 683 | var ( 684 | refs []string 685 | err error 686 | ) 687 | 688 | RegisterTestingT(t) 689 | 690 | rc := newResourceConfig() 691 | object := newObject(rc.GroupVersionKind, "test") 692 | 693 | names := []string{"test1", "test2"} 694 | SetNestedStringSlice(object.Object, names, "spec", "refs") 695 | 696 | refs, err = getReferenceNames(object, ".spec.refs[0]") 697 | Expect(err).NotTo(HaveOccurred()) 698 | Expect(len(refs)).To(Equal(1)) 699 | Expect(refs[0]).To(Equal("test1")) 700 | 701 | refs, err = getReferenceNames(object, ".spec.refs[*]") 702 | Expect(err).NotTo(HaveOccurred()) 703 | Expect(len(refs)).To(Equal(2)) 704 | Expect(refs).To(ConsistOf(names)) 705 | 706 | refs, err = getReferenceNames(object, ".spec.deps[0]") 707 | Expect(err).NotTo(HaveOccurred()) 708 | Expect(len(refs)).To(Equal(0)) 709 | } 710 | 711 | func TestIsDeleting(t *testing.T) { 712 | RegisterTestingT(t) 713 | 714 | rc := newResourceConfig() 715 | object := newObject(rc.GroupVersionKind, "test") 716 | deleting := newObject(rc.GroupVersionKind, "test") 717 | SetNestedField(deleting.Object, string(time.Now().Unix()), "metadata", "deletionTimestamp") 718 | 719 | Expect(isDeleting(object)).To(BeFalse()) 720 | Expect(isDeleting(deleting)).To(BeTrue()) 721 | } 722 | 723 | type testHandler struct { 724 | Func func(*state.State) error 725 | } 726 | 727 | func (h *testHandler) HandleState(s *state.State) error { 728 | if h.Func != nil { 729 | return h.Func(s) 730 | } 731 | return nil 732 | } 733 | 734 | func newClient() client.Client { 735 | cl, err := client.New(kconfig, client.Options{}) 736 | Expect(err).NotTo(HaveOccurred()) 737 | 738 | return cl 739 | } 740 | 741 | func newResourceConfig() *config.ResourceConfig { 742 | return &config.ResourceConfig{ 743 | GroupVersionKind: schema.GroupVersionKind{ 744 | Group: "example.com", 745 | Version: "v1alpha1", 746 | Kind: "Test", 747 | }, 748 | Dependents: []config.DependentConfig{ 749 | config.DependentConfig{ 750 | GroupVersionKind: schema.GroupVersionKind{ 751 | Version: "v1", 752 | Kind: "Pod", 753 | }, 754 | Orphan: false, 755 | }, 756 | }, 757 | References: []config.ReferenceConfig{ 758 | config.ReferenceConfig{ 759 | GroupVersionKind: schema.GroupVersionKind{ 760 | Version: "v1", 761 | Kind: "ConfigMap", 762 | }, 763 | NameFieldPath: ".spec.configMapRefs[*]", 764 | }, 765 | }, 766 | Reconciler: &config.ReconcilerConfig{ 767 | HandlerConfig: config.HandlerConfig{ 768 | StateHandler: &testHandler{}, 769 | }, 770 | RequeueAfter: "30s", 771 | Observe: false, 772 | }, 773 | } 774 | } 775 | 776 | func newState(rc *config.ResourceConfig) *state.State { 777 | s := &state.State{ 778 | Object: newObject(rc.GroupVersionKind, "test"), 779 | Dependents: map[string][]*Unstructured{}, 780 | References: map[string][]*Unstructured{}, 781 | } 782 | 783 | for i := range rc.Dependents { 784 | gvk := rc.Dependents[i].GroupVersionKind 785 | dep := newObject(gvk, "") 786 | depKey := state.ResourceKey(gvk) 787 | s.Dependents[depKey] = []*Unstructured{} 788 | 789 | depNames := []string{"test1", "test2"} 790 | for _, name := range depNames { 791 | d := dep.DeepCopy() 792 | d.SetName(name) 793 | d.SetUID(uuid.NewUUID()) 794 | s.Dependents[depKey] = append(s.Dependents[depKey], d) 795 | } 796 | } 797 | 798 | for i := range rc.References { 799 | gvk := rc.References[i].GroupVersionKind 800 | ref := newObject(gvk, "") 801 | refKey := state.ResourceKey(gvk) 802 | s.References[refKey] = []*Unstructured{} 803 | 804 | refNames := []string{"test1", "test2"} 805 | for _, name := range refNames { 806 | r := ref.DeepCopy() 807 | r.SetName(name) 808 | r.SetUID(uuid.NewUUID()) 809 | s.References[refKey] = append(s.References[refKey], r) 810 | } 811 | } 812 | 813 | return s 814 | } 815 | 816 | func newObject(gvk schema.GroupVersionKind, name string) *Unstructured { 817 | object := &Unstructured{} 818 | object.SetGroupVersionKind(gvk) 819 | object.SetNamespace("default") 820 | object.SetName(name) 821 | object.SetUID(uuid.NewUUID()) 822 | 823 | SetNestedStringSlice(object.Object, []string{"c1", "c2"}, "spec", "configMapRefs") 824 | 825 | return object 826 | } 827 | 828 | func newPod(name string) *corev1.Pod { 829 | return &corev1.Pod{ 830 | ObjectMeta: metav1.ObjectMeta{ 831 | Namespace: "default", 832 | Name: name, 833 | UID: uuid.NewUUID(), 834 | }, 835 | Spec: corev1.PodSpec{ 836 | Containers: []corev1.Container{ 837 | corev1.Container{ 838 | Name: "test", 839 | Image: "nginx:latest", 840 | }, 841 | }, 842 | }, 843 | } 844 | } 845 | 846 | func newConfigMap(name string) *corev1.ConfigMap { 847 | return &corev1.ConfigMap{ 848 | ObjectMeta: metav1.ObjectMeta{ 849 | Namespace: "default", 850 | Name: name, 851 | UID: uuid.NewUUID(), 852 | }, 853 | } 854 | } 855 | 856 | func newCRD(kind string) *apiextensionsv1beta1.CustomResourceDefinition { 857 | group := "example.com" 858 | version := "v1alpha1" 859 | plural := strings.ToLower(kind) 860 | name := fmt.Sprintf("%s.%s", plural, group) 861 | 862 | return &apiextensionsv1beta1.CustomResourceDefinition{ 863 | ObjectMeta: metav1.ObjectMeta{ 864 | Name: name, 865 | }, 866 | Spec: apiextensionsv1beta1.CustomResourceDefinitionSpec{ 867 | Group: group, 868 | Version: version, 869 | Names: apiextensionsv1beta1.CustomResourceDefinitionNames{ 870 | Kind: kind, 871 | Plural: plural, 872 | Singular: plural, 873 | }, 874 | Scope: apiextensionsv1beta1.NamespaceScoped, 875 | }, 876 | } 877 | } 878 | --------------------------------------------------------------------------------