├── pkg ├── webhook │ ├── admission │ │ ├── testdata │ │ │ ├── response │ │ │ │ ├── good_allow.json │ │ │ │ ├── good_deny_quiet.json │ │ │ │ ├── good_deny.json │ │ │ │ └── good_allow_warnings.json │ │ │ └── demo-certs │ │ │ │ ├── server-key.pem │ │ │ │ ├── ca.pem │ │ │ │ ├── client-ca.pem │ │ │ │ └── server.crt │ │ ├── event.go │ │ ├── handler_test.go │ │ ├── response.go │ │ ├── manager_test.go │ │ ├── settings.go │ │ ├── response_test.go │ │ └── config.go │ ├── server │ │ ├── settings.go │ │ ├── testdata │ │ │ └── demo-certs │ │ │ │ ├── server-key.pem │ │ │ │ ├── ca.pem │ │ │ │ ├── client-ca.pem │ │ │ │ └── server.crt │ │ ├── server_test.go │ │ └── server.go │ ├── conversion │ │ ├── config.go │ │ ├── chain_test.go │ │ ├── settings.go │ │ ├── crd_client_config.go │ │ └── response.go │ └── validating │ │ └── validation │ │ └── validation_test.go ├── filter │ ├── filter.go │ └── jq │ │ └── apply.go ├── kube │ └── object_patch │ │ ├── testdata │ │ └── serialized_operations │ │ │ ├── invalid_create.yaml │ │ │ ├── invalid_delete.yaml │ │ │ ├── invalid_patch.yaml │ │ │ ├── valid_create.yaml │ │ │ ├── valid_delete.yaml │ │ │ └── valid_patch.yaml │ │ ├── validation_test.go │ │ └── options.go ├── hook │ ├── testdata │ │ ├── hook_manager │ │ │ ├── hook.sh │ │ │ ├── podHooks │ │ │ │ ├── hook.sh │ │ │ │ └── hook2.sh │ │ │ └── configMapHooks │ │ │ │ └── hook.sh │ │ ├── hook_manager_onstartup_order │ │ │ ├── hook04_startup_1.sh │ │ │ ├── hook01_startup_20.sh │ │ │ ├── hook02_startup_10.sh │ │ │ └── hook03_startup_15.sh │ │ ├── hook_manager_conversion_chains │ │ │ ├── hook2.sh │ │ │ └── hook.sh │ │ ├── hook_manager_conversion_chains_full │ │ │ ├── hook2.sh │ │ │ └── hook.sh │ │ └── hook_manager_validating │ │ │ └── hook.sh │ ├── config │ │ ├── schemas_test.go │ │ ├── validator.go │ │ ├── util.go │ │ └── versioned_untyped.go │ ├── task_metadata │ │ └── task_metadata_test.go │ ├── operation_test.go │ └── types │ │ └── bindings.go ├── schedule_manager │ └── types │ │ └── types.go ├── metric │ ├── collector_test.go │ └── adapter.go ├── shell-operator │ ├── testdata │ │ └── startup_tasks │ │ │ └── hooks │ │ │ ├── hook02_startup_1_schedule.sh │ │ │ ├── hook01_startup_20_kube.sh │ │ │ └── hook03_startup_10_kube_schedule.sh │ └── metrics.go ├── kube_events_manager │ ├── util_test.go │ ├── filter_test.go │ └── filter.go ├── utils │ ├── measure │ │ └── measure.go │ ├── string_helper │ │ ├── apigroup.go │ │ ├── safe_url.go │ │ ├── safe_url_test.go │ │ └── apigroup_test.go │ ├── checksum │ │ ├── checksum_test.go │ │ └── checksum.go │ ├── signal │ │ └── signal.go │ ├── exponential_backoff │ │ ├── delay_test.go │ │ └── delay.go │ ├── file │ │ └── dir.go │ └── labels │ │ └── labels.go ├── kubernetes.go ├── task │ └── queue │ │ ├── task_result.go │ │ └── queue_storage.go └── debug │ └── client.go ├── examples ├── 103-schedule │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── hooks │ │ ├── crontab-5-fields.sh │ │ └── crontab-6-fields.sh │ └── README.md ├── 200-advanced │ ├── Dockerfile │ ├── hooks │ │ ├── common │ │ │ └── functions.sh │ │ ├── 007-onstartup-2 │ │ │ └── shell-hook.sh │ │ ├── 001-onstartup-10 │ │ │ └── shell-hook.sh │ │ ├── 003-schedule │ │ │ └── schedule-hook.sh │ │ └── namespace-hook.sh │ ├── shell-operator-deploy.yaml │ ├── shell-operator-rbac.yaml │ └── README.md ├── 001-startup-shell │ ├── Dockerfile │ ├── hooks │ │ └── shell-hook.sh │ ├── shell-operator-pod.yaml │ └── README.md ├── 101-monitor-pods │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── hooks │ │ └── pods-hook.sh │ ├── shell-operator-rbac.yaml │ └── README.md ├── 104-secret-copier │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── hooks │ │ ├── common │ │ │ └── functions.sh │ │ ├── create_namespace │ │ ├── delete_secret │ │ ├── add_or_update_secret │ │ └── schedule_sync_secret │ └── shell-operator-rbac.yaml ├── 105-crd-simple │ ├── Dockerfile │ ├── cr-crontab.yaml │ ├── shell-operator-pod.yaml │ ├── shell-operator-rbac.yaml │ ├── hooks │ │ └── crd-hook.sh │ ├── README.md │ └── crd-simple.yaml ├── 003-common-library │ ├── Dockerfile │ ├── hooks │ │ ├── common │ │ │ └── functions.sh │ │ └── hook.sh │ ├── shell-operator-pod.yaml │ └── README.md ├── 102-monitor-namespaces │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── shell-operator-rbac.yaml │ ├── README.md │ └── hooks │ │ └── namespace-hook.sh ├── 106-monitor-events │ ├── Dockerfile │ ├── failed-pod.yaml │ ├── shell-operator-pod.yaml │ ├── shell-operator-rbac.yaml │ └── hooks │ │ └── events-hook.sh ├── 220-execution-rate │ ├── .helmignore │ ├── values.yaml │ ├── .dockerignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── crontab-recreate.sh │ ├── hooks │ │ └── settings-rate-limit.sh │ └── templates │ │ ├── rbac.yaml │ │ ├── deployment.yaml │ │ └── crd │ │ └── crontab.yaml ├── 210-conversion-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── values.yaml │ ├── crontab-v1alpha1.yaml │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── webhook-service.yaml │ │ ├── rbac.yaml │ │ ├── deployment.yaml │ │ └── crd │ │ │ └── crontab.yaml │ ├── hooks │ │ └── conversion-alpha.sh │ └── gen-certs.sh ├── 204-validating-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── crontab-non-valid.yaml │ ├── crontab-valid.yaml │ ├── values.yaml │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── webhook-service.yaml │ │ ├── rbac.yaml │ │ ├── deployment.yaml │ │ └── crd │ │ │ └── crontab.yaml │ ├── hooks │ │ └── validating.sh │ └── gen-certs.sh ├── 206-mutating-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── crontab-valid.yaml │ ├── values.yaml │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── webhook-service.yaml │ │ ├── rbac.yaml │ │ ├── deployment.yaml │ │ └── crd │ │ │ └── crontab.yaml │ ├── hooks │ │ └── mutating.sh │ ├── gen-certs.sh │ └── README.md ├── 002-startup-python │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── hooks │ │ └── 00-hook.py │ └── README.md ├── 202-repack-build │ ├── hooks │ │ └── shell-hook.sh │ ├── README.md │ └── Dockerfile ├── 201-install-with-helm-chart │ ├── Chart.yaml │ ├── templates │ │ ├── hook-configmap.yaml │ │ ├── shell-operator-rbac.yaml │ │ └── shell-operator-deployment.yaml │ ├── hooks │ │ └── namespace-hook.sh │ └── README.md └── 230-configmap-python │ ├── Dockerfile │ ├── shell-operator-pod.yaml │ ├── hooks │ └── 00-hook.py │ └── README.md ├── docs ├── src │ ├── image │ │ ├── logo-shell.png │ │ ├── shell-operator-logo.png │ │ └── shell-operator-small-logo.png │ ├── metrics │ │ └── ROOT.md │ └── SUMMARY.md └── book.toml ├── .dockerignore ├── tools └── ginkgo.go ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature.md │ └── bug.md ├── workflows │ ├── checks.yaml │ ├── docs.yaml │ ├── tests.yaml │ ├── lint.yaml │ └── build.yaml ├── dependabot.yaml ├── release.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── test ├── integration │ ├── kube_event_manager │ │ └── testdata │ │ │ └── test-pod.yaml │ ├── run.sh │ ├── suite │ │ └── run.go │ └── kubeclient │ │ └── kube_client_test.go ├── utils │ ├── assert.go │ ├── assert_test.go │ ├── jsonlogrecord_test.go │ ├── jsonlogrecord.go │ ├── directory.go │ ├── kubectl.go │ └── jqmatcher.go └── hook │ └── context │ └── README.md ├── Makefile ├── scripts └── ci │ ├── extract-file.sh │ └── codeclimate_upload.sh ├── shell_lib.sh ├── Dockerfile └── cmd └── shell-operator └── main.go /pkg/webhook/admission/testdata/response/good_allow.json: -------------------------------------------------------------------------------- 1 | {"allowed":true} -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_deny_quiet.json: -------------------------------------------------------------------------------- 1 | {"allowed":false} -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_deny.json: -------------------------------------------------------------------------------- 1 | {"allowed":false,"message": "Denied"} -------------------------------------------------------------------------------- /examples/103-schedule/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/200-advanced/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/001-startup-shell/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/104-secret-copier/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/105-crd-simple/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/003-common-library/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/102-monitor-namespaces/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/106-monitor-events/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /examples/220-execution-rate/.helmignore: -------------------------------------------------------------------------------- 1 | crontab-recreate.sh 2 | Dockerfile 3 | README.md 4 | shell-operator 5 | -------------------------------------------------------------------------------- /examples/220-execution-rate/values.yaml: -------------------------------------------------------------------------------- 1 | shellOperator: 2 | image: localhost:5000/shell-operator:example-220 3 | -------------------------------------------------------------------------------- /docs/src/image/logo-shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/HEAD/docs/src/image/logo-shell.png -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_allow_warnings.json: -------------------------------------------------------------------------------- 1 | {"allowed":true, "warnings": ["Warning 1", "Warning 2"]} -------------------------------------------------------------------------------- /examples/210-conversion-webhook/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | values.yaml 6 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/.helmignore: -------------------------------------------------------------------------------- 1 | crontab-*.yaml 2 | Dockerfile 3 | README.md 4 | gen-certs.sh 5 | shell-operator 6 | -------------------------------------------------------------------------------- /docs/src/image/shell-operator-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/HEAD/docs/src/image/shell-operator-logo.png -------------------------------------------------------------------------------- /examples/204-validating-webhook/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | README.md 6 | values.yaml 7 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | README.md 6 | values.yaml 7 | -------------------------------------------------------------------------------- /examples/220-execution-rate/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | README.md 6 | values.yaml 7 | -------------------------------------------------------------------------------- /examples/002-startup-python/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | RUN apk --no-cache add python3 3 | ADD hooks /hooks 4 | -------------------------------------------------------------------------------- /docs/src/image/shell-operator-small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/HEAD/docs/src/image/shell-operator-small-logo.png -------------------------------------------------------------------------------- /examples/204-validating-webhook/.helmignore: -------------------------------------------------------------------------------- 1 | crontab-valid.yaml 2 | crontab-non-valid.yaml 3 | Dockerfile 4 | README.md 5 | gen-certs.sh 6 | shell-operator 7 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/.helmignore: -------------------------------------------------------------------------------- 1 | crontab-valid.yaml 2 | crontab-non-valid.yaml 3 | Dockerfile 4 | README.md 5 | gen-certs.sh 6 | shell-operator 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | docs 3 | examples 4 | scripts 5 | test 6 | libjq 7 | 8 | .git 9 | .gitignore 10 | Dockerfile 11 | LICENSE 12 | *.md 13 | *.png -------------------------------------------------------------------------------- /examples/206-mutating-webhook/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: 206-mutating-webhook 3 | version: 1.0.0 4 | description: Shell-operator mutating hook example 5 | -------------------------------------------------------------------------------- /tools/ginkgo.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/onsi/ginkgo/v2" 7 | _ "github.com/onsi/ginkgo/v2/ginkgo/outline" 8 | ) 9 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: 204-validating-webhook 3 | version: 1.0.0 4 | description: Shell-operator validating hook example 5 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: 210-conversion-webhook 3 | version: 1.0.0 4 | description: Shell-operator CRD conversion hook example 5 | -------------------------------------------------------------------------------- /examples/220-execution-rate/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: 210-conversion-webhook 3 | version: 1.0.0 4 | description: Shell-operator CRD conversion hook example 5 | -------------------------------------------------------------------------------- /examples/200-advanced/hooks/common/functions.sh: -------------------------------------------------------------------------------- 1 | common::run_hook() { 2 | if [[ $1 == "--config" ]] ; then 3 | hook::config 4 | else 5 | hook::trigger 6 | fi 7 | } -------------------------------------------------------------------------------- /pkg/filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | type Filter interface { 4 | ApplyFilter(filterStr string, data map[string]any) ([]byte, error) 5 | FilterInfo() string 6 | } 7 | -------------------------------------------------------------------------------- /examples/003-common-library/hooks/common/functions.sh: -------------------------------------------------------------------------------- 1 | common::run_hook() { 2 | if [[ $1 == "--config" ]] ; then 3 | hook::config 4 | else 5 | hook::trigger 6 | fi 7 | } -------------------------------------------------------------------------------- /examples/220-execution-rate/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | LABEL app="shell-operator" example="220-execution-rate" 3 | 4 | # Add hooks 5 | ADD hooks /hooks 6 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | LABEL app="shell-operator" example="204-validating-webhook" 3 | 4 | # Add hooks 5 | ADD hooks /hooks 6 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | LABEL app="shell-operator" example="206-mutating-webhook" 3 | 4 | # Add hooks 5 | ADD hooks /hooks 6 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | LABEL app="shell-operator" example="210-conversion-webhook" 3 | 4 | # Add hooks 5 | ADD hooks /hooks 6 | -------------------------------------------------------------------------------- /pkg/kube/object_patch/testdata/serialized_operations/invalid_create.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | operation: Create 3 | namespace: default 4 | object: 5 | --- 6 | operation: CreateOrUpdate 7 | namespace: default 8 | object: -------------------------------------------------------------------------------- /examples/202-repack-build/hooks/shell-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | echo '{"configVersion":"v1", "onStartup": 1}' 5 | else 6 | echo "OnStartup shell hook" 7 | fi 8 | -------------------------------------------------------------------------------- /examples/001-startup-shell/hooks/shell-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | echo '{"configVersion":"v1", "onStartup": 1}' 5 | else 6 | echo "OnStartup shell hook" 7 | fi 8 | -------------------------------------------------------------------------------- /examples/201-install-with-helm-chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: 201-install-with-helm-chart 3 | version: 1.0.0 4 | description: Shell-operator is a tool for running event-driven scripts in a Kubernetes cluster 5 | -------------------------------------------------------------------------------- /examples/105-crd-simple/cr-crontab.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: crontab 5 | spec: 6 | cronSpec: "* * * * */10" 7 | image: my-awesome-cron-image 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/230-configmap-python/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | RUN apk --no-cache add python3 3 | RUN python3 -m ensurepip 4 | RUN pip3 install --no-cache --upgrade pip kubernetes 5 | ADD hooks /hooks 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Github Discussions 4 | url: https://github.com/flant/shell-operator/discussions 5 | about: Please ask and answer questions here 6 | -------------------------------------------------------------------------------- /docs/src/metrics/ROOT.md: -------------------------------------------------------------------------------- 1 | # Shell-operator metrics 2 | 3 | Shell-operator exports its built-in Prometheus metrics to the `/metrics` path. Custom metrics generated by hooks are reported to `/metrics/hooks`. The default port is 9115. 4 | -------------------------------------------------------------------------------- /examples/200-advanced/hooks/007-onstartup-2/shell-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | echo '{"configVersion":"v1", "onStartup": 2}' 5 | else 6 | echo "007-onstartup-2 hook is triggered" 7 | fi 8 | -------------------------------------------------------------------------------- /pkg/hook/testdata/hook_manager/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat <1 and sys.argv[1] == "--config": 7 | print('{"configVersion":"v1", "onStartup": 10}') 8 | else: 9 | print("OnStartup Python powered hook") 10 | -------------------------------------------------------------------------------- /examples/003-common-library/hooks/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source /hooks/common/functions.sh 4 | 5 | hook::config() { 6 | echo '{"configVersion": "v1", "onStartup": 10 }' 7 | } 8 | 9 | hook::trigger() { 10 | echo "hook::trigger function is called!" 11 | } 12 | 13 | common::run_hook "$@" 14 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/values.yaml: -------------------------------------------------------------------------------- 1 | shellOperator: 2 | image: localhost:5000/shell-operator:example-204 3 | # Important: this name should be the same as in gen-certs.sh and csr.json. 4 | validatingWebhookServiceName: example-204-validating-service 5 | validatingWebhookConfigurationName: example-204 6 | -------------------------------------------------------------------------------- /pkg/kube/object_patch/testdata/serialized_operations/invalid_delete.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | operation: Delete 3 | kind: ConfigMap 4 | name: "test" 5 | --- 6 | operation: DeleteInBackground 7 | apiversion: core/v1 8 | name: "test" 9 | --- 10 | operation: DeleteNonCascading 11 | apiversion: core/v1 12 | kind: ConfigMap 13 | -------------------------------------------------------------------------------- /pkg/kube_events_manager/util_test.go: -------------------------------------------------------------------------------- 1 | package kubeeventsmanager 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func Test_RandomizedResyncPeriod(t *testing.T) { 9 | t.SkipNow() 10 | for i := 0; i < 10; i++ { 11 | p := randomizedResyncPeriod() 12 | fmt.Printf("%02d. %s\n", i, p.String()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/105-crd-simple/shell-operator-pod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: shell-operator 6 | spec: 7 | containers: 8 | - name: shell-operator 9 | image: registry.mycompany.com/shell-operator:crd-simple 10 | imagePullPolicy: Always 11 | serviceAccountName: crd-simple-acc 12 | -------------------------------------------------------------------------------- /examples/200-advanced/hooks/001-onstartup-10/shell-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source /hooks/common/functions.sh 4 | 5 | hook::config() { 6 | echo '{"configVersion":"v1", "onStartup": 10 }' 7 | } 8 | 9 | hook::trigger() { 10 | echo "001-onstartup-10 hook is triggered" 11 | } 12 | 13 | common::run_hook "$@" 14 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/shell-operator-pod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: shell-operator 6 | spec: 7 | containers: 8 | - name: shell-operator 9 | image: registry.mycompany.com/shell-operator:monitor-pods 10 | imagePullPolicy: Always 11 | serviceAccountName: monitor-pods-acc 12 | -------------------------------------------------------------------------------- /examples/104-secret-copier/shell-operator-pod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: shell-operator 6 | spec: 7 | containers: 8 | - name: shell-operator 9 | image: registry.mycompany.com/shell-operator:secret-copier 10 | imagePullPolicy: Always 11 | serviceAccountName: secret-copier-acc 12 | -------------------------------------------------------------------------------- /pkg/shell-operator/testdata/startup_tasks/hooks/hook01_startup_20_kube.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat <= 0 { 12 | // 13 | return apiVersion[idx+1:] 14 | } 15 | return apiVersion 16 | } 17 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/templates/certs-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: example-204-validating-certs 5 | type: kubernetes.io/tls 6 | data: 7 | tls.crt: | 8 | {{ .Files.Get "validating-certs/tls.pem" | b64enc }} 9 | tls.key: | 10 | {{ .Files.Get "validating-certs/tls-key.pem" | b64enc }} 11 | ca.crt: | 12 | {{ .Files.Get "validating-certs/ca.pem" | b64enc }} 13 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/templates/certs-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: example-210-conversion-certs 5 | type: kubernetes.io/tls 6 | data: 7 | tls.crt: | 8 | {{ .Files.Get "conversion-certs/tls.pem" | b64enc }} 9 | tls.key: | 10 | {{ .Files.Get "conversion-certs/tls-key.pem" | b64enc }} 11 | ca.crt: | 12 | {{ .Files.Get "conversion-certs/ca.pem" | b64enc }} 13 | -------------------------------------------------------------------------------- /pkg/kube/object_patch/testdata/serialized_operations/valid_delete.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | operation: Delete 3 | apiVersion: core/v1 4 | kind: ConfigMap 5 | name: "test" 6 | namespace: "default" 7 | --- 8 | operation: DeleteInBackground 9 | apiVersion: core/v1 10 | kind: ConfigMap 11 | name: "test" 12 | namespace: "default" 13 | --- 14 | operation: DeleteNonCascading 15 | apiVersion: core/v1 16 | kind: ConfigMap 17 | name: "test" 18 | namespace: "default" -------------------------------------------------------------------------------- /examples/220-execution-rate/crontab-recreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while true ; do 4 | for i in `seq 1 4` ; do 5 | (cat </dev/null 2>/dev/null; then 13 | kubectl create -f - <<< "$object" >/dev/null 14 | else 15 | kubectl replace --force -f - <<< "$object" >/dev/null 16 | fi 17 | } 18 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](README.md) 4 | 5 | - [Quick Start](QUICK_START.md) 6 | - [Running Shell-operator](RUNNING.md) 7 | - [Hooks](HOOKS.md) 8 | - [Work with Kubernetes](KUBERNETES.md) 9 | - [Validating Webhook](BINDING_VALIDATING.md) 10 | - [Conversion Webhooks](BINDING_CONVERSION.md) 11 | - [Metrics](metrics/ROOT.md) 12 | - [Shell-operator metrics](metrics/SELF_METRICS.md) 13 | - [Return metrics from hooks](metrics/METRICS_FROM_HOOKS.md) 14 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/hooks/pods-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat <&2 "Traceback (most recent call last):" 13 | 14 | for ((frame=frames-2; frame >= 0; frame--)); do 15 | local lineno=${BASH_LINENO[frame]} 16 | 17 | printf >&2 ' File "%s", line %d, in %s\n' \ 18 | "${BASH_SOURCE[frame+1]}" "$lineno" "${FUNCNAME[frame+1]}" 19 | 20 | sed >&2 -n "${lineno}s/^[ ]*/ /p" "${BASH_SOURCE[frame+1]}" 21 | done 22 | 23 | printf >&2 "Exiting with status %d\n" "$ret" 24 | } 25 | 26 | trap backtrace ERR 27 | 28 | for f in $(find /frameworks/shell/ -type f -iname "*.sh"); do 29 | source "$f" 30 | done 31 | -------------------------------------------------------------------------------- /examples/202-repack-build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest as shell-operator 2 | 3 | # Final image with kubectl 1.17 and curl 4 | FROM ubuntu:20.04 5 | RUN apt-get update && \ 6 | apt-get install -y jq ca-certificates bash tini curl && \ 7 | wget https://storage.googleapis.com/kubernetes-release/release/v1.17.4/bin/linux/amd64/kubectl -O /bin/kubectl && \ 8 | chmod +x /bin/kubectl && \ 9 | mkdir /hooks 10 | 11 | COPY --from=shell-operator /shell-operator /shell-operator 12 | COPY --from=shell-operator /frameworks / 13 | COPY --from=shell-operator /shell_lib.sh / 14 | 15 | COPY hooks /hooks 16 | 17 | WORKDIR / 18 | ENV SHELL_OPERATOR_HOOKS_DIR /hooks 19 | ENV LOG_TYPE json 20 | ENTRYPOINT ["/sbin/tini", "--", "/shell-operator"] 21 | CMD ["start"] 22 | -------------------------------------------------------------------------------- /examples/104-secret-copier/shell-operator-rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: secret-copier-acc 6 | 7 | --- 8 | apiVersion: rbac.authorization.k8s.io/v1beta1 9 | kind: ClusterRole 10 | metadata: 11 | name: secret-copier 12 | rules: 13 | - apiGroups: [""] 14 | resources: ["namespaces"] 15 | verbs: ["get", "watch", "list"] 16 | - apiGroups: [""] 17 | resources: ["secrets"] 18 | verbs: ["*"] 19 | 20 | --- 21 | apiVersion: rbac.authorization.k8s.io/v1beta1 22 | kind: ClusterRoleBinding 23 | metadata: 24 | name: secret-copier 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: ClusterRole 28 | name: secret-copier 29 | subjects: 30 | - kind: ServiceAccount 31 | name: secret-copier-acc 32 | namespace: secret-copier -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | 10 | # Maintain Go modules 11 | - package-ecosystem: "gomod" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | day: "monday" 16 | open-pull-requests-limit: 5 17 | ignore: 18 | - dependency-name: "k8s.io/api" 19 | - dependency-name: "k8s.io/apimachinery" 20 | - dependency-name: "k8s.io/client-go" 21 | 22 | # Maintain Dockerfile 23 | - package-ecosystem: "docker" 24 | directory: "/" 25 | schedule: 26 | interval: "weekly" 27 | day: "monday" 28 | open-pull-requests-limit: 5 29 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - release-note/ignore 5 | categories: 6 | - title: Exciting New Features 🎉 7 | labels: 8 | - release-note/new-feature 9 | - title: Enhancements 🚀 10 | labels: 11 | - enhancement 12 | - release-note/enhancement 13 | - title: Bug Fixes 🐛 14 | labels: 15 | - bug 16 | - release-note/bug 17 | - title: Breaking Changes 🛠 18 | labels: 19 | - release-note/breaking-change 20 | - title: Deprecations ❌ 21 | labels: 22 | - release-note/deprecation 23 | - title: Dependency Updates ⬆️ 24 | labels: 25 | - dependencies 26 | - release-note/dependencies 27 | - title: Other Changes 28 | labels: 29 | - "*" 30 | -------------------------------------------------------------------------------- /pkg/utils/string_helper/safe_url_test.go: -------------------------------------------------------------------------------- 1 | package string_helper 2 | 3 | import "testing" 4 | 5 | func Test_SafeURLString(t *testing.T) { 6 | tests := []struct { 7 | in string 8 | out string 9 | }{ 10 | { 11 | "importantHook", 12 | "important-hook", 13 | }, 14 | { 15 | "hooks/nextHook", 16 | "hooks/next-hook", 17 | }, 18 | { 19 | "weird spaced Name", 20 | "weird-spaced-name", 21 | }, 22 | { 23 | "weird---dashed---Name", 24 | "weird-dashed-name", 25 | }, 26 | { 27 | "utf8 странное имя для binding", 28 | "utf8-binding", 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.in, func(t *testing.T) { 34 | res := SafeURLString(tt.in) 35 | if res != tt.out { 36 | t.Fatalf("safeUrl should give '%s', got '%s'", tt.out, res) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | #### Overview 10 | 11 | 12 | 13 | #### What this PR does / why we need it 14 | 15 | 21 | 22 | #### Special notes for your reviewer 23 | -------------------------------------------------------------------------------- /test/utils/jsonlogrecord_test.go: -------------------------------------------------------------------------------- 1 | //go:build test 2 | // +build test 3 | 4 | package utils 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_JsonLogRecord(t *testing.T) { 13 | line := "{\"foo\":\"bar\", \"baz\":\"qrux\" }" 14 | 15 | logRecord, err := NewJsonLogRecord().FromString(line) 16 | assert.NoError(t, err) 17 | 18 | assert.True(t, logRecord.HasField("foo")) 19 | assert.True(t, logRecord.FieldEquals("foo", "bar")) 20 | assert.False(t, logRecord.FieldEquals("foo", "Bar")) 21 | assert.True(t, logRecord.HasField("baz")) 22 | assert.True(t, logRecord.FieldContains("baz", "ux")) 23 | assert.False(t, logRecord.FieldContains("baz", "zz")) 24 | 25 | m := map[string]string{ 26 | "foo": "bar", 27 | "baz": "qrux", 28 | } 29 | assert.Equal(t, m, logRecord.Map()) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/hook/testdata/hook_manager_conversion_chains_full/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat < 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like to see** 14 | 15 | 16 | **Describe alternatives you've considered** 17 | 18 | 19 | **Additional context** 20 | -------------------------------------------------------------------------------- /examples/230-configmap-python/hooks/00-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import kubernetes import client, config 5 | 6 | if __name__ == "__main__": 7 | if len(sys.argv)>1 and sys.argv[1] == "--config": 8 | print('{"configVersion":"v1", "onStartup": 10}') 9 | else: 10 | print("OnStartup Python powered hook") 11 | config.load_incluster_config() 12 | 13 | v1 = client.CoreV1Api() 14 | metadata = client.V1ObjectMeta(name="config-map-for-proj") 15 | 16 | config_map = { 17 | "memory": "256Gi", 18 | "coreCPU": "128", 19 | "ver": "1.0.1" 20 | } 21 | 22 | config_map_body = client.V1ConfigMap(data=config_map, metadata=metadata) 23 | resp = v1.create_namespaced_config_map(namespace="default", body=config_map_body) 24 | print(resp) 25 | 26 | -------------------------------------------------------------------------------- /examples/001-startup-shell/README.md: -------------------------------------------------------------------------------- 1 | ## onStartup shell example 2 | 3 | Example of a hook written as bash script. 4 | 5 | ### run 6 | 7 | Build Shell-operator image with custom scripts: 8 | 9 | ``` 10 | docker build -t "registry.mycompany.com/shell-operator:startup-shell" . 11 | docker push registry.mycompany.com/shell-operator:startup-shell 12 | ``` 13 | 14 | Edit image in shell-operator-pod.yaml and apply manifests: 15 | 16 | ``` 17 | kubectl create ns example-startup-shell 18 | kubectl -n example-startup-shell apply -f shell-operator-pod.yaml 19 | ``` 20 | 21 | See in logs that shell-hook.sh was run: 22 | 23 | ``` 24 | kubectl -n example-startup-shell logs -f po/shell-operator 25 | ... 26 | OnStartup shell hook 27 | ... 28 | ``` 29 | 30 | ### cleanup 31 | 32 | ``` 33 | kubectl delete ns/example-startup-shell 34 | docker rmi registry.mycompany.com/shell-operator:startup-shell 35 | ``` 36 | -------------------------------------------------------------------------------- /pkg/webhook/conversion/config.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import ( 4 | "github.com/flant/shell-operator/pkg/utils/string_helper" 5 | ) 6 | 7 | // WebhookConfig 8 | type WebhookConfig struct { 9 | Rules []Rule 10 | CrdName string // This name is used as a suffix to create different URLs for clientConfig in CRD. 11 | Metadata struct { 12 | Name string 13 | DebugName string 14 | LogLabels map[string]string 15 | MetricLabels map[string]string 16 | } 17 | } 18 | 19 | type Rule struct { 20 | FromVersion string `json:"fromVersion"` 21 | ToVersion string `json:"toVersion"` 22 | } 23 | 24 | func (r Rule) String() string { 25 | return r.FromVersion + "->" + r.ToVersion 26 | } 27 | 28 | func (r Rule) ShortFromVersion() string { 29 | return string_helper.TrimGroup(r.FromVersion) 30 | } 31 | 32 | func (r Rule) ShortToVersion() string { 33 | return string_helper.TrimGroup(r.ToVersion) 34 | } 35 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: example-206-acc 6 | labels: 7 | heritage: example-206 8 | --- 9 | # Create and update ValidatingWebhookConfiguration 10 | apiVersion: rbac.authorization.k8s.io/v1 11 | kind: ClusterRole 12 | metadata: 13 | name: example-206 14 | labels: 15 | heritage: example-206 16 | rules: 17 | - apiGroups: ["admissionregistration.k8s.io"] 18 | resources: ["mutatingwebhookconfigurations"] 19 | verbs: ["create", "list", "update"] 20 | 21 | --- 22 | apiVersion: rbac.authorization.k8s.io/v1 23 | kind: ClusterRoleBinding 24 | metadata: 25 | name: example-206 26 | labels: 27 | heritage: example-206 28 | roleRef: 29 | apiGroup: rbac.authorization.k8s.io 30 | kind: ClusterRole 31 | name: example-206 32 | subjects: 33 | - kind: ServiceAccount 34 | name: example-206-acc 35 | namespace: {{ .Release.Namespace }} 36 | -------------------------------------------------------------------------------- /examples/201-install-with-helm-chart/templates/shell-operator-deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: shell-operator 6 | labels: 7 | app: shell-operator 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: shell-operator 13 | template: 14 | metadata: 15 | labels: 16 | app: shell-operator 17 | annotations: 18 | checksum/hook: {{ .Files.Get "hooks/namespace-hook.sh" | sha256sum }} 19 | spec: 20 | containers: 21 | - name: shell-operator 22 | image: "ghcr.io/flant/shell-operator:latest" 23 | imagePullPolicy: Always 24 | volumeMounts: 25 | - name: example-helm-hooks 26 | mountPath: /hooks/ 27 | serviceAccountName: example-helm-acc 28 | volumes: 29 | - name: example-helm-hooks 30 | configMap: 31 | name: example-helm-hooks 32 | defaultMode: 0777 33 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: example-204-acc 6 | labels: 7 | heritage: example-204 8 | --- 9 | # Create and update ValidatingWebhookConfiguration 10 | apiVersion: rbac.authorization.k8s.io/v1beta1 11 | kind: ClusterRole 12 | metadata: 13 | name: example-204 14 | labels: 15 | heritage: example-204 16 | rules: 17 | - apiGroups: ["admissionregistration.k8s.io"] 18 | resources: ["validatingwebhookconfigurations"] 19 | verbs: ["create", "list", "update"] 20 | 21 | --- 22 | apiVersion: rbac.authorization.k8s.io/v1beta1 23 | kind: ClusterRoleBinding 24 | metadata: 25 | name: example-204 26 | labels: 27 | heritage: example-204 28 | roleRef: 29 | apiGroup: rbac.authorization.k8s.io 30 | kind: ClusterRole 31 | name: example-204 32 | subjects: 33 | - kind: ServiceAccount 34 | name: example-204-acc 35 | namespace: {{ .Release.Namespace }} 36 | -------------------------------------------------------------------------------- /pkg/kube_events_manager/filter_test.go: -------------------------------------------------------------------------------- 1 | package kubeeventsmanager 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 9 | 10 | "github.com/flant/shell-operator/pkg/filter/jq" 11 | ) 12 | 13 | func TestApplyFilter(t *testing.T) { 14 | t.Run("filter func with error", func(t *testing.T) { 15 | uns := &unstructured.Unstructured{Object: map[string]interface{}{"foo": "bar"}} 16 | filter := jq.NewFilter() 17 | _, err := applyFilter("", filter, filterFuncWithError, uns) 18 | assert.EqualError(t, err, "filterFn (github.com/flant/shell-operator/pkg/kube_events_manager.filterFuncWithError) contains an error: invalid character 'a' looking for beginning of value") 19 | }) 20 | } 21 | 22 | func filterFuncWithError(_ *unstructured.Unstructured) (interface{}, error) { 23 | var s []string 24 | err := json.Unmarshal([]byte("asdasd"), &s) 25 | 26 | return s, err 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Report a bug to help us improve Shell-operator 4 | --- 5 | 11 | 12 | **Expected behavior (what you expected to happen)**: 13 | 14 | **Actual behavior (what actually happened)**: 15 | 16 | **Steps to reproduce**: 17 | 1. ... 18 | 2. ... 19 | 3. ... 20 | 21 | **Environment**: 22 | - Shell-operator version: 23 | - Kubernetes version: 24 | - Installation type (kubectl apply, helm chart, etc.): 25 | 26 | **Anything else we should know?**: 27 | 28 | **Additional information for debugging (if necessary)**: 29 | 30 |
Hook script
31 | 
32 | 
33 | 34 |
Logs
35 | 
36 | 
-------------------------------------------------------------------------------- /examples/003-common-library/README.md: -------------------------------------------------------------------------------- 1 | ## common library example 2 | 3 | Example with common libraries. 4 | 5 | ### run 6 | 7 | Build shell-operator image with custom scripts: 8 | 9 | ``` 10 | $ docker build -t "registry.mycompany.com/shell-operator:common-library" . 11 | $ docker push registry.mycompany.com/shell-operator:common-library 12 | ``` 13 | 14 | Edit image in shell-operator-pod.yaml and apply manifests: 15 | 16 | ``` 17 | $ kubectl create ns example-common-library 18 | $ kubectl -n example-common-library apply -f shell-operator-pod.yaml 19 | ``` 20 | 21 | See in logs that hook.sh was run: 22 | 23 | ``` 24 | $ kubectl -n example-common-library logs -f po/shell-operator 25 | ... 26 | INFO : Running hook 'hook.sh' binding 'ON_STARTUP' ... 27 | hook::trigger function is called! 28 | ... 29 | ``` 30 | 31 | ### cleanup 32 | 33 | ``` 34 | $ kubectl delete ns/example-common-library 35 | $ docker rmi registry.mycompany.com/shell-operator:common-library 36 | ``` 37 | -------------------------------------------------------------------------------- /pkg/kube/object_patch/options.go: -------------------------------------------------------------------------------- 1 | package object_patch 2 | 3 | import ( 4 | sdkpkg "github.com/deckhouse/module-sdk/pkg" 5 | ) 6 | 7 | type Option func(o sdkpkg.PatchCollectorOptionApplier) 8 | 9 | func (opt Option) Apply(o sdkpkg.PatchCollectorOptionApplier) { 10 | opt(o) 11 | } 12 | 13 | func WithSubresource(subresource string) Option { 14 | return func(o sdkpkg.PatchCollectorOptionApplier) { 15 | o.WithSubresource(subresource) 16 | } 17 | } 18 | 19 | func withIgnoreMissingObject(ignore bool) Option { 20 | return func(o sdkpkg.PatchCollectorOptionApplier) { 21 | o.WithIgnoreMissingObject(ignore) 22 | } 23 | } 24 | 25 | func WithIgnoreMissingObject() Option { 26 | return withIgnoreMissingObject(true) 27 | } 28 | 29 | func withIgnoreHookError(ignore bool) Option { 30 | return func(o sdkpkg.PatchCollectorOptionApplier) { 31 | o.WithIgnoreHookError(ignore) 32 | } 33 | } 34 | 35 | func WithIgnoreHookError() Option { 36 | return withIgnoreHookError(true) 37 | } 38 | -------------------------------------------------------------------------------- /test/integration/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script can be used to run integration tests locally. 4 | # Set CLUSTER_NAME to use existing cluster for tests. 5 | # i.e. $ CLUSTER_NAME=kube-19 ./test/integration/run.sh 6 | 7 | # Start cluster using kind. Delete it on interrupt or on exit. 8 | DEFAULT_CLUSTER_NAME="e2e-test" 9 | KIND_NODE_IMAGE="kindest/node:v1.19.7" 10 | 11 | cleanup() { 12 | kind delete cluster --name $CLUSTER_NAME 13 | } 14 | 15 | if [[ -z $CLUSTER_NAME ]] ; then 16 | echo "Start new cluster" 17 | CLUSTER_NAME="${DEFAULT_CLUSTER_NAME}" 18 | 19 | trap '(exit 130)' INT 20 | trap '(exit 143)' TERM 21 | trap 'rc=$?; cleanup; exit $rc' EXIT 22 | 23 | kind create cluster \ 24 | --name $CLUSTER_NAME \ 25 | --image $KIND_NODE_IMAGE 26 | else 27 | echo "Use existing cluster '${CLUSTER_NAME}'" 28 | fi 29 | 30 | 31 | ./ginkgo \ 32 | --tags 'integration test' \ 33 | --vet off \ 34 | --race \ 35 | -p \ 36 | -r test/integration 37 | -------------------------------------------------------------------------------- /examples/002-startup-python/README.md: -------------------------------------------------------------------------------- 1 | ## onStartup python example 2 | 3 | Example of a hook written in Python. 4 | 5 | ### run 6 | 7 | Build shell-operator image with custom scripts: 8 | 9 | ``` 10 | $ docker build -t "registry.mycompany.com/shell-operator:startup-python" . 11 | $ docker push registry.mycompany.com/shell-operator:startup-python 12 | ``` 13 | 14 | Edit image in shell-operator-pod.yaml and apply manifests: 15 | 16 | ``` 17 | $ kubectl create ns example-startup-python 18 | $ kubectl -n example-startup-python apply -f shell-operator-pod.yaml 19 | ``` 20 | 21 | See in logs that 00-hook.py was run: 22 | 23 | ``` 24 | $ kubectl -n example-startup-python logs -f po/shell-operator 25 | ... 26 | INFO : Running hook '00-hook.py' binding 'ON_STARTUP' ... 27 | OnStartup Python powered hook 28 | ... 29 | ``` 30 | 31 | ### cleanup 32 | 33 | ``` 34 | $ kubectl delete ns/example-startup-python 35 | $ docker rmi registry.mycompany.com/shell-operator:startup-python 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/230-configmap-python/README.md: -------------------------------------------------------------------------------- 1 | ## onStartup python example 2 | 3 | Example of a hook written in Python. 4 | 5 | ### run 6 | 7 | Build shell-operator image with custom scripts: 8 | 9 | ``` 10 | $ docker build -t "registry.mycompany.com/shell-operator:startup-python" . 11 | $ docker push registry.mycompany.com/shell-operator:startup-python 12 | ``` 13 | 14 | Edit image in shell-operator-pod.yaml and apply manifests: 15 | 16 | ``` 17 | $ kubectl create ns example-startup-python 18 | $ kubectl -n example-startup-python apply -f shell-operator-pod.yaml 19 | ``` 20 | 21 | See in logs that 00-hook.py was run: 22 | 23 | ``` 24 | $ kubectl -n example-startup-python logs -f po/shell-operator 25 | ... 26 | INFO : Running hook '00-hook.py' binding 'ON_STARTUP' ... 27 | OnStartup Python powered hook 28 | ... 29 | ``` 30 | 31 | ### cleanup 32 | 33 | ``` 34 | $ kubectl delete ns/example-startup-python 35 | $ docker rmi registry.mycompany.com/shell-operator:startup-python 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/220-execution-rate/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: shell-operator 6 | labels: 7 | heritage: example-220 8 | app: shell-operator-example-220 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: shell-operator-example-220 14 | strategy: 15 | type: Recreate 16 | template: 17 | metadata: 18 | labels: 19 | heritage: example-220 20 | app: shell-operator-example-220 21 | annotations: 22 | checksum/hook: {{ .Files.Get "hooks/settings-rate-limit.sh" | sha256sum }} 23 | spec: 24 | containers: 25 | - name: shell-operator 26 | image: {{ .Values.shellOperator.image | quote }} 27 | imagePullPolicy: Always 28 | env: 29 | - name: SHELL_OPERATOR_NAMESPACE 30 | valueFrom: 31 | fieldRef: 32 | fieldPath: metadata.namespace 33 | serviceAccountName: example-220-acc 34 | -------------------------------------------------------------------------------- /pkg/utils/string_helper/apigroup_test.go: -------------------------------------------------------------------------------- 1 | package string_helper 2 | 3 | import "testing" 4 | 5 | func Test_TrimGroup(t *testing.T) { 6 | var in string 7 | var actual string 8 | var expect string 9 | 10 | in = "stable.example.com/v1beta1" 11 | expect = "v1beta1" 12 | actual = TrimGroup(in) 13 | if actual != expect { 14 | t.Fatalf("group prefix should be trimmed: expect '%s', got '%s'", expect, actual) 15 | } 16 | 17 | in = "v1beta1" 18 | expect = "v1beta1" 19 | actual = TrimGroup(in) 20 | if actual != expect { 21 | t.Fatalf("group prefix should be trimmed: expect '%s', got '%s'", expect, actual) 22 | } 23 | 24 | in = "stable.example.com/" 25 | expect = "" 26 | actual = TrimGroup(in) 27 | if actual != expect { 28 | t.Fatalf("group prefix should be trimmed: expect '%s', got '%s'", expect, actual) 29 | } 30 | 31 | in = "" 32 | expect = "" 33 | actual = TrimGroup(in) 34 | if actual != expect { 35 | t.Fatalf("group prefix should be trimmed: expect '%s', got '%s'", expect, actual) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/106-monitor-events/hooks/events-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat < $VALIDATING_RESPONSE_PATH 30 | {"allowed":true} 31 | EOF 32 | else 33 | cat < $VALIDATING_RESPONSE_PATH 34 | {"allowed":false, "message":"Only images from repo.example.com are allowed"} 35 | EOF 36 | fi 37 | } 38 | 39 | hook::run $@ 40 | -------------------------------------------------------------------------------- /test/utils/jsonlogrecord.go: -------------------------------------------------------------------------------- 1 | //go:build test 2 | // +build test 3 | 4 | package utils 5 | 6 | import ( 7 | "encoding/json" 8 | ) 9 | 10 | type JsonLogRecord map[string]string 11 | 12 | func NewJsonLogRecord() JsonLogRecord { 13 | return make(JsonLogRecord) 14 | } 15 | 16 | func (j JsonLogRecord) FromString(s string) (JsonLogRecord, error) { 17 | return j.FromBytes([]byte(s)) 18 | } 19 | 20 | func (j JsonLogRecord) FromBytes(arr []byte) (JsonLogRecord, error) { 21 | err := json.Unmarshal(arr, &j) 22 | if err != nil { 23 | return j, err 24 | } 25 | return j, nil 26 | } 27 | 28 | func (j JsonLogRecord) Map() map[string]string { 29 | return j 30 | } 31 | 32 | func (j JsonLogRecord) HasField(key string) bool { 33 | return HasField(j, key) 34 | } 35 | 36 | func (j JsonLogRecord) FieldContains(key string, s string) bool { 37 | return FieldContains(j, key, s) 38 | } 39 | 40 | func (j JsonLogRecord) FieldHasPrefix(key string, s string) bool { 41 | return FieldHasPrefix(j, key, s) 42 | } 43 | 44 | func (j JsonLogRecord) FieldEquals(key string, s string) bool { 45 | return FieldEquals(j, key, s) 46 | } 47 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/hooks/conversion-alpha.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $FRAMEWORK_DIR/shell_lib.sh 4 | 5 | function __config__() { 6 | cat <$CONVERSION_RESPONSE_PATH 28 | {"convertedObjects": $converted} 29 | EOF 30 | else 31 | cat <$CONVERSION_RESPONSE_PATH 32 | {"failedMessage":"Conversion of crontabs.stable.example.com is failed"} 33 | EOF 34 | fi 35 | } 36 | 37 | hook::run $@ 38 | -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/demo-certs/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl 3 | cm5ldGVzMB4XDTIwMTIwNzA5MjQzM1oXDTMwMTIwNTA5MjQzM1owFTETMBEGA1UE 4 | AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALu5 5 | V2gMv0LLYqQxuFCNOLFLL1DCEAVTUU/hcwaSTEwVdrfXYNmIh9VjMThs/9+I1jdB 6 | HtRCwhcyyabyzFoiUh53sUlqDB7pIndcbOovfUtF81sz7ZwT16Jzxd4AjjHni9Lh 7 | SqYlJSPSNMCVAz0d+Nnvyx54U0uqsNqL/VK4klN0ERry8HbfvQwct6m42bEhtj8k 8 | WgMAg3JPpGteUtoLMM80B/OxLT7Gp/VAIDCnViPmxNOYOlkjDY735XNpF2Y5NEgQ 9 | FP6fyJxQ1Qu/Lpzw7xzyfYG7JDabrpgwIk4PUTUSO/pBNUg484eS269/zApvk5db 10 | VX+6+sZwMPlXcLAjWacCAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB 11 | /wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAC5QQ/i7O5ywS5q1jgAixCWGxI+H 12 | dKN8xQ03WpibtGFPrtcx1bddPN0WXKrwuGF64oxeGwo1Ewbl+JNGszeQgTPv5mti 13 | 4ZbttBbCNgTs9fm5nUWOtaUDSl32WNweVLeVLK2cgQ6Wz0uTAVY05S3jhFJVvk/z 14 | zWhdp315RG3pceErwyAgGs1mj1OqwhiGjswbTozx02AL/puuFZrvR3G4re23Hxcl 15 | WVvMqhGPiGJzY9WldZn5oIFN0HkDWGrve4cBBXCZ26hyICNHXCR7HhT50bwOZzhf 16 | yviy8//eIhvYo+Nf0NKIchLGgCpApzo0CmcQZ6a+0OL5hN9pKAHEgJnJOdk= 17 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /pkg/webhook/server/testdata/demo-certs/ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl 3 | cm5ldGVzMB4XDTIwMTIwNzA5MjQzM1oXDTMwMTIwNTA5MjQzM1owFTETMBEGA1UE 4 | AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALu5 5 | V2gMv0LLYqQxuFCNOLFLL1DCEAVTUU/hcwaSTEwVdrfXYNmIh9VjMThs/9+I1jdB 6 | HtRCwhcyyabyzFoiUh53sUlqDB7pIndcbOovfUtF81sz7ZwT16Jzxd4AjjHni9Lh 7 | SqYlJSPSNMCVAz0d+Nnvyx54U0uqsNqL/VK4klN0ERry8HbfvQwct6m42bEhtj8k 8 | WgMAg3JPpGteUtoLMM80B/OxLT7Gp/VAIDCnViPmxNOYOlkjDY735XNpF2Y5NEgQ 9 | FP6fyJxQ1Qu/Lpzw7xzyfYG7JDabrpgwIk4PUTUSO/pBNUg484eS269/zApvk5db 10 | VX+6+sZwMPlXcLAjWacCAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB 11 | /wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAC5QQ/i7O5ywS5q1jgAixCWGxI+H 12 | dKN8xQ03WpibtGFPrtcx1bddPN0WXKrwuGF64oxeGwo1Ewbl+JNGszeQgTPv5mti 13 | 4ZbttBbCNgTs9fm5nUWOtaUDSl32WNweVLeVLK2cgQ6Wz0uTAVY05S3jhFJVvk/z 14 | zWhdp315RG3pceErwyAgGs1mj1OqwhiGjswbTozx02AL/puuFZrvR3G4re23Hxcl 15 | WVvMqhGPiGJzY9WldZn5oIFN0HkDWGrve4cBBXCZ26hyICNHXCR7HhT50bwOZzhf 16 | yviy8//eIhvYo+Nf0NKIchLGgCpApzo0CmcQZ6a+0OL5hN9pKAHEgJnJOdk= 17 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /pkg/hook/task_metadata/task_metadata_test.go: -------------------------------------------------------------------------------- 1 | package task_metadata 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | 8 | bctx "github.com/flant/shell-operator/pkg/hook/binding_context" 9 | htypes "github.com/flant/shell-operator/pkg/hook/types" 10 | "github.com/flant/shell-operator/pkg/task" 11 | ) 12 | 13 | func Test_HookMetadata_Access(t *testing.T) { 14 | g := NewWithT(t) 15 | 16 | Task := task.NewTask(HookRun). 17 | WithMetadata(HookMetadata{ 18 | HookName: "test-hook", 19 | BindingType: htypes.Schedule, 20 | BindingContext: []bctx.BindingContext{ 21 | {Binding: "each_1_min"}, 22 | {Binding: "each_5_min"}, 23 | }, 24 | AllowFailure: true, 25 | }) 26 | 27 | hm := HookMetadataAccessor(Task) 28 | 29 | g.Expect(hm.HookName).Should(Equal("test-hook")) 30 | g.Expect(hm.BindingType).Should(Equal(htypes.Schedule)) 31 | g.Expect(hm.AllowFailure).Should(BeTrue()) 32 | g.Expect(hm.BindingContext).Should(HaveLen(2)) 33 | g.Expect(hm.BindingContext[0].Binding).Should(Equal("each_1_min")) 34 | g.Expect(hm.BindingContext[1].Binding).Should(Equal("each_5_min")) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/webhook/server/testdata/demo-certs/client-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl 3 | cm5ldGVzMB4XDTIwMTIwNzA5MjQzM1oXDTMwMTIwNTA5MjQzM1owFTETMBEGA1UE 4 | AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALu5 5 | V2gMv0LLYqQxuFCNOLFLL1DCEAVTUU/hcwaSTEwVdrfXYNmIh9VjMThs/9+I1jdB 6 | HtRCwhcyyabyzFoiUh53sUlqDB7pIndcbOovfUtF81sz7ZwT16Jzxd4AjjHni9Lh 7 | SqYlJSPSNMCVAz0d+Nnvyx54U0uqsNqL/VK4klN0ERry8HbfvQwct6m42bEhtj8k 8 | WgMAg3JPpGteUtoLMM80B/OxLT7Gp/VAIDCnViPmxNOYOlkjDY735XNpF2Y5NEgQ 9 | FP6fyJxQ1Qu/Lpzw7xzyfYG7JDabrpgwIk4PUTUSO/pBNUg484eS269/zApvk5db 10 | VX+6+sZwMPlXcLAjWacCAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB 11 | /wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAC5QQ/i7O5ywS5q1jgAixCWGxI+H 12 | dKN8xQ03WpibtGFPrtcx1bddPN0WXKrwuGF64oxeGwo1Ewbl+JNGszeQgTPv5mti 13 | 4ZbttBbCNgTs9fm5nUWOtaUDSl32WNweVLeVLK2cgQ6Wz0uTAVY05S3jhFJVvk/z 14 | zWhdp315RG3pceErwyAgGs1mj1OqwhiGjswbTozx02AL/puuFZrvR3G4re23Hxcl 15 | WVvMqhGPiGJzY9WldZn5oIFN0HkDWGrve4cBBXCZ26hyICNHXCR7HhT50bwOZzhf 16 | yviy8//eIhvYo+Nf0NKIchLGgCpApzo0CmcQZ6a+0OL5hN9pKAHEgJnJOdk= 17 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /pkg/webhook/server/testdata/demo-certs/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICxDCCAaygAwIBAgIRALdOeYJpMsBRH0dEvEdYeRkwDQYJKoZIhvcNAQELBQAw 3 | FTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0yMDEyMDgxMDAyMzlaFw0yMTEyMDgx 4 | MDAyMzlaMDsxOTA3BgNVBAMTMHNoZWxsLW9wZXJhdG9yLXZhbGlkYXRpbmctc2Vy 5 | dmljZS5zaGVsbC10ZXN0LnN2YzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABN6H 6 | rDix35/iP7QRUNvRBHYvaez22CpjxOdgQuiqAvCVA3M5wMyWCfJVk9fDJSAzitp9 7 | ofvNGPoHVDekiSssk1WjgbMwgbAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG 8 | CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwewYDVR0RBHQwcoIwc2hlbGwtb3BlcmF0 9 | b3ItdmFsaWRhdGluZy1zZXJ2aWNlLnNoZWxsLXRlc3Quc3Zjgj5zaGVsbC1vcGVy 10 | YXRvci12YWxkaWF0aW5nLXNlcnZpY2Uuc2hlbGwtdGVzdC5zdmMuY2x1c3Rlci5s 11 | b2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAYkblqQ4dvX4QPqZHbcaqkZB+2WCt6wYw 12 | TXA5h3ZWCRhLaNx5K1fgTrBTLRgMisUnDpGDKvqRskL9lNrGKDL6Ws2sxqGJheyg 13 | K6ABc+mlH3sXYu+WEAP3Qeqfp/gJ+G7fzopoaTtMTAjGu2jwshqsGU1X+ndr3/kz 14 | i640KaUM0VL39PNBlu15Rvt6ycYHf5jZIy7UOLize9U1NK7pZ/XkUOPsFQSk6LML 15 | OhXGVsHxWuqSQ+DDEdQ+eBN1jGeGMjW/C1m8UQ72mOUJDSo3T+3Zi1cz4Vwk1WMp 16 | FNZyPCNxhFLMDGN+1Gxrp12MiXPSFvw1ziNZ/KMB2JqX2KZCt3M5/w== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/demo-certs/client-ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICyDCCAbCgAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl 3 | cm5ldGVzMB4XDTIwMTIwNzA5MjQzM1oXDTMwMTIwNTA5MjQzM1owFTETMBEGA1UE 4 | AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALu5 5 | V2gMv0LLYqQxuFCNOLFLL1DCEAVTUU/hcwaSTEwVdrfXYNmIh9VjMThs/9+I1jdB 6 | HtRCwhcyyabyzFoiUh53sUlqDB7pIndcbOovfUtF81sz7ZwT16Jzxd4AjjHni9Lh 7 | SqYlJSPSNMCVAz0d+Nnvyx54U0uqsNqL/VK4klN0ERry8HbfvQwct6m42bEhtj8k 8 | WgMAg3JPpGteUtoLMM80B/OxLT7Gp/VAIDCnViPmxNOYOlkjDY735XNpF2Y5NEgQ 9 | FP6fyJxQ1Qu/Lpzw7xzyfYG7JDabrpgwIk4PUTUSO/pBNUg484eS269/zApvk5db 10 | VX+6+sZwMPlXcLAjWacCAwEAAaMjMCEwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB 11 | /wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAC5QQ/i7O5ywS5q1jgAixCWGxI+H 12 | dKN8xQ03WpibtGFPrtcx1bddPN0WXKrwuGF64oxeGwo1Ewbl+JNGszeQgTPv5mti 13 | 4ZbttBbCNgTs9fm5nUWOtaUDSl32WNweVLeVLK2cgQ6Wz0uTAVY05S3jhFJVvk/z 14 | zWhdp315RG3pceErwyAgGs1mj1OqwhiGjswbTozx02AL/puuFZrvR3G4re23Hxcl 15 | WVvMqhGPiGJzY9WldZn5oIFN0HkDWGrve4cBBXCZ26hyICNHXCR7HhT50bwOZzhf 16 | yviy8//eIhvYo+Nf0NKIchLGgCpApzo0CmcQZ6a+0OL5hN9pKAHEgJnJOdk= 17 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/demo-certs/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICxDCCAaygAwIBAgIRALdOeYJpMsBRH0dEvEdYeRkwDQYJKoZIhvcNAQELBQAw 3 | FTETMBEGA1UEAxMKa3ViZXJuZXRlczAeFw0yMDEyMDgxMDAyMzlaFw0yMTEyMDgx 4 | MDAyMzlaMDsxOTA3BgNVBAMTMHNoZWxsLW9wZXJhdG9yLXZhbGlkYXRpbmctc2Vy 5 | dmljZS5zaGVsbC10ZXN0LnN2YzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABN6H 6 | rDix35/iP7QRUNvRBHYvaez22CpjxOdgQuiqAvCVA3M5wMyWCfJVk9fDJSAzitp9 7 | ofvNGPoHVDekiSssk1WjgbMwgbAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG 8 | CCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwewYDVR0RBHQwcoIwc2hlbGwtb3BlcmF0 9 | b3ItdmFsaWRhdGluZy1zZXJ2aWNlLnNoZWxsLXRlc3Quc3Zjgj5zaGVsbC1vcGVy 10 | YXRvci12YWxkaWF0aW5nLXNlcnZpY2Uuc2hlbGwtdGVzdC5zdmMuY2x1c3Rlci5s 11 | b2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAYkblqQ4dvX4QPqZHbcaqkZB+2WCt6wYw 12 | TXA5h3ZWCRhLaNx5K1fgTrBTLRgMisUnDpGDKvqRskL9lNrGKDL6Ws2sxqGJheyg 13 | K6ABc+mlH3sXYu+WEAP3Qeqfp/gJ+G7fzopoaTtMTAjGu2jwshqsGU1X+ndr3/kz 14 | i640KaUM0VL39PNBlu15Rvt6ycYHf5jZIy7UOLize9U1NK7pZ/XkUOPsFQSk6LML 15 | OhXGVsHxWuqSQ+DDEdQ+eBN1jGeGMjW/C1m8UQ72mOUJDSo3T+3Zi1cz4Vwk1WMp 16 | FNZyPCNxhFLMDGN+1Gxrp12MiXPSFvw1ziNZ/KMB2JqX2KZCt3M5/w== 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /test/integration/suite/run.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package suite 5 | 6 | import ( 7 | "os" 8 | "testing" 9 | 10 | "github.com/deckhouse/deckhouse/pkg/log" 11 | klient "github.com/flant/kube-client/client" 12 | objectpatch "github.com/flant/shell-operator/pkg/kube/object_patch" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var ( 18 | ClusterName string 19 | ContextName string 20 | KubeClient *klient.Client 21 | ObjectPatcher *objectpatch.ObjectPatcher 22 | ) 23 | 24 | func RunIntegrationSuite(t *testing.T, description string, clusterPrefix string) { 25 | ClusterName = os.Getenv("CLUSTER_NAME") 26 | ContextName = "kind-" + ClusterName 27 | 28 | RegisterFailHandler(Fail) 29 | RunSpecs(t, description) 30 | } 31 | 32 | var _ = BeforeSuite(func() { 33 | // Initialize kube client out-of-cluster 34 | KubeClient = klient.New(klient.WithLogger(log.NewNop())) 35 | KubeClient.WithContextName(ContextName) 36 | err := KubeClient.Init() 37 | Expect(err).ShouldNot(HaveOccurred()) 38 | 39 | ObjectPatcher = objectpatch.NewObjectPatcher(KubeClient, log.NewNop()) 40 | }) 41 | -------------------------------------------------------------------------------- /pkg/hook/config/validator.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-openapi/spec" 7 | "github.com/go-openapi/strfmt" 8 | "github.com/go-openapi/validate" 9 | "github.com/hashicorp/go-multierror" 10 | ) 11 | 12 | // See https://github.com/kubernetes/apiextensions-apiserver/blob/1bb376f70aa2c6f2dec9a8c7f05384adbfac7fbb/pkg/apiserver/validation/validation.go#L47 13 | func ValidateConfig(dataObj interface{}, s *spec.Schema, rootName string) error { 14 | if s == nil { 15 | return fmt.Errorf("validate config: schema is not provided") 16 | } 17 | 18 | validator := validate.NewSchemaValidator(s, nil, rootName, strfmt.Default) // , validate.DisableObjectArrayTypeCheck(true) 19 | 20 | result := validator.Validate(dataObj) 21 | if result.IsValid() { 22 | return nil 23 | } 24 | 25 | var allErrs *multierror.Error 26 | for _, err := range result.Errors { 27 | allErrs = multierror.Append(allErrs, err) 28 | } 29 | 30 | // NOTE: no validation errors, but config is not valid! 31 | if allErrs.Len() == 0 { 32 | allErrs = multierror.Append(allErrs, fmt.Errorf("configuration is not valid")) 33 | } 34 | 35 | return allErrs 36 | } 37 | -------------------------------------------------------------------------------- /examples/102-monitor-namespaces/README.md: -------------------------------------------------------------------------------- 1 | ## monitor-namespaces-hook example 2 | 3 | Example of jqFilter usage to triggered a hook on changing namespace labels. 4 | 5 | 6 | ### Run 7 | 8 | Build shell-operator image with custom scripts: 9 | 10 | ``` 11 | $ docker build -t "registry.mycompany.com/shell-operator:monitor-namespaces" . 12 | $ docker push registry.mycompany.com/shell-operator:monitor-namespaces 13 | ``` 14 | 15 | Edit image in shell-operator-pod.yaml and apply manifests: 16 | 17 | ``` 18 | $ kubectl create ns example-monitor-namespaces 19 | $ kubectl -n example-monitor-namespaces apply -f shell-operator-rbac.yaml 20 | $ kubectl -n example-monitor-namespaces apply -f shell-operator-pod.yaml 21 | ``` 22 | 23 | Create ns to trigget onKuberneteEvent: 24 | 25 | ``` 26 | $ kubectl create ns foobar 27 | ``` 28 | 29 | See in logs that hook was run: 30 | 31 | ``` 32 | $ kubectl -n example-monitor-namespaces logs po/shell-operator 33 | 34 | ... 35 | Namespace foobar was created 36 | ... 37 | ``` 38 | 39 | ### cleanup 40 | 41 | ``` 42 | $ kubectl delete ns/example-monitor-namespaces 43 | $ docker rmi registry.mycompany.com/shell-operator:monitor-namespaces 44 | 45 | ``` -------------------------------------------------------------------------------- /pkg/hook/operation_test.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | func Test_ValidateMetricOperations(t *testing.T) { 10 | // g := NewWithT(t) 11 | tests := []struct { 12 | name string 13 | op string 14 | expected bool 15 | }{ 16 | { 17 | "simple", 18 | `{"group":"someGroup", "action":"expire"}`, 19 | true, 20 | }, 21 | { 22 | "action set", 23 | `{"name":"metric_1", "action":"set", "value":1}`, 24 | true, 25 | }, 26 | { 27 | "set shortcut", 28 | `{"name":"metric_1", "set":1}`, 29 | true, 30 | }, 31 | { 32 | "invalid", 33 | `{"group":"someGroup", "action":"expired"}`, 34 | false, 35 | }, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | g := NewWithT(t) 41 | 42 | ops, err := MetricOperationsFromBytes([]byte(tt.op), "") 43 | g.Expect(err).ShouldNot(HaveOccurred()) 44 | 45 | err = ValidateOperations(ops) 46 | if tt.expected { 47 | g.Expect(err).ShouldNot(HaveOccurred()) 48 | } else { 49 | // t.Logf("expected error: %v", err) 50 | g.Expect(err).Should(HaveOccurred()) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /scripts/ci/codeclimate_upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | echo Download codeclimate test-reporter 4 | curl -Ls https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -o ./cc-test-reporter 5 | chmod +x ./cc-test-reporter 6 | ./cc-test-reporter --version 7 | 8 | COVERAGE_PREFIX=${COVERAGE_PREFIX:-github.com/flant/shell-operator} 9 | 10 | coverage_files=$(find $COVERAGE_DIR -name '*.out') 11 | for file in ${coverage_files[@]} 12 | do 13 | echo Convert "'"$file"'" to json 14 | file_name=$(echo $file | tr / _) 15 | ./cc-test-reporter format-coverage \ 16 | -t=gocov \ 17 | -o="cc-coverage/$file_name.codeclimate.json" \ 18 | -p=${COVERAGE_PREFIX} \ 19 | "$file" 20 | done 21 | 22 | echo Create combined coverage report 23 | ./cc-test-reporter sum-coverage \ 24 | -o="cc-coverage/codeclimate.json" \ 25 | -p=$(ls -1q cc-coverage/*.codeclimate.json | wc -l) \ 26 | cc-coverage/*.codeclimate.json 27 | 28 | 29 | if [[ ${CC_TEST_REPORTER_ID} == "" ]] ; then 30 | echo "Set \$CC_TEST_REPORTER_ID to upload coverage report!" 31 | exit 1 32 | fi 33 | 34 | echo Upload coverage report 35 | ./cc-test-reporter upload-coverage \ 36 | -i="cc-coverage/codeclimate.json" 37 | -------------------------------------------------------------------------------- /pkg/webhook/admission/handler_test.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import "testing" 4 | 5 | func Test_DetectConfigurationAndWebhook(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | path string 9 | expect []string 10 | }{ 11 | { 12 | "simple", 13 | "/azaa/qqqq", 14 | []string{"azaa", "qqqq"}, 15 | }, 16 | { 17 | "composite webhookId", 18 | "/hooks/hook-name/my-crd-validator", 19 | []string{"hooks", "hook-name/my-crd-validator"}, 20 | }, 21 | { 22 | "no webhookId", 23 | "/hooks/", 24 | []string{"hooks", ""}, 25 | }, 26 | { 27 | "no configurationId", 28 | "////", 29 | []string{"", ""}, 30 | }, 31 | { 32 | "empty_1", 33 | "", 34 | []string{"", ""}, 35 | }, 36 | { 37 | "empty_2", 38 | "/", 39 | []string{"", ""}, 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | c, w := detectConfigurationAndWebhook(tt.path) 46 | if c != tt.expect[0] { 47 | t.Fatalf("expected configurationId '%s', got '%s'", tt.expect[0], c) 48 | } 49 | if w != tt.expect[1] { 50 | t.Fatalf("expected webhookId '%s', got '%s'", tt.expect[1], w) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/utils/signal/signal.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/deckhouse/deckhouse/pkg/log" 10 | ) 11 | 12 | // WaitForProcessInterruption wait for SIGINT or SIGTERM and run a callback function. 13 | // 14 | // First signal start a callback function, which should call os.Exit(0). 15 | // Next signal will call os.Exit(128 + signal-value). 16 | // If no cb is given, 17 | func WaitForProcessInterruption(cb ...func()) { 18 | allowedCount := 1 19 | interruptCh := make(chan os.Signal, 1) 20 | 21 | forcedExit := func(s os.Signal) { 22 | log.Info("Forced shutdown by signal", slog.String("name", s.String())) 23 | 24 | signum := 0 25 | if v, ok := s.(syscall.Signal); ok { 26 | signum = int(v) 27 | } 28 | os.Exit(128 + signum) 29 | } 30 | 31 | signal.Notify(interruptCh, syscall.SIGINT, syscall.SIGTERM) 32 | for { 33 | sig := <-interruptCh 34 | allowedCount-- 35 | switch allowedCount { 36 | case 0: 37 | if len(cb) > 0 { 38 | log.Info("Grace shutdown by signal", slog.String("name", sig.String())) 39 | cb[0]() 40 | } else { 41 | forcedExit(sig) 42 | } 43 | case -1: 44 | forcedExit(sig) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/webhook/validating/validation/validation_test.go: -------------------------------------------------------------------------------- 1 | package validation 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | v1 "k8s.io/api/admissionregistration/v1" 8 | "sigs.k8s.io/yaml" 9 | ) 10 | 11 | func decodeValidatingFromYaml(inYaml string) *v1.ValidatingWebhookConfiguration { 12 | res := new(v1.ValidatingWebhookConfiguration) 13 | 14 | err := yaml.Unmarshal([]byte(inYaml), res) 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | return res 20 | } 21 | 22 | func Test_Validate(t *testing.T) { 23 | g := NewWithT(t) 24 | 25 | validConfYaml := ` 26 | #apiVersion: admissionregistration.k8s.io/v1 27 | #kind: ValidatingWebhookConfiguration 28 | #metadata: 29 | # name: "pod-policy.example.com" 30 | webhooks: 31 | - name: "pod-policy.example.com" 32 | objectSelector: 33 | matchLabels: 34 | foo: bar 35 | rules: 36 | - apiGroups: [""] 37 | apiVersions: ["v1"] 38 | operations: ["CREATE"] 39 | resources: ["pods"] 40 | scope: "Namespaced" 41 | sideEffects: None 42 | timeoutSeconds: 5 43 | ` 44 | validConf := decodeValidatingFromYaml(validConfYaml) 45 | 46 | err := ValidateValidatingWebhooks(validConf) 47 | 48 | g.Expect(err).ShouldNot(HaveOccurred()) 49 | } 50 | -------------------------------------------------------------------------------- /examples/104-secret-copier/hooks/create_namespace: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source /hooks/common/functions.sh 4 | 5 | hook::config() { 6 | cat < int64(MaxExponentialBackoffDelay.Seconds()) || seconds < 0 { 22 | t.Fatalf("delay for retry %d should be greater than 0 and less than MaxExponentialBackoffDelay=%d seconds, actual delay is %s", i, int64(MaxExponentialBackoffDelay.Seconds()), delay) 23 | } 24 | case i > ExponentialCalculationsCount: 25 | if seconds != int64(MaxExponentialBackoffDelay.Seconds()) { 26 | t.Fatalf("delay for retry %d should be MaxExponentialBackoffDelay=%d seconds, actual delay is %s", i, int64(MaxExponentialBackoffDelay.Seconds()), delay) 27 | } 28 | } 29 | 30 | // debug messages 31 | // t.Logf("Delay for %02d retry: %s\n", i, delay) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/hook/testdata/hook_manager_validating/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat < $VALIDATING_RESPONSE_PATH 33 | {"allowed":true, "patch": "$PATCH"} 34 | EOF 35 | # else 36 | # cat < $VALIDATING_RESPONSE_PATH 37 | #{"allowed":false, "message":"Only images from repo.example.com are allowed"} 38 | #EOF 39 | # fi 40 | } 41 | 42 | hook::run $@ 43 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/README.md: -------------------------------------------------------------------------------- 1 | ## monitor-pods-hook example 2 | 3 | Example of monitoring new Pods. 4 | 5 | ### Run 6 | 7 | Build shell-operator image with custom script: 8 | 9 | ``` 10 | $ docker build -t "registry.mycompany.com/shell-operator:monitor-pods" . 11 | $ docker push registry.mycompany.com/shell-operator:monitor-pods 12 | ``` 13 | 14 | Edit image in shell-operator-pod.yaml and apply manifests: 15 | 16 | ``` 17 | $ kubectl create ns example-monitor-pods 18 | $ kubectl -n example-monitor-pods apply -f shell-operator-rbac.yaml 19 | $ kubectl -n example-monitor-pods apply -f shell-operator-pod.yaml 20 | ``` 21 | 22 | Scale kubernetes-dashboard to trigger onKuberneteEvent: 23 | 24 | ``` 25 | $ kubectl -n kube-system scale --replicas=1 deploy/kubernetes-dashboard 26 | ``` 27 | 28 | See in logs that hook was run: 29 | 30 | ``` 31 | $ kubectl -n example-monitor-pods logs po/shell-operator 32 | 33 | ... 34 | Pod 'kubernetes-dashboard-769df5545f-pzg7x' added 35 | ... 36 | Pod 'kubernetes-dashboard-769df5545f-xnmdl' added 37 | ... 38 | ``` 39 | 40 | ### cleanup 41 | 42 | ``` 43 | $ kubectl delete clusterrolebinding/monitor-pods 44 | $ kubectl delete clusterrole/monitor-pods 45 | $ kubectl delete ns/example-monitor-pods 46 | $ docker rmi registry.mycompany.com/shell-operator:monitor-pods 47 | ``` 48 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | # Run unit tests. 2 | # Start for pull requests and on push to default branch. 3 | name: Unit tests 4 | on: 5 | pull_request: 6 | types: [opened, synchronize] 7 | jobs: 8 | run_unit_tests: 9 | name: Run unit tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up Go 1.23 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: "1.23" 16 | 17 | - name: Check out shell-operator code 18 | uses: actions/checkout@v4 19 | 20 | - name: Restore Go modules 21 | id: go-modules-cache 22 | uses: actions/cache@v4.2.3 23 | with: 24 | path: | 25 | ~/go/pkg/mod 26 | key: ${{ runner.os }}-gomod-${{ hashFiles('go.mod', 'go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-gomod- 29 | 30 | - name: Download Go modules 31 | if: steps.go-modules-cache.outputs.cache-hit != 'true' 32 | run: | 33 | go mod download 34 | echo -n "Go modules unpacked size is: " && du -sh $HOME/go/pkg/mod 35 | 36 | - name: Run unit tests 37 | run: | 38 | export GOOS=linux 39 | 40 | go test \ 41 | --race \ 42 | -tags test \ 43 | ./cmd/... ./pkg/... ./test/utils 44 | -------------------------------------------------------------------------------- /examples/201-install-with-helm-chart/hooks/namespace-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat <. 5 | name: crontabs.stable.example.com 6 | spec: 7 | # group name to use for REST API: /apis// 8 | group: stable.example.com 9 | # either Namespaced or Cluster 10 | scope: Namespaced 11 | names: 12 | # plural name to be used in the URL: /apis/// 13 | plural: crontabs 14 | # singular name to be used as an alias on the CLI and for display 15 | singular: crontab 16 | # kind is normally the CamelCased singular type. Your resource manifests use this. 17 | kind: CronTab 18 | # shortNames allow shorter string to match your resource on the CLI 19 | shortNames: 20 | - ct 21 | # preserveUnknownFields: false 22 | # list of versions supported by this CustomResourceDefinition 23 | versions: 24 | - name: v1 25 | # Each version can be enabled/disabled by Served flag. 26 | served: true 27 | # One and only one version must be marked as the storage version. 28 | storage: true 29 | schema: 30 | openAPIV3Schema: 31 | type: object 32 | properties: 33 | spec: 34 | type: object 35 | properties: 36 | cronSpec: 37 | type: string 38 | image: 39 | type: string 40 | replicas: 41 | type: integer 42 | -------------------------------------------------------------------------------- /pkg/webhook/admission/response.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type Response struct { 14 | Allowed bool `json:"allowed"` 15 | Message string `json:"message,omitempty"` 16 | Warnings []string `json:"warnings,omitempty"` 17 | Patch []byte `json:"patch,omitempty"` 18 | } 19 | 20 | func ResponseFromFile(filePath string) (*Response, error) { 21 | data, err := os.ReadFile(filePath) 22 | if err != nil { 23 | return nil, fmt.Errorf("cannot read %s: %s", filePath, err) 24 | } 25 | 26 | if len(data) == 0 { 27 | return nil, nil 28 | } 29 | 30 | return ResponseFromBytes(data) 31 | } 32 | 33 | func ResponseFromBytes(data []byte) (*Response, error) { 34 | return FromReader(bytes.NewReader(data)) 35 | } 36 | 37 | func FromReader(r io.Reader) (*Response, error) { 38 | response := new(Response) 39 | 40 | if err := json.NewDecoder(r).Decode(response); err != nil { 41 | return nil, err 42 | } 43 | 44 | return response, nil 45 | } 46 | 47 | func (r *Response) Dump() string { 48 | b := new(strings.Builder) 49 | b.WriteString("AdmissionResponse(allowed=") 50 | b.WriteString(strconv.FormatBool(r.Allowed)) 51 | if len(r.Patch) > 0 { 52 | b.WriteString(",patch=") 53 | b.Write(r.Patch) 54 | } 55 | if r.Message != "" { 56 | b.WriteString(",msg=") 57 | b.WriteString(r.Message) 58 | } 59 | for _, warning := range r.Warnings { 60 | b.WriteString(",warn=") 61 | b.WriteString(warning) 62 | } 63 | b.WriteString(")") 64 | return b.String() 65 | } 66 | -------------------------------------------------------------------------------- /pkg/webhook/conversion/settings.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import ( 4 | "github.com/flant/shell-operator/pkg/app" 5 | "github.com/flant/shell-operator/pkg/webhook/server" 6 | ) 7 | 8 | type WebhookSettings struct { 9 | server.Settings 10 | CAPath string 11 | CABundle []byte 12 | } 13 | 14 | // DefaultSettings returns default settings for conversion webhook 15 | // This is initialized at startup and can be modified by flag parsing 16 | var DefaultSettings = &WebhookSettings{ 17 | Settings: server.Settings{ 18 | ServerCertPath: app.ConversionServerCertPathDefault, 19 | ServerKeyPath: app.ConversionServerKeyPathDefault, 20 | ClientCAPaths: nil, 21 | ServiceName: app.ConversionServiceNameDefault, 22 | ListenAddr: app.ConversionListenAddrDefault, 23 | ListenPort: app.ConversionListenPortDefault, 24 | }, 25 | CAPath: app.ConversionCAPathDefault, 26 | } 27 | 28 | // InitFromFlags updates DefaultSettings with values from parsed flags 29 | func InitFromFlags(serviceName, certPath, keyPath, caPath string, clientCAs []string, port, addr string) { 30 | if serviceName != "" { 31 | DefaultSettings.ServiceName = serviceName 32 | } 33 | if certPath != "" { 34 | DefaultSettings.ServerCertPath = certPath 35 | } 36 | if keyPath != "" { 37 | DefaultSettings.ServerKeyPath = keyPath 38 | } 39 | if caPath != "" { 40 | DefaultSettings.CAPath = caPath 41 | } 42 | if len(clientCAs) > 0 { 43 | DefaultSettings.ClientCAPaths = clientCAs 44 | } 45 | if port != "" { 46 | DefaultSettings.ListenPort = port 47 | } 48 | if addr != "" { 49 | DefaultSettings.ListenAddr = addr 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/200-advanced/README.md: -------------------------------------------------------------------------------- 1 | ## advanced example 2 | 3 | Use Deployment to install shell-operator with several hooks. 4 | 5 | 6 | ### Run 7 | 8 | Build shell-operator image with custom scripts: 9 | 10 | ``` 11 | $ docker build -t "registry.mycompany.com/shell-operator:advanced" . 12 | $ docker push registry.mycompany.com/shell-operator:advanced 13 | ``` 14 | 15 | Edit image in shell-operator-pod.yaml and apply manifests: 16 | 17 | ``` 18 | $ kubectl create ns example-advanced 19 | $ kubectl -n example-advanced apply -f shell-operator-rbac.yaml 20 | $ kubectl -n example-advanced apply -f shell-operator-deploy.yaml 21 | ``` 22 | 23 | Create ns to trigger onKubernetesEvent: 24 | 25 | ``` 26 | $ kubectl create ns foobar 27 | ``` 28 | 29 | See in logs that startup hooks are run in order and all other hooks are triggered: 30 | 31 | ``` 32 | $ kubectl -n example-advanced logs deploy/shell-operator 33 | ... 34 | INFO : TASK_RUN HookRun@ON_STARTUP namespace-hook.sh 35 | 007-onstartup-2 hook is triggered 36 | ... 37 | INFO : TASK_RUN HookRun@ON_STARTUP namespace-hook.sh 38 | namespace-hook is triggered on startup. 39 | ... 40 | INFO : TASK_RUN HookRun@ON_STARTUP 001-onstartup-10/shell-hook.sh 41 | 001-onstartup-10 hook is triggered 42 | ... 43 | INFO : TASK_RUN HookRun@SCHEDULE 003-schedule/schedule-hook.sh 44 | Message from Schedule hook! 45 | ... 46 | INFO : TASK_RUN HookRun@KUBERNETES namespace-hook.sh 47 | Namespace qweqwe was created 48 | ... 49 | ``` 50 | 51 | ### cleanup 52 | 53 | ``` 54 | $ kubectl delete ns/example-advanced 55 | $ docker rmi registry.mycompany.com/shell-operator:advanced 56 | ``` 57 | -------------------------------------------------------------------------------- /pkg/utils/file/dir.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/flant/shell-operator/pkg/app" 9 | ) 10 | 11 | func RequireExistingDirectory(inDir string) (string, error) { 12 | if inDir == "" { 13 | return "", fmt.Errorf("path is required but not set") 14 | } 15 | 16 | dir, err := filepath.Abs(inDir) 17 | if err != nil { 18 | return "", fmt.Errorf("get absolute path: %w", err) 19 | } 20 | 21 | if exists := DirExists(dir); !exists { 22 | return "", fmt.Errorf("path '%s' not exist", dir) 23 | } 24 | 25 | return dir, nil 26 | } 27 | 28 | func EnsureTempDirectory(inDir string) (string, error) { 29 | // No path to temporary dir, use default temporary dir. 30 | if inDir == "" { 31 | tmpPath := app.AppName + "-*" 32 | dir, err := os.MkdirTemp("", tmpPath) 33 | if err != nil { 34 | return "", fmt.Errorf("create tmp dir in '%s': %s", tmpPath, err) 35 | } 36 | return dir, nil 37 | } 38 | 39 | // Get absolute path for temporary directory and create if needed. 40 | dir, err := filepath.Abs(inDir) 41 | if err != nil { 42 | return "", fmt.Errorf("get absolute path: %v", err) 43 | } 44 | if exists := DirExists(dir); !exists { 45 | err := os.Mkdir(dir, os.FileMode(0o777)) 46 | if err != nil { 47 | return "", fmt.Errorf("create tmp dir '%s': %s", dir, err) 48 | } 49 | } 50 | return dir, nil 51 | } 52 | 53 | // DirExists checking for directory existence 54 | // return bool value 55 | func DirExists(path string) bool { 56 | fileInfo, err := os.Stat(path) 57 | if err != nil && os.IsNotExist(err) { 58 | return false 59 | } 60 | 61 | return fileInfo.IsDir() 62 | } 63 | -------------------------------------------------------------------------------- /test/integration/kubeclient/kube_client_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package kubeclient_test 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | "time" 10 | 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | 15 | . "github.com/flant/shell-operator/test/integration/suite" 16 | ) 17 | 18 | func Test(t *testing.T) { 19 | RunIntegrationSuite(t, "kube client suite", "kube-client-test") 20 | } 21 | 22 | var _ = Describe("Kubernetes API client package", func() { 23 | When("client connect outside of the cluster", func() { 24 | It("should list deployments", func(ctx context.Context) { 25 | list, err := KubeClient.AppsV1().Deployments("").List(ctx, metav1.ListOptions{}) 26 | Ω(err).Should(Succeed()) 27 | Ω(list.Items).Should(Not(HaveLen(0))) 28 | }, SpecTimeout(10*time.Second)) 29 | 30 | It("should find GroupVersionResource for Pod by kind only", func(ctx context.Context) { 31 | gvr, err := KubeClient.GroupVersionResource("", "Pod") 32 | Ω(err).Should(Succeed()) 33 | Ω(gvr.Resource).Should(Equal("pods")) 34 | Ω(gvr.Group).Should(Equal("")) 35 | Ω(gvr.Version).Should(Equal("v1")) 36 | }, SpecTimeout(10*time.Second)) 37 | 38 | It("should find GroupVersionResource for Deployment by apiVersion and kind", func(ctx context.Context) { 39 | gvr, err := KubeClient.GroupVersionResource("apps/v1", "Deployment") 40 | Ω(err).Should(Succeed()) 41 | Ω(gvr.Resource).Should(Equal("deployments")) 42 | Ω(gvr.Group).Should(Equal("apps")) 43 | Ω(gvr.Version).Should(Equal("v1")) 44 | }, SpecTimeout(10*time.Second)) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: shell-operator 6 | labels: 7 | heritage: example-204 8 | app: shell-operator-example-204 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: shell-operator-example-204 14 | strategy: 15 | type: Recreate 16 | template: 17 | metadata: 18 | labels: 19 | heritage: example-204 20 | app: shell-operator-example-204 21 | annotations: 22 | checksum/hook: {{ .Files.Get "hooks/validating.sh" | sha256sum }} 23 | spec: 24 | containers: 25 | - name: shell-operator 26 | image: {{ .Values.shellOperator.image | quote }} 27 | imagePullPolicy: Always 28 | env: 29 | - name: SHELL_OPERATOR_NAMESPACE 30 | valueFrom: 31 | fieldRef: 32 | fieldPath: metadata.namespace 33 | - name: VALIDATING_WEBHOOK_SERVICE_NAME 34 | value: {{ .Values.shellOperator.validatingWebhookServiceName | quote }} 35 | - name: VALIDATING_WEBHOOK_CONFIGURATION_NAME 36 | value: {{ .Values.shellOperator.validatingWebhookConfigurationName | quote }} 37 | livenessProbe: 38 | httpGet: 39 | port: 9680 40 | path: /healthz 41 | scheme: HTTPS 42 | volumeMounts: 43 | - name: validating-certs 44 | mountPath: /validating-certs/ 45 | readOnly: true 46 | serviceAccountName: example-204-acc 47 | 48 | volumes: 49 | - name: validating-certs 50 | secret: 51 | secretName: example-204-validating-certs 52 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: shell-operator 6 | labels: 7 | heritage: example-206 8 | app: shell-operator-example-206 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: shell-operator-example-206 14 | strategy: 15 | type: Recreate 16 | template: 17 | metadata: 18 | labels: 19 | heritage: example-206 20 | app: shell-operator-example-206 21 | annotations: 22 | checksum/hook: {{ .Files.Get "hooks/validating.sh" | sha256sum }} 23 | spec: 24 | containers: 25 | - name: shell-operator 26 | image: {{ .Values.shellOperator.image | quote }} 27 | imagePullPolicy: Always 28 | env: 29 | - name: SHELL_OPERATOR_NAMESPACE 30 | valueFrom: 31 | fieldRef: 32 | fieldPath: metadata.namespace 33 | - name: LOG_LEVEL 34 | value: Debug 35 | - name: VALIDATING_WEBHOOK_SERVICE_NAME 36 | value: {{ .Values.shellOperator.webhookServiceName | quote }} 37 | - name: VALIDATING_WEBHOOK_CONFIGURATION_NAME 38 | value: {{ .Values.shellOperator.webhookConfigurationName | quote }} 39 | livenessProbe: 40 | httpGet: 41 | port: 9680 42 | path: /healthz 43 | scheme: HTTPS 44 | volumeMounts: 45 | - name: validating-certs 46 | mountPath: /validating-certs/ 47 | readOnly: true 48 | serviceAccountName: example-206-acc 49 | volumes: 50 | - name: validating-certs 51 | secret: 52 | secretName: example-206-certs 53 | -------------------------------------------------------------------------------- /examples/104-secret-copier/hooks/add_or_update_secret: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source /hooks/common/functions.sh 4 | 5 | hook::config() { 6 | cat <. 9 | name: crontabs.stable.example.com 10 | labels: 11 | heritage: example-204 12 | spec: 13 | # group name to use for REST API: /apis// 14 | group: stable.example.com 15 | # list of versions supported by this CustomResourceDefinition 16 | versions: 17 | - name: v1 18 | # Each version can be enabled/disabled by Served flag. 19 | served: true 20 | # One and only one version must be marked as the storage version. 21 | storage: true 22 | schema: 23 | openAPIV3Schema: 24 | type: object 25 | properties: 26 | spec: 27 | type: object 28 | properties: 29 | cronSpec: 30 | type: string 31 | image: 32 | type: string 33 | replicas: 34 | type: integer 35 | # either Namespaced or Cluster 36 | scope: Namespaced 37 | names: 38 | # plural name to be used in the URL: /apis/// 39 | plural: crontabs 40 | # singular name to be used as an alias on the CLI and for display 41 | singular: crontab 42 | # kind is normally the CamelCased singular type. Your resource manifests use this. 43 | kind: CronTab 44 | # shortNames allow shorter string to match your resource on the CLI 45 | shortNames: 46 | - ct 47 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/templates/crd/crontab.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # See: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ 3 | # 4 | --- 5 | apiVersion: apiextensions.k8s.io/v1 6 | kind: CustomResourceDefinition 7 | metadata: 8 | # name must match the spec fields below, and be in the form: . 9 | name: crontabs.stable.example.com 10 | labels: 11 | heritage: example-206 12 | spec: 13 | # group name to use for REST API: /apis// 14 | group: stable.example.com 15 | # list of versions supported by this CustomResourceDefinition 16 | versions: 17 | - name: v1 18 | # Each version can be enabled/disabled by Served flag. 19 | served: true 20 | # One and only one version must be marked as the storage version. 21 | storage: true 22 | schema: 23 | openAPIV3Schema: 24 | type: object 25 | properties: 26 | spec: 27 | type: object 28 | properties: 29 | cronSpec: 30 | type: string 31 | image: 32 | type: string 33 | replicas: 34 | type: integer 35 | # either Namespaced or Cluster 36 | scope: Namespaced 37 | names: 38 | # plural name to be used in the URL: /apis/// 39 | plural: crontabs 40 | # singular name to be used as an alias on the CLI and for display 41 | singular: crontab 42 | # kind is normally the CamelCased singular type. Your resource manifests use this. 43 | kind: CronTab 44 | # shortNames allow shorter string to match your resource on the CLI 45 | shortNames: 46 | - ct 47 | -------------------------------------------------------------------------------- /pkg/hook/config/util.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | uuid "github.com/gofrs/uuid/v5" 7 | ) 8 | 9 | func ConvertFloatForBinding(value interface{}, bindingName string) (*float64, error) { 10 | if value == nil { 11 | return nil, nil 12 | } 13 | if floatValue, ok := value.(float64); ok { 14 | return &floatValue, nil 15 | } 16 | return nil, fmt.Errorf("binding %s has unsupported value '%v'", bindingName, value) 17 | } 18 | 19 | // MergeArrays returns merged array with unique elements. Preserve elements order. 20 | func MergeArrays(a1 []string, a2 []string) []string { 21 | union := make(map[string]bool) 22 | for _, a := range a2 { 23 | union[a] = true 24 | } 25 | res := make([]string, 0) 26 | for _, a := range a1 { 27 | res = append(res, a) 28 | union[a] = false 29 | } 30 | for _, a := range a2 { 31 | if union[a] { 32 | res = append(res, a) 33 | union[a] = false 34 | } 35 | } 36 | return res 37 | } 38 | 39 | func MonitorDebugName(configName string, configIndex int) string { 40 | if configName == "" { 41 | return fmt.Sprintf("kubernetes[%d]", configIndex) 42 | } 43 | return fmt.Sprintf("kubernetes[%d]{%s}", configIndex, configName) 44 | } 45 | 46 | // TODO uuid is not a good choice here. Make it more readable. 47 | func MonitorConfigID() string { 48 | return uuid.Must(uuid.NewV4()).String() 49 | // ei.DebugName = uuid.NewV4().String() 50 | // if ei.Monitor.ConfigIdPrefix != "" { 51 | // ei.DebugName = ei.Monitor.ConfigIdPrefix + "-" + ei.DebugName[len(ei.Monitor.ConfigIdPrefix)+1:] 52 | //} 53 | // return ei.DebugName 54 | } 55 | 56 | // TODO uuid is not a good choice here. Make it more readable. 57 | func ScheduleID() string { 58 | return uuid.Must(uuid.NewV4()).String() 59 | } 60 | -------------------------------------------------------------------------------- /pkg/shell-operator/metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Flant JSC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package shell_operator 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | 21 | "github.com/flant/shell-operator/pkg/metrics" 22 | ) 23 | 24 | // setupMetricStorage creates and initializes metrics storage for built-in operator metrics. 25 | // If MetricStorage is already set via options, it uses that; otherwise creates a new one. 26 | func (op *ShellOperator) setupMetricStorage(kubeEventsManagerLabels []string) error { 27 | err := metrics.RegisterOperatorMetrics(op.MetricStorage, kubeEventsManagerLabels) 28 | if err != nil { 29 | return fmt.Errorf("register operator metrics: %w", err) 30 | } 31 | 32 | op.APIServer.RegisterRoute(http.MethodGet, "/metrics", op.MetricStorage.Handler().ServeHTTP) 33 | 34 | return nil 35 | } 36 | 37 | // setupHookMetricStorage creates and initializes metrics storage for hook metrics. 38 | // If HookMetricStorage is already set via options, it uses that; otherwise creates a new one. 39 | func (op *ShellOperator) setupHookMetricStorage() { 40 | op.APIServer.RegisterRoute(http.MethodGet, "/metrics/hooks", op.HookMetricStorage.Handler().ServeHTTP) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/utils/checksum/checksum.go: -------------------------------------------------------------------------------- 1 | package checksum 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | ) 10 | 11 | func CalculateChecksum(stringArr ...string) string { 12 | hasher := md5.New() 13 | sort.Strings(stringArr) 14 | for _, value := range stringArr { 15 | _, _ = hasher.Write([]byte(value)) 16 | } 17 | return hex.EncodeToString(hasher.Sum(nil)) 18 | } 19 | 20 | func CalculateChecksumOfFile(path string) (string, error) { 21 | content, err := os.ReadFile(path) 22 | if err != nil { 23 | return "", err 24 | } 25 | return CalculateChecksum(string(content)), nil 26 | } 27 | 28 | func CalculateChecksumOfDirectory(path string) (string, error) { 29 | res := "" 30 | 31 | err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 32 | if err != nil { 33 | return err 34 | } 35 | if !info.Mode().IsRegular() { 36 | return nil 37 | } 38 | 39 | checksum, err := CalculateChecksumOfFile(path) 40 | if err != nil { 41 | return err 42 | } 43 | res = CalculateChecksum(res, checksum) 44 | 45 | return nil 46 | }) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | return res, nil 52 | } 53 | 54 | func CalculateChecksumOfPaths(pathArr ...string) (string, error) { 55 | res := "" 56 | 57 | for _, path := range pathArr { 58 | fileInfo, err := os.Stat(path) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | var checksum string 64 | if fileInfo.IsDir() { 65 | checksum, err = CalculateChecksumOfDirectory(path) 66 | } else { 67 | checksum, err = CalculateChecksumOfFile(path) 68 | } 69 | 70 | if err != nil { 71 | return "", err 72 | } 73 | res = CalculateChecksum(res, checksum) 74 | } 75 | 76 | return res, nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/webhook/admission/manager_test.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | v1 "k8s.io/api/admissionregistration/v1" 8 | ) 9 | 10 | func Test_Manager_AddWebhook(t *testing.T) { 11 | m := NewWebhookManager(nil) 12 | m.Namespace = "default" 13 | vs := &WebhookSettings{} 14 | vs.ConfigurationName = "webhook-configuration" 15 | vs.ServiceName = "webhook-service" 16 | vs.ServerKeyPath = "testdata/demo-certs/server-key.pem" 17 | vs.ServerCertPath = "testdata/demo-certs/server.crt" 18 | vs.CAPath = "testdata/demo-certs/ca.pem" 19 | m.Settings = vs 20 | 21 | err := m.Init() 22 | if err != nil { 23 | t.Fatalf("WebhookManager should init: %v", err) 24 | } 25 | 26 | fail := v1.Fail 27 | none := v1.SideEffectClassNone 28 | timeoutSeconds := int32(10) 29 | 30 | cfg := &ValidatingWebhookConfig{ 31 | ValidatingWebhook: &v1.ValidatingWebhook{ 32 | Name: "test-validating", 33 | Rules: []v1.RuleWithOperations{ 34 | { 35 | Operations: []v1.OperationType{v1.OperationAll}, 36 | Rule: v1.Rule{ 37 | APIGroups: []string{"apps"}, 38 | APIVersions: []string{"v1"}, 39 | Resources: []string{"deployments"}, 40 | }, 41 | }, 42 | }, 43 | FailurePolicy: &fail, 44 | SideEffects: &none, 45 | TimeoutSeconds: &timeoutSeconds, 46 | }, 47 | } 48 | m.AddValidatingWebhook(cfg) 49 | 50 | if len(m.ValidatingResources) != 1 { 51 | t.Fatalf("WebhookManager should have resources: got length %d", len(m.ValidatingResources)) 52 | } 53 | 54 | for k, v := range m.ValidatingResources { 55 | if len(v.hooks) != 1 { 56 | t.Fatalf("Resource '%s' should have Webhooks: got length %d", k, len(m.ValidatingResources)) 57 | } 58 | assert.Equal(t, v1.Fail, *v.hooks[""].FailurePolicy) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | # every push to a branch: 2 | # - run linter 3 | name: Lint 4 | on: 5 | pull_request: 6 | types: [opened, synchronize] 7 | 8 | jobs: 9 | run_linter: 10 | name: Run linter 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Go 1.23 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: "1.23" 17 | 18 | - name: Check out shell-operator code 19 | uses: actions/checkout@v4 20 | 21 | - name: Restore Go modules 22 | id: go-modules-cache 23 | uses: actions/cache@v4.2.3 24 | with: 25 | path: | 26 | ~/go/pkg/mod 27 | key: ${{ runner.os }}-gomod-${{ hashFiles('go.mod', 'go.sum') }} 28 | restore-keys: | 29 | ${{ runner.os }}-gomod- 30 | 31 | - name: Download Go modules 32 | if: steps.go-modules-cache.outputs.cache-hit != 'true' 33 | run: | 34 | go mod download 35 | echo -n "Go modules unpacked size is: " && du -sh $HOME/go/pkg/mod 36 | 37 | - name: Run golangci-lint 38 | run: | 39 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b . v2.1.6 40 | ./golangci-lint run --build-tags integration,test 41 | 42 | 43 | codespell: 44 | name: Run codespell 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/setup-python@v5 48 | with: 49 | python-version: 3.8 50 | 51 | - name: Check out addon-operator code 52 | uses: actions/checkout@v4 53 | 54 | - name: Run codespell 55 | run: | 56 | pip install codespell==v1.17.1 57 | codespell --skip=".git,go.mod,go.sum,*.log,*.gif,*.png,*.md" -L witht,eventtypes,uint,uptodate,afterall,keypair 58 | -------------------------------------------------------------------------------- /pkg/filter/jq/apply.go: -------------------------------------------------------------------------------- 1 | package jq 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | 7 | "github.com/itchyny/gojq" 8 | 9 | "github.com/flant/shell-operator/pkg/filter" 10 | ) 11 | 12 | var _ filter.Filter = (*Filter)(nil) 13 | 14 | func NewFilter() *Filter { 15 | return &Filter{} 16 | } 17 | 18 | type Filter struct{} 19 | 20 | // ApplyFilter runs jq expression provided in jqFilter with jsonData as input. 21 | func (f *Filter) ApplyFilter(jqFilter string, data map[string]any) ([]byte, error) { 22 | query, err := gojq.Parse(jqFilter) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | var workData any 28 | if data == nil { 29 | workData = nil 30 | } else { 31 | workData, err = deepCopyAny(data) 32 | if err != nil { 33 | return nil, err 34 | } 35 | } 36 | 37 | iter := query.Run(workData) 38 | result := make([]any, 0) 39 | for { 40 | v, ok := iter.Next() 41 | if !ok { 42 | break 43 | } 44 | if err, ok := v.(error); ok { 45 | var errGoJq *gojq.HaltError 46 | if errors.As(err, &errGoJq) && errGoJq.Value() == nil { 47 | break 48 | } 49 | return nil, err 50 | } 51 | result = append(result, v) 52 | } 53 | 54 | switch len(result) { 55 | case 0: 56 | return []byte("null"), nil 57 | case 1: 58 | return json.Marshal(result[0]) 59 | default: 60 | return json.Marshal(result) 61 | } 62 | } 63 | 64 | func (f *Filter) FilterInfo() string { 65 | return "jqFilter implementation: using itchyny/gojq" 66 | } 67 | 68 | func deepCopyAny(input any) (any, error) { 69 | if input == nil { 70 | return nil, nil 71 | } 72 | data, err := json.Marshal(input) 73 | if err != nil { 74 | return nil, err 75 | } 76 | var output any 77 | if err := json.Unmarshal(data, &output); err != nil { 78 | return nil, err 79 | } 80 | return output, nil 81 | } 82 | -------------------------------------------------------------------------------- /examples/201-install-with-helm-chart/README.md: -------------------------------------------------------------------------------- 1 | ## example with helm chart 2 | 3 | A helm version of [102-monitor-namespaces](https://github.com/flant/shell-operator/tree/master/examples/102-monitor-namespaces) example. It uses `ghcr.io/flant/shell-operator:latest` image in chart template to run shell-operator and a ConfigMap as a storage for hooks. 4 | 5 | 6 | ### Run 7 | 8 | Tiller should be configured with ServiceAccount to be able to install releases in different namespaces: 9 | 10 | ``` 11 | kubectl create serviceaccount tiller --namespace kube-system 12 | 13 | kubectl create clusterrolebinding tiller --clusterrole=cluster-admin --serviceaccount=kube-system:tiller 14 | 15 | helm init --service-account tiller 16 | ``` 17 | 18 | Install example to ns/example-helm: 19 | 20 | ``` 21 | helm install . --namespace example-helm --name example-helm 22 | ``` 23 | 24 | ### See hook in action 25 | 26 | 1. Create ns/foobar to trigger a hook: 27 | 28 | ``` 29 | kubectl create ns foobar 30 | ``` 31 | 32 | See in logs that hook was run: 33 | 34 | ``` 35 | kubectl -n example-helm logs deploy/shell-operator 36 | 37 | ... 38 | Namespace foobar was created 39 | ... 40 | ``` 41 | 42 | 2. Delete ns/foobar to trigger a hook: 43 | 44 | ``` 45 | kubectl create ns foobar 46 | ``` 47 | 48 | See in logs that hook was run: 49 | 50 | ``` 51 | kubectl -n example-helm logs deploy/shell-operator 52 | 53 | ... 54 | Namespace foobar was deleted 55 | ... 56 | ``` 57 | 58 | ### Make changes 59 | 60 | Deployment/shell-operator has annotation with checksum of hook file, so after editing namespace-hook.sh release can be upgraded without additional kubectl commands: 61 | 62 | ``` 63 | helm upgrade example-helm . --namespace example-helm 64 | ``` 65 | 66 | ### Cleanup 67 | 68 | ``` 69 | helm delete --purge example-helm 70 | kubectl delete ns/example-helm 71 | ``` 72 | -------------------------------------------------------------------------------- /examples/104-secret-copier/hooks/schedule_sync_secret: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Hook with a schedule binding: sync secrets with the 'secret-copier: yes' label from the 'default' namespace to the other namespaces. 4 | # 5 | 6 | source /hooks/common/functions.sh 7 | 8 | hook::config() { 9 | cat <>> Remove ${RM_FILES}" 27 | rm -f $RM_FILES 28 | 29 | echo ">>> Generate CA key and certificate" 30 | cat <>> Generate cert.key and cert.crt" 64 | cat <>> Remove ${RM_FILES}" 27 | rm -f $RM_FILES 28 | 29 | echo ">>> Generate CA key and certificate" 30 | cat <>> Generate cert.key and cert.crt" 63 | cat <>> Remove ${RM_FILES}" 27 | rm -f $RM_FILES 28 | 29 | echo ">>> Generate CA key and certificate" 30 | cat <>> Generate cert.key and cert.crt" 64 | cat <. 6 | name: crontabs.stable.example.com 7 | labels: 8 | heritage: example-220 9 | spec: 10 | # group name to use for REST API: /apis// 11 | group: stable.example.com 12 | # list of versions supported by this CustomResourceDefinition 13 | versions: 14 | - name: v1alpha1 15 | served: true 16 | storage: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | spec: 22 | type: object 23 | required: [cron, imageName] 24 | properties: 25 | cron: 26 | type: array 27 | items: 28 | type: string 29 | imageName: 30 | type: string 31 | - name: v1alpha2 32 | served: true 33 | storage: false 34 | schema: 35 | openAPIV3Schema: 36 | type: object 37 | properties: 38 | spec: 39 | type: object 40 | required: [cron, imageName] 41 | properties: 42 | cron: 43 | type: string 44 | imageName: 45 | type: string 46 | 47 | # either Namespaced or Cluster 48 | scope: Namespaced 49 | names: 50 | # plural name to be used in the URL: /apis/// 51 | plural: crontabs 52 | # singular name to be used as an alias on the CLI and for display 53 | singular: crontab 54 | # kind is normally the CamelCased singular type. Your resource manifests use this. 55 | kind: CronTab 56 | # shortNames allow shorter string to match your resource on the CLI 57 | shortNames: 58 | - ct 59 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/templates/crd/crontab.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | # name must match the spec fields below, and be in the form: . 6 | name: crontabs.stable.example.com 7 | labels: 8 | heritage: example-210 9 | spec: 10 | # group name to use for REST API: /apis// 11 | group: stable.example.com 12 | # list of versions supported by this CustomResourceDefinition 13 | versions: 14 | - name: v1alpha1 15 | served: true 16 | storage: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | spec: 22 | type: object 23 | required: [cron, imageName] 24 | properties: 25 | cron: 26 | type: array 27 | items: 28 | type: string 29 | imageName: 30 | type: string 31 | - name: v1alpha2 32 | served: true 33 | storage: false 34 | schema: 35 | openAPIV3Schema: 36 | type: object 37 | properties: 38 | spec: 39 | type: object 40 | required: [cron, imageName] 41 | properties: 42 | cron: 43 | type: string 44 | imageName: 45 | type: string 46 | 47 | # either Namespaced or Cluster 48 | scope: Namespaced 49 | names: 50 | # plural name to be used in the URL: /apis/// 51 | plural: crontabs 52 | # singular name to be used as an alias on the CLI and for display 53 | singular: crontab 54 | # kind is normally the CamelCased singular type. Your resource manifests use this. 55 | kind: CronTab 56 | # shortNames allow shorter string to match your resource on the CLI 57 | shortNames: 58 | - ct 59 | -------------------------------------------------------------------------------- /pkg/webhook/conversion/crd_client_config.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | 10 | klient "github.com/flant/kube-client/client" 11 | "github.com/flant/shell-operator/pkg" 12 | ) 13 | 14 | // A clientConfig for a particular CRD. 15 | type CrdClientConfig struct { 16 | KubeClient *klient.Client 17 | CrdName string 18 | Namespace string 19 | ServiceName string 20 | Path string 21 | CABundle []byte 22 | } 23 | 24 | var SupportedConversionReviewVersions = []string{"v1", "v1beta1"} 25 | 26 | func (c *CrdClientConfig) Update(ctx context.Context) error { 27 | var ( 28 | retryTimeout = 15 * time.Second 29 | retryBudget = 60 // 60 times * 15 sec = 15 min 30 | client = c.KubeClient 31 | ) 32 | 33 | tryToGetCRD: 34 | crd, err := client.ApiExt().CustomResourceDefinitions().Get(ctx, c.CrdName, metav1.GetOptions{}) 35 | if err != nil { 36 | if retryBudget > 0 { 37 | retryBudget-- 38 | time.Sleep(retryTimeout) 39 | goto tryToGetCRD 40 | } 41 | 42 | return err 43 | } 44 | 45 | if crd.Spec.Conversion == nil { 46 | crd.Spec.Conversion = new(extv1.CustomResourceConversion) 47 | } 48 | crd.Spec.Conversion.Strategy = extv1.WebhookConverter 49 | 50 | if crd.Spec.Conversion.Webhook == nil { 51 | crd.Spec.Conversion.Webhook = new(extv1.WebhookConversion) 52 | } 53 | crd.Spec.Conversion.Webhook.ClientConfig = &extv1.WebhookClientConfig{ 54 | URL: nil, 55 | Service: &extv1.ServiceReference{ 56 | Namespace: c.Namespace, 57 | Name: c.ServiceName, 58 | Path: &c.Path, 59 | }, 60 | CABundle: c.CABundle, 61 | } 62 | crd.Spec.Conversion.Webhook.ConversionReviewVersions = SupportedConversionReviewVersions 63 | 64 | _, err = client.ApiExt().CustomResourceDefinitions().Update(ctx, crd, pkg.DefaultUpdateOptions()) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/webhook/admission/settings.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import ( 4 | "github.com/flant/shell-operator/pkg/app" 5 | "github.com/flant/shell-operator/pkg/webhook/server" 6 | ) 7 | 8 | type WebhookSettings struct { 9 | server.Settings 10 | CAPath string 11 | CABundle []byte 12 | ConfigurationName string 13 | DefaultFailurePolicy string 14 | } 15 | 16 | // DefaultSettings returns default settings for validating webhook 17 | // This is initialized at startup and can be modified by flag parsing 18 | var DefaultSettings = &WebhookSettings{ 19 | Settings: server.Settings{ 20 | ServerCertPath: app.ValidatingServerCertPathDefault, 21 | ServerKeyPath: app.ValidatingServerKeyPathDefault, 22 | ClientCAPaths: nil, 23 | ServiceName: app.ValidatingServiceNameDefault, 24 | ListenAddr: app.ValidatingListenAddrDefault, 25 | ListenPort: app.ValidatingListenPortDefault, 26 | }, 27 | CAPath: app.ValidatingCAPathDefault, 28 | ConfigurationName: app.ValidatingConfigurationNameDefault, 29 | DefaultFailurePolicy: app.ValidatingFailurePolicyTypeDefault, 30 | } 31 | 32 | // InitFromFlags updates DefaultSettings with values from parsed flags 33 | func InitFromFlags(configName, serviceName, certPath, keyPath, caPath string, clientCAs []string, failurePolicy, port, addr string) { 34 | if configName != "" { 35 | DefaultSettings.ConfigurationName = configName 36 | } 37 | if serviceName != "" { 38 | DefaultSettings.ServiceName = serviceName 39 | } 40 | if certPath != "" { 41 | DefaultSettings.ServerCertPath = certPath 42 | } 43 | if keyPath != "" { 44 | DefaultSettings.ServerKeyPath = keyPath 45 | } 46 | if caPath != "" { 47 | DefaultSettings.CAPath = caPath 48 | } 49 | if len(clientCAs) > 0 { 50 | DefaultSettings.ClientCAPaths = clientCAs 51 | } 52 | if failurePolicy != "" { 53 | DefaultSettings.DefaultFailurePolicy = failurePolicy 54 | } 55 | if port != "" { 56 | DefaultSettings.ListenPort = port 57 | } 58 | if addr != "" { 59 | DefaultSettings.ListenAddr = addr 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/README.md: -------------------------------------------------------------------------------- 1 | # Example with mutating hook 2 | 3 | This is a simple example of `kubernetesMutating` binding. Read more information in [BINDING_MUTATING.md](../../BINDING_MUTATING.md). 4 | 5 | The example contains one hook that is used to mutate custom resource [CronTab](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) on create or update (see also [105-crd-example](../105-crd-example/README.md). The hook just updates a `spec.replicas` field to value 333. 6 | 7 | ## Run 8 | 9 | ### Generate certificates 10 | 11 | An HTTP server behind the MutatingWebhookConfiguration requires a certificate issued by the CA. For simplicity, this process is automated with `gen-certs.sh` script. Just run it: 12 | 13 | ``` 14 | ./gen-certs.sh 15 | ``` 16 | 17 | > Note: `gen-certs.sh` requires [cfssl utility](https://github.com/cloudflare/cfssl/releases/latest). 18 | 19 | ### Build and install example 20 | 21 | Build Docker image and use helm3 to install it: 22 | 23 | ``` 24 | docker build -t localhost:5000/shell-operator:example-206 . 25 | docker push localhost:5000/shell-operator:example-206 26 | helm upgrade --install \ 27 | --namespace example-206 \ 28 | --create-namespace \ 29 | example-206 . 30 | ``` 31 | 32 | ### See mutating hook in action 33 | 34 | Create a CronTab resource: 35 | 36 | ``` 37 | $ kubectl -n example-206 apply -f crontab-valid.yaml 38 | crontab.stable.example.com/crontab-valid created 39 | ``` 40 | 41 | Check CronTab manifest: 42 | 43 | ``` 44 | $ kubectl -n example-206 get crontab -o yaml 45 | ... 46 | apiVersion: stable.example.com/v1 47 | kind: CronTab 48 | metadata: 49 | name: crontab-valid 50 | ... 51 | spec: 52 | cronSpec: '* * * * */5' <--- It is from the YAML file. 53 | image: repo.example.com/my-awesome-cron-image:v1 <--- It is from the YAML file. 54 | replicas: 333 <--- This one is set by the hook. 55 | ... 56 | ``` 57 | 58 | ### Cleanup 59 | 60 | ``` 61 | helm delete --namespace=example-206 example-206 62 | kubectl delete ns example-206 63 | kubectl delete mutatingwebhookconfiguration/example-206 64 | ``` 65 | -------------------------------------------------------------------------------- /pkg/debug/client.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/flant/shell-operator/pkg/app" 13 | utils "github.com/flant/shell-operator/pkg/utils/file" 14 | ) 15 | 16 | type Client struct { 17 | SocketPath string 18 | httpClient *http.Client 19 | } 20 | 21 | func NewClient(socketPath string) (*Client, error) { 22 | exists, err := utils.FileExists(socketPath) 23 | if err != nil { 24 | return nil, fmt.Errorf("check debug socket '%s': %s", socketPath, err) 25 | } 26 | if !exists { 27 | return nil, fmt.Errorf("debug socket '%s' is not exists", socketPath) 28 | } 29 | 30 | client := &http.Client{ 31 | Transport: &http.Transport{ 32 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { 33 | dialer := &net.Dialer{ 34 | Timeout: 10 * time.Second, 35 | } 36 | return dialer.DialContext(ctx, "unix", socketPath) 37 | }, 38 | DisableKeepAlives: true, 39 | }, 40 | } 41 | 42 | return &Client{ 43 | SocketPath: socketPath, 44 | httpClient: client, 45 | }, nil 46 | } 47 | 48 | func (c *Client) Close() { 49 | if c.httpClient != nil { 50 | if transport, ok := c.httpClient.Transport.(*http.Transport); ok { 51 | transport.CloseIdleConnections() 52 | } 53 | } 54 | } 55 | 56 | func DefaultClient() (*Client, error) { 57 | return NewClient(app.DebugUnixSocket) 58 | } 59 | 60 | func (c *Client) Get(url string) ([]byte, error) { 61 | resp, err := c.httpClient.Get(url) 62 | if err != nil { 63 | return nil, err 64 | } 65 | defer resp.Body.Close() 66 | 67 | bodyBuf := new(bytes.Buffer) 68 | _, err = io.Copy(bodyBuf, resp.Body) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return bodyBuf.Bytes(), nil 73 | } 74 | 75 | func (c *Client) Post(targetUrl string, data map[string][]string) ([]byte, error) { 76 | resp, err := c.httpClient.PostForm(targetUrl, data) 77 | if err != nil { 78 | return nil, err 79 | } 80 | defer resp.Body.Close() 81 | 82 | bodyBuf := new(bytes.Buffer) 83 | _, err = io.Copy(bodyBuf, resp.Body) 84 | if err != nil { 85 | return nil, err 86 | } 87 | return bodyBuf.Bytes(), nil 88 | } 89 | -------------------------------------------------------------------------------- /test/utils/kubectl.go: -------------------------------------------------------------------------------- 1 | //go:build test 2 | // +build test 3 | 4 | // TODO: remove useless code 5 | 6 | package utils 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | "github.com/onsi/gomega/gexec" 16 | ) 17 | 18 | type KubectlCmd struct { 19 | ContextName string 20 | ConfigPath string 21 | } 22 | 23 | func Kubectl(context string) *KubectlCmd { 24 | return &KubectlCmd{ 25 | ContextName: context, 26 | } 27 | } 28 | 29 | func (k *KubectlCmd) Apply(ns string, fileName string) { 30 | args := []string{"apply"} 31 | if ns != "" { 32 | args = append(args, "-n") 33 | args = append(args, ns) 34 | } 35 | args = append(args, "-f") 36 | args = append(args, fileName) 37 | 38 | cmd := exec.Command(GetKubectlPath(), args...) 39 | 40 | k.Succeed(cmd) 41 | } 42 | 43 | func (k *KubectlCmd) ReplaceForce(ns string, fileName string) { 44 | args := []string{"replace"} 45 | if ns != "" { 46 | args = append(args, "-n") 47 | args = append(args, ns) 48 | } 49 | args = append(args, "--force") 50 | args = append(args, "-f") 51 | args = append(args, fileName) 52 | 53 | cmd := exec.Command(GetKubectlPath(), args...) 54 | 55 | k.Succeed(cmd) 56 | } 57 | 58 | func (k *KubectlCmd) Delete(ns string, resourceName string) { 59 | args := []string{"delete"} 60 | if ns != "" { 61 | args = append(args, "-n") 62 | args = append(args, ns) 63 | } 64 | args = append(args, resourceName) 65 | 66 | cmd := exec.Command(GetKubectlPath(), args...) 67 | 68 | k.Succeed(cmd) 69 | } 70 | 71 | func (k *KubectlCmd) Succeed(cmd *exec.Cmd) { 72 | if k.ConfigPath != "" { 73 | cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", k.ConfigPath)) 74 | } 75 | if k.ContextName != "" { 76 | cmd.Args = append(cmd.Args, "--context", k.ContextName) 77 | } 78 | session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) 79 | Ω(err).ShouldNot(HaveOccurred()) 80 | <-session.Exited 81 | Ω(session).Should(gexec.Exit()) 82 | Ω(session.ExitCode()).Should(Equal(0)) 83 | } 84 | 85 | func GetKubectlPath() string { 86 | path := os.Getenv("KUBECTL_BINARY_PATH") 87 | if path == "" { 88 | return "kubectl" 89 | } 90 | return path 91 | } 92 | -------------------------------------------------------------------------------- /pkg/webhook/admission/response_test.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import "testing" 4 | 5 | func Test_AdmissionResponseFromFile_Allowed(t *testing.T) { 6 | r, err := ResponseFromFile("testdata/response/good_allow.json") 7 | if err != nil { 8 | t.Fatalf("ValidatingResponse should be loaded from file: %v", err) 9 | } 10 | 11 | if r == nil { 12 | t.Fatalf("ValidatingResponse should not be nil") 13 | } 14 | 15 | if !r.Allowed { 16 | t.Fatalf("ValidatingResponse should have allowed=true: %#v", r) 17 | } 18 | } 19 | 20 | func Test_AdmissionResponseFromFile_AllowedWithWarnings(t *testing.T) { 21 | r, err := ResponseFromFile("testdata/response/good_allow_warnings.json") 22 | if err != nil { 23 | t.Fatalf("ValidatingResponse should be loaded from file: %v", err) 24 | } 25 | 26 | if r == nil { 27 | t.Fatalf("ValidatingResponse should not be nil") 28 | } 29 | 30 | if !r.Allowed { 31 | t.Fatalf("ValidatingResponse should have allowed=true: %#v", r) 32 | } 33 | 34 | if len(r.Warnings) != 2 { 35 | t.Fatalf("ValidatingResponse should have warnings: %#v", r) 36 | } 37 | } 38 | 39 | func Test_AdmissionResponseFromFile_NotAllowed_WithMessage(t *testing.T) { 40 | r, err := ResponseFromFile("testdata/response/good_deny.json") 41 | if err != nil { 42 | t.Fatalf("ValidatingResponse should be loaded from file: %v", err) 43 | } 44 | 45 | if r == nil { 46 | t.Fatalf("ValidatingResponse should not be nil") 47 | } 48 | 49 | if r.Allowed { 50 | t.Fatalf("ValidatingResponse should have allowed=false: %#v", r) 51 | } 52 | 53 | if r.Message == "" { 54 | t.Fatalf("ValidatingResponse should have message: %#v", r) 55 | } 56 | } 57 | 58 | func Test_AdmissionResponseFromFile_NotAllowed_WithoutMessage(t *testing.T) { 59 | r, err := ResponseFromFile("testdata/response/good_deny_quiet.json") 60 | if err != nil { 61 | t.Fatalf("ValidatingResponse should be loaded from file: %v", err) 62 | } 63 | 64 | if r == nil { 65 | t.Fatalf("ValidatingResponse should not be nil") 66 | } 67 | 68 | if r.Allowed { 69 | t.Fatalf("ValidatingResponse should have allowed=false: %#v", r) 70 | } 71 | 72 | if r.Message != "" { 73 | t.Fatalf("ValidatingResponse should have no message: %#v", r) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/hook/config/versioned_untyped.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "sigs.k8s.io/yaml" 7 | ) 8 | 9 | const VersionKey = "configVersion" 10 | 11 | type VersionedUntyped struct { 12 | Obj map[string]interface{} 13 | Version string 14 | 15 | VersionKey string 16 | VersionValidator func(string, bool) (string, error) 17 | } 18 | 19 | // NewDefaultVersionedUntyped is a VersionedUntyper object with default version key 20 | // and version validator against Schemas map 21 | func NewDefaultVersionedUntyped() *VersionedUntyped { 22 | return &VersionedUntyped{ 23 | VersionKey: VersionKey, 24 | VersionValidator: func(origVer string, found bool) (string, error) { 25 | newVer := origVer 26 | if !found { 27 | newVer = "v0" 28 | } 29 | 30 | _, hasSchema := Schemas[newVer] 31 | if hasSchema { 32 | return newVer, nil 33 | } 34 | 35 | return "", fmt.Errorf("'%s' value '%s' is unsupported", VersionKey, origVer) 36 | }, 37 | } 38 | } 39 | 40 | func (u *VersionedUntyped) Load(data []byte) error { 41 | err := yaml.Unmarshal(data, &u.Obj) 42 | if err != nil { 43 | return fmt.Errorf("config unmarshal: %v", err) 44 | } 45 | 46 | // detect version 47 | u.Version, err = u.LoadConfigVersion() 48 | if err != nil { 49 | return fmt.Errorf("config version: %v", err) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // LoadConfigVersion 56 | func (u *VersionedUntyped) LoadConfigVersion() (string, error) { 57 | value, found, err := u.GetString(u.VersionKey) 58 | if err != nil { 59 | return "", err 60 | } 61 | 62 | if u.VersionValidator != nil { 63 | newVer, err := u.VersionValidator(value, found) 64 | if err != nil { 65 | return value, err 66 | } 67 | return newVer, nil 68 | } 69 | 70 | return value, nil 71 | } 72 | 73 | // GetString returns string value by key 74 | func (u *VersionedUntyped) GetString(key string) (string, bool, error) { 75 | val, found := u.Obj[key] 76 | 77 | if !found { 78 | return "", false, nil 79 | } 80 | 81 | if val == nil { 82 | return "", true, fmt.Errorf("missing '%s' value", key) 83 | } 84 | 85 | value, ok := val.(string) 86 | if !ok { 87 | return "", true, fmt.Errorf("string value is expected for key '%s'", key) 88 | } 89 | 90 | return value, true, nil 91 | } 92 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | # every push to a branch: build binary 2 | name: Build 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | jobs: 7 | build_binary: 8 | name: Build shell-operator binary 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Set up Go 1.23 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version: "1.23" 15 | 16 | - name: Check out shell-operator code 17 | uses: actions/checkout@v4 18 | 19 | - name: Restore Go modules 20 | id: go-modules-cache 21 | uses: actions/cache@v4.2.3 22 | with: 23 | path: | 24 | ~/go/pkg/mod 25 | key: ${{ runner.os }}-gomod-${{ hashFiles('go.mod', 'go.sum') }} 26 | 27 | - name: Download Go modules 28 | if: steps.go-modules-cache.outputs.cache-hit != 'true' 29 | run: | 30 | go mod download 31 | echo -n "Go modules unpacked size is: " && du -sh $HOME/go/pkg/mod 32 | 33 | - name: Build binary 34 | run: | 35 | export GOOS=linux 36 | 37 | go build ./cmd/shell-operator 38 | 39 | # MacOS build works fine because jq package already has static libraries. 40 | # Windows build requires jq compilation, this should be done in libjq-go. 41 | # TODO Does cross-compile can help here? 42 | # 43 | # build_darwin_binary: 44 | # name: Darwin binary 45 | # runs-on: macos-10.15 46 | # steps: 47 | # - uses: actions/checkout@v4 48 | # 49 | # - name: install jq 50 | # run: | 51 | # brew install jq 52 | # 53 | # - name: build shell-operator binary 54 | # run: | 55 | # GO111MODULE=on \ 56 | # CGO_ENABLED=1 \ 57 | # go build ./cmd/shell-operator 58 | # 59 | # file ./shell-operator 60 | # 61 | # ./shell-operator version 62 | # 63 | # build_windows_binary: 64 | # name: Windows binary 65 | # runs-on: windows-2019 66 | # steps: 67 | # - uses: actions/checkout@v4 68 | # 69 | # - name: build shell-operator binary 70 | # run: | 71 | # GO111MODULE=on \ 72 | # CGO_ENABLED=1 \ 73 | # go build ./cmd/shell-operator 74 | # 75 | # ls -la . 76 | # 77 | # ./shell-operator.exe version 78 | # shell: bash 79 | -------------------------------------------------------------------------------- /test/utils/jqmatcher.go: -------------------------------------------------------------------------------- 1 | //go:build test 2 | // +build test 3 | 4 | // TODO: remove useless code 5 | 6 | package utils 7 | 8 | // import ( 9 | // "encoding/json" 10 | // "fmt" 11 | 12 | // "github.com/onsi/gomega/matchers" 13 | // "github.com/onsi/gomega/types" 14 | 15 | // . "github.com/flant/libjq-go" 16 | // ) 17 | 18 | // // var _ = Ω(verBcs).To(MatchJq(`.[0] | has("objects")`, Equal(true))) 19 | // func MatchJq(jqExpression string, matcher interface{}) types.GomegaMatcher { 20 | // return &matchJq{ 21 | // JqExpr: jqExpression, 22 | // Matcher: matcher, 23 | // } 24 | // } 25 | 26 | // type matchJq struct { 27 | // JqExpr string 28 | // InputString string 29 | // Matcher interface{} 30 | // ResMatcher types.GomegaMatcher 31 | // } 32 | 33 | // func (matcher *matchJq) Match(actual interface{}) (success bool, err error) { 34 | // switch v := actual.(type) { 35 | // case string: 36 | // matcher.InputString = v 37 | // case []byte: 38 | // matcher.InputString = string(v) 39 | // default: 40 | // inputBytes, err := json.Marshal(v) 41 | // if err != nil { 42 | // return false, fmt.Errorf("MatchJq marshal object to json: %s\n", err) 43 | // } 44 | // matcher.InputString = string(inputBytes) 45 | // } 46 | 47 | // //nolint:typecheck // Ignore false positive: undeclared name: `Jq`. 48 | // res, err := Jq().Program(matcher.JqExpr).Run(matcher.InputString) 49 | // if err != nil { 50 | // return false, fmt.Errorf("MatchJq apply jq expression: %s\n", err) 51 | // } 52 | 53 | // var isMatcher bool 54 | // matcher.ResMatcher, isMatcher = matcher.Matcher.(types.GomegaMatcher) 55 | // if !isMatcher { 56 | // matcher.ResMatcher = &matchers.EqualMatcher{Expected: res} 57 | // } 58 | 59 | // return matcher.ResMatcher.Match(res) 60 | // } 61 | 62 | // func (matcher *matchJq) FailureMessage(actual interface{}) (message string) { 63 | // return fmt.Sprintf("Jq expression `%s` applied to '%s' not match: %v", matcher.JqExpr, matcher.InputString, matcher.ResMatcher.FailureMessage(actual)) 64 | // } 65 | 66 | // func (matcher *matchJq) NegatedFailureMessage(actual interface{}) (message string) { 67 | // return fmt.Sprintf("Jq expression `%s` applied to '%s' should not match: %s", matcher.JqExpr, matcher.InputString, matcher.ResMatcher.NegatedFailureMessage(actual)) 68 | // } 69 | -------------------------------------------------------------------------------- /pkg/utils/exponential_backoff/delay.go: -------------------------------------------------------------------------------- 1 | package exponential_backoff 2 | 3 | import ( 4 | "math" 5 | "math/rand/v2" 6 | "time" 7 | ) 8 | 9 | const ( 10 | MaxExponentialBackoffDelay = 32 * time.Second 11 | ExponentialDelayFactor float64 = 2.0 // Each delta delay is twice bigger. 12 | ExponentialDelayRandomMs = 1000 // Each delay has random additional milliseconds. 13 | ) 14 | 15 | // ExponentialCalculationsCount counts of exponential calculations before return max delay to prevent overflow with big numbers. 16 | var ExponentialCalculationsCount = int(math.Log(MaxExponentialBackoffDelay.Seconds()) / math.Log(ExponentialDelayFactor)) 17 | 18 | // CalculateDelay returns delay distributed from initialDelay to default maxDelay (32s) 19 | // 20 | // Example: 21 | // 22 | // Retry 0: 5s 23 | // Retry 1: 6s 24 | // Retry 2: 7.8s 25 | // Retry 3: 9.8s 26 | // Retry 4: 13s 27 | // Retry 5: 21s 28 | // Retry 6: 32s 29 | // Retry 7: 32s 30 | func CalculateDelay(initialDelay time.Duration, retryCount int) time.Duration { 31 | return CalculateDelayWithMax(initialDelay, MaxExponentialBackoffDelay, retryCount) 32 | } 33 | 34 | // CalculateDelayWithMax returns delay distributed from initialDelay to maxDelay based on retryCount number. 35 | // 36 | // Delay for retry number 0 is an initialDelay. 37 | // 38 | // Calculation of exponential delays starts from retry number 1. 39 | // 40 | // After ExponentialCalculationsCount rounds of calculations, maxDelay is returned. 41 | func CalculateDelayWithMax(initialDelay time.Duration, maxDelay time.Duration, retryCount int) time.Duration { 42 | var delayNs int64 43 | switch { 44 | case retryCount == 0: 45 | return initialDelay 46 | case retryCount <= ExponentialCalculationsCount: 47 | // Calculate exponential delta for delay. 48 | delayNs = int64(float64(time.Second) * math.Pow(ExponentialDelayFactor, float64(retryCount-1))) 49 | default: 50 | // No calculation, return maxDelay. 51 | delayNs = maxDelay.Nanoseconds() 52 | } 53 | 54 | // Random addition to delay. 55 | rndDelayNs := rand.Int64N(ExponentialDelayRandomMs) * int64(time.Millisecond) 56 | 57 | delay := initialDelay + time.Duration(delayNs) + time.Duration(rndDelayNs) 58 | delay = delay.Truncate(100 * time.Millisecond) 59 | if delay.Nanoseconds() > maxDelay.Nanoseconds() { 60 | return maxDelay 61 | } 62 | return delay 63 | } 64 | -------------------------------------------------------------------------------- /pkg/kube_events_manager/filter.go: -------------------------------------------------------------------------------- 1 | package kubeeventsmanager 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "reflect" 8 | "runtime" 9 | "runtime/trace" 10 | 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | 13 | "github.com/flant/shell-operator/pkg/filter" 14 | kemtypes "github.com/flant/shell-operator/pkg/kube_events_manager/types" 15 | utils_checksum "github.com/flant/shell-operator/pkg/utils/checksum" 16 | ) 17 | 18 | // applyFilter filters object json representation with jq expression, calculate checksum 19 | // over result and return ObjectAndFilterResult. If jqFilter is empty, no filter 20 | // is required and checksum is calculated over full json representation of the object. 21 | func applyFilter(jqFilter string, fl filter.Filter, filterFn func(obj *unstructured.Unstructured) (result interface{}, err error), obj *unstructured.Unstructured) (*kemtypes.ObjectAndFilterResult, error) { 22 | defer trace.StartRegion(context.Background(), "ApplyJqFilter").End() 23 | 24 | res := &kemtypes.ObjectAndFilterResult{ 25 | Object: obj, 26 | } 27 | res.Metadata.JqFilter = jqFilter 28 | res.Metadata.ResourceId = resourceId(obj) 29 | 30 | // If filterFn is passed, run it and return result. 31 | if filterFn != nil { 32 | filteredObj, err := filterFn(obj) 33 | if err != nil { 34 | return nil, fmt.Errorf("filterFn (%s) contains an error: %v", runtime.FuncForPC(reflect.ValueOf(filterFn).Pointer()).Name(), err) 35 | } 36 | 37 | filteredBytes, err := json.Marshal(filteredObj) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | res.FilterResult = filteredObj 43 | res.Metadata.Checksum = utils_checksum.CalculateChecksum(string(filteredBytes)) 44 | 45 | return res, nil 46 | } 47 | 48 | // Render obj to JSON text to apply jq filter. 49 | if jqFilter == "" { 50 | data, err := json.Marshal(obj) 51 | if err != nil { 52 | return nil, err 53 | } 54 | res.Metadata.Checksum = utils_checksum.CalculateChecksum(string(data)) 55 | } else { 56 | var err error 57 | var filtered []byte 58 | filtered, err = fl.ApplyFilter(jqFilter, obj.UnstructuredContent()) 59 | if err != nil { 60 | return nil, fmt.Errorf("jqFilter: %v", err) 61 | } 62 | 63 | res.FilterResult = string(filtered) 64 | res.Metadata.Checksum = utils_checksum.CalculateChecksum(string(filtered)) 65 | } 66 | 67 | return res, nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/webhook/admission/config.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import ( 4 | v1 "k8s.io/api/admissionregistration/v1" 5 | 6 | "github.com/flant/shell-operator/pkg/utils/string_helper" 7 | ) 8 | 9 | // ConfigurationId is a first element in Path field for each Webhook. 10 | // It should be url safe. 11 | // 12 | // WebhookId is a second element for Path field. 13 | 14 | type Metadata struct { 15 | Name string 16 | WebhookId string 17 | ConfigurationId string // A suffix to create different ValidatingWebhookConfiguration/MutatingWebhookConfiguration resources. 18 | DebugName string 19 | LogLabels map[string]string 20 | MetricLabels map[string]string 21 | } 22 | 23 | type IWebhookConfig interface { 24 | GetMeta() Metadata 25 | SetMeta(Metadata) 26 | SetClientConfig(v1.WebhookClientConfig) 27 | UpdateIds(string, string) 28 | } 29 | 30 | type ValidatingWebhookConfig struct { 31 | *v1.ValidatingWebhook 32 | Metadata 33 | } 34 | 35 | func (c *ValidatingWebhookConfig) GetMeta() Metadata { return c.Metadata } 36 | func (c *ValidatingWebhookConfig) SetMeta(m Metadata) { c.Metadata = m } 37 | func (c *ValidatingWebhookConfig) SetClientConfig(cc v1.WebhookClientConfig) { 38 | equivalent := v1.Equivalent 39 | c.MatchPolicy = &equivalent 40 | c.ClientConfig = cc 41 | } 42 | 43 | type MutatingWebhookConfig struct { 44 | *v1.MutatingWebhook 45 | Metadata 46 | } 47 | 48 | func (c *MutatingWebhookConfig) GetMeta() Metadata { return c.Metadata } 49 | func (c *MutatingWebhookConfig) SetMeta(m Metadata) { c.Metadata = m } 50 | func (c *MutatingWebhookConfig) SetClientConfig(cc v1.WebhookClientConfig) { 51 | equivalent := v1.Equivalent 52 | c.MatchPolicy = &equivalent 53 | c.ClientConfig = cc 54 | } 55 | 56 | // UpdateIds use confId and webhookId to set a ConfigurationId prefix and a WebhookId. 57 | func (c *ValidatingWebhookConfig) UpdateIds(confID, webhookID string) { 58 | c.Metadata.ConfigurationId = confID 59 | if confID == "" { 60 | c.Metadata.ConfigurationId = DefaultConfigurationId 61 | } 62 | c.Metadata.WebhookId = string_helper.SafeURLString(webhookID) 63 | } 64 | 65 | func (c *MutatingWebhookConfig) UpdateIds(confID, webhookID string) { 66 | c.Metadata.ConfigurationId = confID 67 | if confID == "" { 68 | c.Metadata.ConfigurationId = DefaultConfigurationId 69 | } 70 | c.Metadata.WebhookId = string_helper.SafeURLString(webhookID) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/webhook/conversion/response.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "k8s.io/apimachinery/pkg/runtime" 13 | ) 14 | 15 | /* 16 | Response is a holder of the conversion hook response. 17 | 18 | Unlike ConverionsResponse, only one filed (FailedMessage) is used to determine success or fail: 19 | 20 | Response is Success if FailedMessage is empty: 21 | 22 | "result": { 23 | "status": "Success" 24 | }, 25 | 26 | Response is Failed: 27 | 28 | "result": { 29 | "status": "Failed", 30 | "message": FailedMessage 31 | } 32 | 33 | ConvertedObjects: 34 | # Objects must match the order of request.objects, and have apiVersion set to . 35 | # kind, metadata.uid, metadata.name, and metadata.namespace fields must not be changed by the webhook. 36 | # metadata.labels and metadata.annotations fields may be changed by the webhook. 37 | # All other changes to metadata fields by the webhook are ignored. 38 | */ 39 | type Response struct { 40 | FailedMessage string `json:"failedMessage"` 41 | ConvertedObjects []runtime.RawExtension `json:"convertedObjects,omitempty"` 42 | } 43 | 44 | func ResponseFromFile(filePath string) (*Response, error) { 45 | data, err := os.ReadFile(filePath) 46 | if err != nil { 47 | return nil, fmt.Errorf("cannot read %s: %s", filePath, err) 48 | } 49 | 50 | if len(data) == 0 { 51 | return nil, nil 52 | } 53 | return ResponseFromBytes(data) 54 | } 55 | 56 | func ResponseFromBytes(data []byte) (*Response, error) { 57 | return ResponseFromReader(bytes.NewReader(data)) 58 | } 59 | 60 | func ResponseFromReader(r io.Reader) (*Response, error) { 61 | response := new(Response) 62 | 63 | dec := json.NewDecoder(r) 64 | 65 | err := dec.Decode(response) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return response, nil 71 | } 72 | 73 | func (r *Response) Dump() string { 74 | b := new(strings.Builder) 75 | b.WriteString("conversion.Response(") 76 | if r.FailedMessage != "" { 77 | b.WriteString("failedMessage=") 78 | b.WriteString(r.FailedMessage) 79 | } 80 | if len(r.ConvertedObjects) > 0 { 81 | if r.FailedMessage != "" { 82 | b.WriteRune(',') 83 | } 84 | b.WriteString("convertedObjects.len=") 85 | b.WriteString(strconv.FormatInt(int64(len(r.ConvertedObjects)), 10)) 86 | } 87 | b.WriteString(")") 88 | return b.String() 89 | } 90 | -------------------------------------------------------------------------------- /pkg/metric/adapter.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "github.com/deckhouse/deckhouse/pkg/log" 5 | metricsstorage "github.com/deckhouse/deckhouse/pkg/metrics-storage" 6 | "github.com/prometheus/client_golang/prometheus" 7 | 8 | klient "github.com/flant/kube-client/client" 9 | utils "github.com/flant/shell-operator/pkg/utils/labels" 10 | ) 11 | 12 | // this adapter is used for kube-client 13 | // it's deprecated, but don't know how to use it without breaking changes 14 | // 15 | //nolint:staticcheck 16 | var _ klient.MetricStorage = (*MetricsAdapter)(nil) 17 | 18 | type MetricsAdapter struct { 19 | Storage metricsstorage.Storage 20 | Logger *log.Logger 21 | } 22 | 23 | func NewMetricsAdapter(storage metricsstorage.Storage, logger *log.Logger) *MetricsAdapter { 24 | return &MetricsAdapter{Storage: storage, Logger: logger} 25 | } 26 | 27 | // RegisterCounter registers a counter using the external storage 28 | func (a *MetricsAdapter) RegisterCounter(metric string, labels map[string]string) *prometheus.CounterVec { 29 | // Use external storage to register the counter 30 | labelNames := utils.LabelNames(labels) 31 | _, err := a.Storage.RegisterCounter(metric, labelNames) 32 | if err != nil { 33 | a.Logger.Warn("failed to register counter metric", log.Err(err)) 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // CounterAdd adds a value to a counter using the external storage 40 | func (a *MetricsAdapter) CounterAdd(metric string, value float64, labels map[string]string) { 41 | // Use external storage for the actual counter operation 42 | a.Storage.CounterAdd(metric, value, labels) 43 | } 44 | 45 | // RegisterHistogram registers a histogram using the external storage 46 | func (a *MetricsAdapter) RegisterHistogram(metric string, labels map[string]string, buckets []float64) *prometheus.HistogramVec { 47 | // Use external storage to register the histogram 48 | labelNames := utils.LabelNames(labels) 49 | _, err := a.Storage.RegisterHistogram(metric, labelNames, buckets) 50 | if err != nil { 51 | a.Logger.Warn("failed to register histogram metric", log.Err(err)) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // HistogramObserve observes a value in a histogram using the external storage 58 | func (a *MetricsAdapter) HistogramObserve(metric string, value float64, labels map[string]string, buckets []float64) { 59 | // Use external storage for the actual histogram operation 60 | a.Storage.HistogramObserve(metric, value, labels, buckets) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/hook/types/bindings.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | kubeeventsmanager "github.com/flant/shell-operator/pkg/kube_events_manager" 8 | smtypes "github.com/flant/shell-operator/pkg/schedule_manager/types" 9 | "github.com/flant/shell-operator/pkg/webhook/admission" 10 | "github.com/flant/shell-operator/pkg/webhook/conversion" 11 | ) 12 | 13 | var _ fmt.Stringer = (*BindingType)(nil) 14 | 15 | type BindingType string 16 | 17 | func (bt BindingType) String() string { 18 | return string(bt) 19 | } 20 | 21 | const ( 22 | Schedule BindingType = "schedule" 23 | OnStartup BindingType = "onStartup" 24 | OnKubernetesEvent BindingType = "kubernetes" 25 | KubernetesConversion BindingType = "kubernetesCustomResourceConversion" 26 | KubernetesValidating BindingType = "kubernetesValidating" 27 | KubernetesMutating BindingType = "kubernetesMutating" 28 | ) 29 | 30 | // Types for effective binding configs 31 | type CommonBindingConfig struct { 32 | BindingName string 33 | AllowFailure bool 34 | } 35 | 36 | type OnStartupConfig struct { 37 | CommonBindingConfig 38 | Order float64 39 | } 40 | 41 | type ScheduleConfig struct { 42 | CommonBindingConfig 43 | ScheduleEntry smtypes.ScheduleEntry 44 | IncludeSnapshotsFrom []string 45 | Queue string 46 | Group string 47 | } 48 | 49 | type OnKubernetesEventConfig struct { 50 | CommonBindingConfig 51 | Monitor *kubeeventsmanager.MonitorConfig 52 | IncludeSnapshotsFrom []string 53 | Queue string 54 | Group string 55 | ExecuteHookOnSynchronization bool 56 | WaitForSynchronization bool 57 | KeepFullObjectsInMemory bool 58 | } 59 | 60 | type ConversionConfig struct { 61 | CommonBindingConfig 62 | IncludeSnapshotsFrom []string 63 | Group string 64 | Webhook *conversion.WebhookConfig 65 | } 66 | 67 | type ValidatingConfig struct { 68 | CommonBindingConfig 69 | IncludeSnapshotsFrom []string 70 | Group string 71 | Webhook *admission.ValidatingWebhookConfig 72 | } 73 | 74 | type MutatingConfig struct { 75 | CommonBindingConfig 76 | IncludeSnapshotsFrom []string 77 | Group string 78 | Webhook *admission.MutatingWebhookConfig 79 | } 80 | 81 | type Settings struct { 82 | ExecutionMinInterval time.Duration 83 | ExecutionBurst int 84 | } 85 | -------------------------------------------------------------------------------- /pkg/webhook/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/deckhouse/deckhouse/pkg/log" 14 | "github.com/go-chi/chi/v5" 15 | ) 16 | 17 | type WebhookServer struct { 18 | Settings *Settings 19 | Namespace string 20 | Router chi.Router 21 | } 22 | 23 | // Start runs https server to listen for AdmissionReview requests from the API-server. 24 | func (s *WebhookServer) Start() error { 25 | // Load server certificate. 26 | keyPair, err := tls.LoadX509KeyPair( 27 | s.Settings.ServerCertPath, 28 | s.Settings.ServerKeyPath, 29 | ) 30 | if err != nil { 31 | return fmt.Errorf("load TLS certs: %v", err) 32 | } 33 | 34 | // Construct a hostname for certificate. 35 | host := fmt.Sprintf("%s.%s", 36 | s.Settings.ServiceName, 37 | s.Namespace, 38 | ) 39 | 40 | tlsConf := &tls.Config{ 41 | Certificates: []tls.Certificate{keyPair}, 42 | ServerName: host, 43 | } 44 | 45 | // Load client CA if defined 46 | if len(s.Settings.ClientCAPaths) > 0 { 47 | roots := x509.NewCertPool() 48 | 49 | for _, caPath := range s.Settings.ClientCAPaths { 50 | caBytes, err := os.ReadFile(caPath) 51 | if err != nil { 52 | return fmt.Errorf("load client CA '%s': %v", caPath, err) 53 | } 54 | 55 | ok := roots.AppendCertsFromPEM(caBytes) 56 | if !ok { 57 | return fmt.Errorf("parse client CA '%s': %v", caPath, err) 58 | } 59 | } 60 | 61 | tlsConf.ClientAuth = tls.RequireAndVerifyClientCert 62 | tlsConf.ClientCAs = roots 63 | } 64 | 65 | listenAddr := net.JoinHostPort(s.Settings.ListenAddr, s.Settings.ListenPort) 66 | // Check if port is available 67 | listener, err := net.Listen("tcp", listenAddr) 68 | if err != nil { 69 | return fmt.Errorf("try listen on '%s': %v", listenAddr, err) 70 | } 71 | 72 | timeout := time.Duration(10) * time.Second 73 | 74 | srv := &http.Server{ 75 | Handler: s.Router, 76 | TLSConfig: tlsConf, 77 | Addr: listenAddr, 78 | IdleTimeout: timeout, 79 | ReadTimeout: timeout, 80 | ReadHeaderTimeout: timeout, 81 | } 82 | 83 | go func() { 84 | log.Info("Webhook server listens", slog.String("address", listenAddr)) 85 | err := srv.ServeTLS(listener, "", "") 86 | if err != nil { 87 | log.Error("Error starting Webhook https server", log.Err(err)) 88 | // Stop process if server can't start. 89 | os.Exit(1) 90 | } 91 | }() 92 | 93 | return nil 94 | } 95 | --------------------------------------------------------------------------------