├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── feature.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yaml ├── release.yml └── workflows │ ├── build.yaml │ ├── checks.yaml │ ├── docs.yaml │ ├── lint.yaml │ ├── publish-dev.yaml │ ├── publish-release.yaml │ ├── tests-labeled.yaml │ └── tests.yaml ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── shell-operator │ ├── main.go │ └── start.go ├── docs ├── book.toml └── src │ ├── BINDING_CONVERSION.md │ ├── BINDING_VALIDATING.md │ ├── HOOKS.md │ ├── KUBERNETES.md │ ├── QUICK_START.md │ ├── README.md │ ├── RUNNING.md │ ├── SUMMARY.md │ ├── image │ ├── logo-shell.png │ ├── shell-operator-logo.png │ └── shell-operator-small-logo.png │ └── metrics │ ├── METRICS_FROM_HOOKS.md │ ├── ROOT.md │ └── SELF_METRICS.md ├── examples ├── 001-startup-shell │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ └── shell-hook.sh │ └── shell-operator-pod.yaml ├── 002-startup-python │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ └── 00-hook.py │ └── shell-operator-pod.yaml ├── 003-common-library │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ ├── common │ │ │ └── functions.sh │ │ └── hook.sh │ └── shell-operator-pod.yaml ├── 101-monitor-pods │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ └── pods-hook.sh │ ├── shell-operator-pod.yaml │ └── shell-operator-rbac.yaml ├── 102-monitor-namespaces │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ └── namespace-hook.sh │ ├── shell-operator-pod.yaml │ └── shell-operator-rbac.yaml ├── 103-schedule │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ ├── crontab-5-fields.sh │ │ └── crontab-6-fields.sh │ └── shell-operator-pod.yaml ├── 104-secret-copier │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ ├── add_or_update_secret │ │ ├── common │ │ │ └── functions.sh │ │ ├── create_namespace │ │ ├── delete_secret │ │ └── schedule_sync_secret │ ├── shell-operator-pod.yaml │ └── shell-operator-rbac.yaml ├── 105-crd-simple │ ├── Dockerfile │ ├── README.md │ ├── cr-crontab.yaml │ ├── crd-simple.yaml │ ├── hooks │ │ └── crd-hook.sh │ ├── shell-operator-pod.yaml │ └── shell-operator-rbac.yaml ├── 106-monitor-events │ ├── Dockerfile │ ├── README.md │ ├── failed-pod.yaml │ ├── hooks │ │ └── events-hook.sh │ ├── shell-operator-pod.yaml │ └── shell-operator-rbac.yaml ├── 200-advanced │ ├── Dockerfile │ ├── README.md │ ├── hooks │ │ ├── 001-onstartup-10 │ │ │ └── shell-hook.sh │ │ ├── 003-schedule │ │ │ └── schedule-hook.sh │ │ ├── 007-onstartup-2 │ │ │ └── shell-hook.sh │ │ ├── common │ │ │ └── functions.sh │ │ └── namespace-hook.sh │ ├── shell-operator-deploy.yaml │ └── shell-operator-rbac.yaml ├── 201-install-with-helm-chart │ ├── Chart.yaml │ ├── README.md │ ├── hooks │ │ └── namespace-hook.sh │ └── templates │ │ ├── hook-configmap.yaml │ │ ├── shell-operator-deployment.yaml │ │ └── shell-operator-rbac.yaml ├── 202-repack-build │ ├── Dockerfile │ ├── README.md │ └── hooks │ │ └── shell-hook.sh ├── 204-validating-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── README.md │ ├── crontab-non-valid.yaml │ ├── crontab-valid.yaml │ ├── gen-certs.sh │ ├── hooks │ │ └── validating.sh │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── crd │ │ │ └── crontab.yaml │ │ ├── deployment.yaml │ │ ├── rbac.yaml │ │ └── webhook-service.yaml │ └── values.yaml ├── 206-mutating-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── README.md │ ├── crontab-valid.yaml │ ├── gen-certs.sh │ ├── hooks │ │ └── mutating.sh │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── crd │ │ │ └── crontab.yaml │ │ ├── deployment.yaml │ │ ├── rbac.yaml │ │ └── webhook-service.yaml │ └── values.yaml ├── 210-conversion-webhook │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── README.md │ ├── crontab-v1alpha1.yaml │ ├── gen-certs.sh │ ├── hooks │ │ └── conversion-alpha.sh │ ├── templates │ │ ├── certs-secret.yaml │ │ ├── crd │ │ │ └── crontab.yaml │ │ ├── deployment.yaml │ │ ├── rbac.yaml │ │ └── webhook-service.yaml │ └── values.yaml ├── 220-execution-rate │ ├── .dockerignore │ ├── .helmignore │ ├── Chart.yaml │ ├── Dockerfile │ ├── README.md │ ├── crontab-recreate.sh │ ├── hooks │ │ └── settings-rate-limit.sh │ ├── templates │ │ ├── crd │ │ │ └── crontab.yaml │ │ ├── deployment.yaml │ │ └── rbac.yaml │ └── values.yaml └── 230-configmap-python │ ├── Dockerfile │ ├── README.md │ ├── hooks │ └── 00-hook.py │ └── shell-operator-pod.yaml ├── frameworks └── shell │ ├── context.sh │ └── hook.sh ├── go.mod ├── go.sum ├── pkg ├── app │ ├── app.go │ ├── debug.go │ ├── kube-client.go │ ├── log.go │ └── webhook.go ├── config │ ├── config.go │ └── config_test.go ├── debug │ ├── client.go │ ├── debug-cmd.go │ └── server.go ├── executor │ ├── executor.go │ └── executor_test.go ├── filter │ ├── filter.go │ └── jq │ │ ├── apply.go │ │ └── apply_test.go ├── hook │ ├── binding_context │ │ ├── binding_context.go │ │ └── binding_context_test.go │ ├── config │ │ ├── config.go │ │ ├── config_test.go │ │ ├── config_v0.go │ │ ├── config_v1.go │ │ ├── schemas.go │ │ ├── schemas_test.go │ │ ├── util.go │ │ ├── validator.go │ │ ├── validator_test.go │ │ ├── versioned_untyped.go │ │ └── versioned_untyped_test.go │ ├── controller │ │ ├── admission_bindings_controller.go │ │ ├── conversion_bindings_controller.go │ │ ├── hook_controller.go │ │ ├── hook_controller_test.go │ │ ├── kubernetes_bindings_controller.go │ │ └── schedule_bindings_controller.go │ ├── hook.go │ ├── hook_manager.go │ ├── hook_manager_test.go │ ├── hook_test.go │ ├── task_metadata │ │ ├── task_metadata.go │ │ └── task_metadata_test.go │ ├── testdata │ │ ├── hook_manager │ │ │ ├── configMapHooks │ │ │ │ └── hook.sh │ │ │ ├── hook.sh │ │ │ └── podHooks │ │ │ │ ├── hook.sh │ │ │ │ └── hook2.sh │ │ ├── hook_manager_conversion_chains │ │ │ ├── hook.sh │ │ │ └── hook2.sh │ │ ├── hook_manager_conversion_chains_full │ │ │ ├── hook.sh │ │ │ └── hook2.sh │ │ ├── hook_manager_onstartup_order │ │ │ ├── hook01_startup_20.sh │ │ │ ├── hook02_startup_10.sh │ │ │ ├── hook03_startup_15.sh │ │ │ └── hook04_startup_1.sh │ │ └── hook_manager_validating │ │ │ └── hook.sh │ └── types │ │ └── bindings.go ├── kube │ └── object_patch │ │ ├── helpers.go │ │ ├── operation.go │ │ ├── options.go │ │ ├── patch.go │ │ ├── patch_collector.go │ │ ├── patch_test.go │ │ ├── testdata │ │ └── serialized_operations │ │ │ ├── invalid_create.yaml │ │ │ ├── invalid_delete.yaml │ │ │ ├── invalid_patch.yaml │ │ │ ├── valid_create.yaml │ │ │ ├── valid_delete.yaml │ │ │ └── valid_patch.yaml │ │ ├── validation.go │ │ └── validation_test.go ├── kube_events_manager │ ├── error_handler.go │ ├── factory.go │ ├── filter.go │ ├── filter_test.go │ ├── kube_events_manager.go │ ├── kube_events_manager_test.go │ ├── monitor.go │ ├── monitor_config.go │ ├── monitor_test.go │ ├── namespace_informer.go │ ├── resource_informer.go │ ├── types │ │ ├── types.go │ │ └── types_test.go │ ├── util.go │ └── util_test.go ├── metric │ ├── collector.go │ ├── collector_test.go │ ├── grouped_storage_mock.go │ ├── storage.go │ ├── storage_mock.go │ └── storage_test.go ├── metric_storage │ ├── metric_storage.go │ ├── operation │ │ ├── operation.go │ │ └── operation_test.go │ └── vault │ │ ├── vault.go │ │ └── vault_test.go ├── schedule_manager │ ├── schedule_manager.go │ ├── schedule_manager_test.go │ └── types │ │ └── types.go ├── shell-operator │ ├── bootstrap.go │ ├── combine_binding_context.go │ ├── combine_binding_context_test.go │ ├── debug_server.go │ ├── http_server.go │ ├── kube_client.go │ ├── manager_events_handler.go │ ├── metrics_hooks.go │ ├── metrics_operator.go │ ├── operator.go │ ├── operator_test.go │ └── testdata │ │ └── startup_tasks │ │ └── hooks │ │ ├── hook01_startup_20_kube.sh │ │ ├── hook02_startup_1_schedule.sh │ │ └── hook03_startup_10_kube_schedule.sh ├── task │ ├── dump │ │ ├── dump.go │ │ └── dump_test.go │ ├── queue │ │ ├── queue_set.go │ │ ├── task_queue.go │ │ └── task_queue_test.go │ └── task.go ├── utils │ ├── checksum │ │ ├── checksum.go │ │ └── checksum_test.go │ ├── exponential_backoff │ │ ├── delay.go │ │ └── delay_test.go │ ├── file │ │ ├── dir.go │ │ ├── file.go │ │ └── file_test.go │ ├── labels │ │ └── labels.go │ ├── measure │ │ └── measure.go │ ├── signal │ │ └── signal.go │ ├── string_helper │ │ ├── apigroup.go │ │ ├── apigroup_test.go │ │ ├── safe_url.go │ │ └── safe_url_test.go │ └── structured-logger │ │ └── structured_logger.go └── webhook │ ├── admission │ ├── config.go │ ├── event.go │ ├── handler.go │ ├── handler_test.go │ ├── manager.go │ ├── manager_test.go │ ├── resource.go │ ├── response.go │ ├── response_test.go │ ├── settings.go │ └── testdata │ │ ├── demo-certs │ │ ├── ca.pem │ │ ├── client-ca.pem │ │ ├── server-key.pem │ │ └── server.crt │ │ └── response │ │ ├── good_allow.json │ │ ├── good_allow_warnings.json │ │ ├── good_deny.json │ │ └── good_deny_quiet.json │ ├── conversion │ ├── chain.go │ ├── chain_test.go │ ├── config.go │ ├── crd_client_config.go │ ├── handler.go │ ├── manager.go │ ├── response.go │ └── settings.go │ ├── server │ ├── server.go │ ├── server_test.go │ ├── settings.go │ └── testdata │ │ └── demo-certs │ │ ├── ca.pem │ │ ├── client-ca.pem │ │ ├── server-key.pem │ │ └── server.crt │ └── validating │ └── validation │ ├── rule.go │ ├── validation.go │ └── validation_test.go ├── scripts └── ci │ ├── codeclimate_upload.sh │ └── extract-file.sh ├── shell_lib.sh ├── test ├── hook │ └── context │ │ ├── README.md │ │ ├── context_combiner.go │ │ ├── generator.go │ │ ├── generator_test.go │ │ └── state.go ├── integration │ ├── kube_event_manager │ │ ├── kube_event_manager_test.go │ │ └── testdata │ │ │ └── test-pod.yaml │ ├── kubeclient │ │ ├── kube_client_test.go │ │ └── object_patch_test.go │ ├── run.sh │ └── suite │ │ └── run.go └── utils │ ├── assert.go │ ├── assert_test.go │ ├── directory.go │ ├── jqmatcher.go │ ├── jsonloganalyzer.go │ ├── jsonlogrecord.go │ ├── jsonlogrecord_test.go │ ├── kubectl.go │ ├── prometheus.go │ ├── shell-operator.go │ └── streamedexec.go └── tools └── ginkgo.go /.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 -------------------------------------------------------------------------------- /.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 | 
-------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for Shell-operator 4 | --- 5 | 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | types: [opened, labeled, unlabeled, synchronize] 6 | 7 | jobs: 8 | release-label: 9 | name: Release note label 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check minimum labels 14 | uses: mheap/github-action-required-labels@v5 15 | with: 16 | mode: minimum 17 | count: 1 18 | labels: "release-note/dependencies, dependencies, release-note/deprecation, release-note/breaking-change, release-note/bug, bug, release-note/enhancement, enhancement, release-note/new-feature, release-note/ignore" 19 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-20.04 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup mdBook 18 | uses: peaceiris/actions-mdbook@v2 19 | with: 20 | mdbook-version: '0.4.10' 21 | 22 | - run: mdbook build docs 23 | 24 | - name: Deploy 25 | uses: peaceiris/actions-gh-pages@v4 26 | if: ${{ github.ref == 'refs/heads/main' }} 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | publish_dir: ./docs/book 30 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # IDE proj files 15 | .idea 16 | *.iml 17 | *.proj 18 | 19 | # C Dependency 20 | libjq 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Prebuilt libjq. 2 | FROM --platform=${TARGETPLATFORM:-linux/amd64} flant/jq:b6be13d5-musl as libjq 3 | 4 | # Go builder. 5 | FROM --platform=${TARGETPLATFORM:-linux/amd64} golang:1.23-alpine3.21 AS builder 6 | 7 | ARG appVersion=latest 8 | RUN apk --no-cache add git ca-certificates gcc musl-dev libc-dev binutils-gold 9 | 10 | # Cache-friendly download of go dependencies. 11 | ADD go.mod go.sum /app/ 12 | WORKDIR /app 13 | RUN go mod download 14 | 15 | ADD . /app 16 | 17 | RUN GOOS=linux \ 18 | go build -ldflags="-s -w -X 'github.com/flant/shell-operator/pkg/app.Version=$appVersion'" \ 19 | -o shell-operator \ 20 | ./cmd/shell-operator 21 | 22 | # Final image 23 | FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.21 24 | ARG TARGETPLATFORM 25 | RUN apk --no-cache add ca-certificates bash sed tini && \ 26 | kubectlArch=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\/v7//') && \ 27 | echo "Download kubectl for ${kubectlArch}" && \ 28 | wget https://dl.k8s.io/release/v1.30.12/bin/${kubectlArch}/kubectl -O /bin/kubectl && \ 29 | chmod +x /bin/kubectl && \ 30 | mkdir /hooks 31 | ADD frameworks/shell /frameworks/shell 32 | ADD shell_lib.sh / 33 | COPY --from=libjq /bin/jq /usr/bin 34 | COPY --from=builder /app/shell-operator / 35 | WORKDIR / 36 | ENV SHELL_OPERATOR_HOOKS_DIR=/hooks 37 | ENV LOG_TYPE=json 38 | ENTRYPOINT ["/sbin/tini", "--", "/shell-operator"] 39 | CMD ["start"] 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO=$(shell which go) 2 | GIT=$(shell which git) 3 | 4 | .PHONY: go-check 5 | go-check: 6 | $(call error-if-empty,$(GO),go) 7 | 8 | .PHONY: git-check 9 | git-check: 10 | $(call error-if-empty,$(GIT),git) 11 | 12 | .PHONY: go-module-version 13 | go-module-version: go-check git-check 14 | @echo "go get $(shell $(GO) list ./cmd/shell-operator)@$(shell $(GIT) rev-parse HEAD)" 15 | 16 | .PHONY: test 17 | test: go-check 18 | @$(GO) test --race --cover ./... 19 | 20 | define error-if-empty 21 | @if [[ -z $(1) ]]; then echo "$(2) not installed"; false; fi 22 | endef -------------------------------------------------------------------------------- /cmd/shell-operator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/deckhouse/deckhouse/pkg/log" 8 | "gopkg.in/alecthomas/kingpin.v2" 9 | 10 | "github.com/flant/kube-client/klogtolog" 11 | "github.com/flant/shell-operator/pkg/app" 12 | "github.com/flant/shell-operator/pkg/debug" 13 | "github.com/flant/shell-operator/pkg/filter/jq" 14 | ) 15 | 16 | func main() { 17 | kpApp := kingpin.New(app.AppName, fmt.Sprintf("%s %s: %s", app.AppName, app.Version, app.AppDescription)) 18 | 19 | logger := log.NewLogger(log.Options{}) 20 | log.SetDefault(logger) 21 | 22 | // override usage template to reveal additional commands with information about start command 23 | kpApp.UsageTemplate(app.OperatorUsageTemplate(app.AppName)) 24 | 25 | // Initialize klog wrapper when all values are parsed 26 | kpApp.Action(func(_ *kingpin.ParseContext) error { 27 | klogtolog.InitAdapter(app.DebugKubernetesAPI, logger.Named("klog")) 28 | return nil 29 | }) 30 | 31 | // print version 32 | kpApp.Command("version", "Show version.").Action(func(_ *kingpin.ParseContext) error { 33 | fmt.Printf("%s %s\n", app.AppName, app.Version) 34 | fl := jq.NewFilter() 35 | fmt.Println(fl.FilterInfo()) 36 | return nil 37 | }) 38 | 39 | // start main loop 40 | startCmd := kpApp.Command("start", "Start shell-operator."). 41 | Default(). 42 | Action(start(logger)) 43 | app.DefineStartCommandFlags(kpApp, startCmd) 44 | 45 | debug.DefineDebugCommands(kpApp) 46 | debug.DefineDebugCommandsSelf(kpApp) 47 | 48 | kingpin.MustParse(kpApp.Parse(os.Args[1:])) 49 | } 50 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["The Shell-Operator Maintainers"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Shell-operator" 7 | 8 | [output.html] 9 | curly-quotes = true 10 | git-repository-url = "https://github.com/flant/shell-operator" 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/src/image/logo-shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/3474988452c35b2bcb22ba235abcb258138275e8/docs/src/image/logo-shell.png -------------------------------------------------------------------------------- /docs/src/image/shell-operator-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/3474988452c35b2bcb22ba235abcb258138275e8/docs/src/image/shell-operator-logo.png -------------------------------------------------------------------------------- /docs/src/image/shell-operator-small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flant/shell-operator/3474988452c35b2bcb22ba235abcb258138275e8/docs/src/image/shell-operator-small-logo.png -------------------------------------------------------------------------------- /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/001-startup-shell/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/001-startup-shell/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:startup-shell 10 | imagePullPolicy: Always 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/002-startup-python/hooks/00-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | if len(sys.argv)>1 and sys.argv[1] == "--config": 7 | print('{"configVersion":"v1", "onStartup": 10}') 8 | else: 9 | print("OnStartup Python powered hook") 10 | -------------------------------------------------------------------------------- /examples/002-startup-python/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:startup-python 10 | imagePullPolicy: Always 11 | -------------------------------------------------------------------------------- /examples/003-common-library/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/003-common-library/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:common-library 10 | imagePullPolicy: Always 11 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/flant/shell-operator:latest 2 | ADD hooks /hooks 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/101-monitor-pods/hooks/pods-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | 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 | -------------------------------------------------------------------------------- /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 <. 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 | -------------------------------------------------------------------------------- /examples/105-crd-simple/hooks/crd-hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat < 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-204 . 25 | docker push localhost:5000/shell-operator:example-204 26 | helm upgrade --install \ 27 | --namespace example-204 \ 28 | --create-namespace \ 29 | example-204 . 30 | ``` 31 | 32 | ### See validating hook in action 33 | 34 | 1. Attempt to create a non valid CronTab: 35 | 36 | ``` 37 | $ kubectl -n example-204 apply -f crontab-non-valid.yaml 38 | Error from server: error when creating "crontab-non-valid.yaml": admission webhook "private-repo-policy.example.com" denied the request: Only images from repo.example.com are allowed 39 | $ kubectl -n example-204 get crontab 40 | No resources found in example-204 namespace. 41 | ``` 42 | 43 | 2. Create a valid CronTab: 44 | 45 | ``` 46 | $ kubectl -n example-204 apply -f crontab-valid.yaml 47 | crontab.stable.example.com/crontab-valid created 48 | $ kubectl -n example-204 get crontab 49 | NAME AGE 50 | crontab-valid 9s 51 | ``` 52 | 53 | 3. Change the "image" field in the valid CronTab: 54 | 55 | ``` 56 | $ kubectl -n example-204 patch crontab crontab-valid --type=merge -p '{"spec":{"image":"localhost:5000/crontab:latest"}}' 57 | Error from server: admission webhook "private-repo-policy.example.com" denied the request: Only images from repo.example.com are allowed 58 | ``` 59 | 60 | ### Cleanup 61 | 62 | ``` 63 | helm delete --namespace=example-204 example-204 64 | kubectl delete ns example-204 65 | kubectl delete validatingwebhookconfiguration/example-204 66 | ``` 67 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/crontab-non-valid.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: crontab-non-valid 5 | labels: 6 | heritage: example-204 7 | spec: 8 | cronSpec: "* * * * */5" 9 | image: my-awesome-cron-image 10 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/crontab-valid.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: crontab-valid 5 | labels: 6 | heritage: example-204 7 | spec: 8 | cronSpec: "* * * * */5" 9 | image: repo.example.com/my-awesome-cron-image:v1 10 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/gen-certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NAMESPACE=example-204 4 | SERVICE_NAME=example-204-validating-service 5 | 6 | COMMON_NAME=${SERVICE_NAME}.${NAMESPACE} 7 | 8 | set -eo pipefail 9 | 10 | echo ================================================================= 11 | echo THIS SCRIPT IS NOT SECURE! USE IT ONLY FOR DEMONSTATION PURPOSES. 12 | echo ================================================================= 13 | echo 14 | 15 | mkdir -p validating-certs && cd validating-certs 16 | 17 | if [[ -e ca.csr ]] ; then 18 | read -p "Regenerate certificates? (yes/no) [no]: " 19 | if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]] 20 | then 21 | exit 0 22 | fi 23 | fi 24 | 25 | RM_FILES="ca* cert*" 26 | echo ">>> 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 < $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 | -------------------------------------------------------------------------------- /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/204-validating-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-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/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/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 | -------------------------------------------------------------------------------- /examples/204-validating-webhook/templates/webhook-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ .Values.shellOperator.validatingWebhookServiceName }} 5 | labels: 6 | heritage: example-204 7 | spec: 8 | # type: LoadBalancer 9 | # externalTrafficPolicy: Local 10 | ports: 11 | - name: validating-webhook 12 | port: 443 13 | targetPort: 9680 14 | protocol: TCP 15 | selector: 16 | app: shell-operator-example-204 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | README.md 6 | values.yaml 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/crontab-valid.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: crontab-valid 5 | labels: 6 | heritage: example-206 7 | spec: 8 | cronSpec: "* * * * */5" 9 | image: repo.example.com/my-awesome-cron-image:v1 10 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/gen-certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NAMESPACE=example-206 4 | SERVICE_NAME=example-206-mutating-service 5 | 6 | COMMON_NAME=${SERVICE_NAME}.${NAMESPACE} 7 | 8 | set -eo pipefail 9 | 10 | echo ================================================================= 11 | echo THIS SCRIPT IS NOT SECURE! USE IT ONLY FOR DEMONSTATION PURPOSES. 12 | echo ================================================================= 13 | echo 14 | 15 | mkdir -p certs && cd certs 16 | 17 | if [[ -e ca.csr ]] ; then 18 | read -p "Regenerate certificates? (yes/no) [no]: " 19 | if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]] 20 | then 21 | exit 0 22 | fi 23 | fi 24 | 25 | RM_FILES="ca* cert*" 26 | echo ">>> 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 < $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/206-mutating-webhook/templates/certs-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: example-206-certs 5 | type: kubernetes.io/tls 6 | data: 7 | tls.crt: | 8 | {{ .Files.Get "certs/tls.pem" | b64enc }} 9 | tls.key: | 10 | {{ .Files.Get "certs/tls-key.pem" | b64enc }} 11 | ca.crt: | 12 | {{ .Files.Get "certs/ca.pem" | b64enc }} 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/206-mutating-webhook/templates/webhook-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ .Values.shellOperator.webhookServiceName }} 5 | labels: 6 | heritage: example-206 7 | spec: 8 | # type: LoadBalancer 9 | # externalTrafficPolicy: Local 10 | ports: 11 | - name: webhook 12 | port: 443 13 | targetPort: 9680 14 | protocol: TCP 15 | selector: 16 | app: shell-operator-example-206 17 | -------------------------------------------------------------------------------- /examples/206-mutating-webhook/values.yaml: -------------------------------------------------------------------------------- 1 | shellOperator: 2 | image: localhost:5000/shell-operator:example-206 3 | # Important: this name should be the same as in gen-certs.sh and csr.json. 4 | webhookServiceName: example-206-mutating-service 5 | webhookConfigurationName: example-206 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/README.md: -------------------------------------------------------------------------------- 1 | # Example with conversion hooks 2 | 3 | This is a simple example of `kubernetesCustomResourceConversion` binding. Read more information in [BINDING_CONVERSION.md](../../BINDING_CONVERSION.md). 4 | 5 | The example contains one hook that is used for conversion of custom resource [CronTab](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/) on create or update (see also [105-crd-example](../105-crd-example/README.md). The `CronTab` has two versions: v1alpha1 and v1alpha2. The later one changes a type of .spec.crontab field from array to a string. The hook applies this change to resources with version v1alpha1. 6 | 7 | ## Run 8 | 9 | ### Generate certificates 10 | 11 | An HTTP server for conversion webhook 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-210 . 25 | docker push localhost:5000/shell-operator:example-210 26 | helm upgrade --install \ 27 | --namespace example-210 \ 28 | --create-namespace \ 29 | example-210 . 30 | ``` 31 | 32 | ### See conversion hook in action 33 | 34 | 1. Create a CronTab with previous version v1alpha1: 35 | 36 | ``` 37 | $ cat crontab-v1alpha1.yaml 38 | apiVersion: "stable.example.com/v1alpha1" 39 | kind: CronTab 40 | metadata: 41 | name: crontab-v1alpha1 42 | labels: 43 | heritage: example-210 44 | spec: 45 | cron: 46 | - "*" 47 | - "*" 48 | - "*" 49 | - "*" 50 | - "*/5" 51 | imageName: repo.example.com/my-awesome-cron-image:v1 52 | 53 | $ kubectl -n example-210 apply -f crontab-v1alpha1.yaml 54 | crontab.stable.example.com/crontab-v1alpha1 created 55 | ``` 56 | 57 | 2. Now get created resource back: 58 | 59 | ``` 60 | $ kubectl -n example-210 get ct/crontab-v1alpha1 -o yaml 61 | apiVersion: stable.example.com/v1alpha2 62 | kind: CronTab 63 | metadata: 64 | ... 65 | spec: 66 | cron: '* * * * */5' 67 | imageName: repo.example.com/my-awesome-cron-image:v1 68 | ``` 69 | 70 | Note that `apiVersion` is "stable.example.com/v1alpha2" and `spec.cron` is converted to a string. 71 | 72 | 73 | ### Cleanup 74 | 75 | ``` 76 | helm delete --namespace=example-210 example-210 77 | kubectl delete ns example-210 78 | kubectl delete crd crontabs.stable.example.com 79 | ``` 80 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/crontab-v1alpha1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1alpha1" 2 | kind: CronTab 3 | metadata: 4 | name: crontab-v1alpha1 5 | labels: 6 | heritage: example-210 7 | spec: 8 | cron: 9 | - "*" 10 | - "*" 11 | - "*" 12 | - "*" 13 | - "*/5" 14 | imageName: repo.example.com/my-awesome-cron-image:v1 15 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/gen-certs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NAMESPACE=example-210 4 | SERVICE_NAME=example-210-conversion-service 5 | 6 | COMMON_NAME=${SERVICE_NAME}.${NAMESPACE} 7 | 8 | set -eo pipefail 9 | 10 | echo ================================================================= 11 | echo THIS SCRIPT IS NOT SECURE! USE IT ONLY FOR DEMONSTATION PURPOSES. 12 | echo ================================================================= 13 | echo 14 | 15 | mkdir -p conversion-certs && cd conversion-certs 16 | 17 | if [[ -e ca.csr ]] ; then 18 | read -p "Regenerate certificates? (yes/no) [no]: " 19 | if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]] 20 | then 21 | exit 0 22 | fi 23 | fi 24 | 25 | RM_FILES="ca* cert*" 26 | echo ">>> 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 <$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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: shell-operator 6 | labels: 7 | heritage: example-210 8 | app: shell-operator-example-210 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: shell-operator-example-210 14 | strategy: 15 | type: Recreate 16 | template: 17 | metadata: 18 | labels: 19 | heritage: example-210 20 | app: shell-operator-example-210 21 | annotations: 22 | checksum/hook: {{ .Files.Get "hooks/conversion-alpha.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: CONVERSION_WEBHOOK_SERVICE_NAME 34 | value: {{ .Values.shellOperator.conversionWebhookServiceName | quote }} 35 | livenessProbe: 36 | httpGet: 37 | port: 9681 38 | path: /healthz 39 | scheme: HTTPS 40 | volumeMounts: 41 | - name: conversion-certs 42 | mountPath: /conversion-certs/ 43 | readOnly: true 44 | serviceAccountName: example-210-acc 45 | volumes: 46 | - name: conversion-certs 47 | secret: 48 | secretName: example-210-conversion-certs 49 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: example-210-acc 6 | labels: 7 | heritage: example-210 8 | --- 9 | # Create and update ValidatingWebhookConfiguration 10 | apiVersion: rbac.authorization.k8s.io/v1beta1 11 | kind: ClusterRole 12 | metadata: 13 | name: example-210 14 | labels: 15 | heritage: example-210 16 | rules: 17 | - apiGroups: ["apiextensions.k8s.io"] 18 | resources: ["customresourcedefinitions"] 19 | verbs: ["list", "update"] 20 | - apiGroups: ["stable.example.com"] 21 | resources: ["crontabs"] 22 | verbs: ["get", "list", "watch"] 23 | 24 | --- 25 | apiVersion: rbac.authorization.k8s.io/v1beta1 26 | kind: ClusterRoleBinding 27 | metadata: 28 | name: example-210 29 | labels: 30 | heritage: example-210 31 | roleRef: 32 | apiGroup: rbac.authorization.k8s.io 33 | kind: ClusterRole 34 | name: example-210 35 | subjects: 36 | - kind: ServiceAccount 37 | name: example-210-acc 38 | namespace: {{ .Release.Namespace }} 39 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/templates/webhook-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ .Values.shellOperator.conversionWebhookServiceName }} 5 | labels: 6 | heritage: example-210 7 | spec: 8 | # type: LoadBalancer 9 | # externalTrafficPolicy: Local 10 | ports: 11 | - name: conversion-webhook 12 | port: 443 13 | targetPort: 9681 14 | protocol: TCP 15 | selector: 16 | app: shell-operator-example-210 17 | -------------------------------------------------------------------------------- /examples/210-conversion-webhook/values.yaml: -------------------------------------------------------------------------------- 1 | shellOperator: 2 | image: localhost:5000/shell-operator:example-210 3 | # Important: this name should be the same as in gen-certs.sh and csr.json. 4 | conversionWebhookServiceName: example-210-conversion-service 5 | -------------------------------------------------------------------------------- /examples/220-execution-rate/.dockerignore: -------------------------------------------------------------------------------- 1 | templates 2 | .helmignore 3 | Chart.yaml 4 | crontab*.yaml 5 | README.md 6 | values.yaml 7 | -------------------------------------------------------------------------------- /examples/220-execution-rate/.helmignore: -------------------------------------------------------------------------------- 1 | crontab-recreate.sh 2 | Dockerfile 3 | README.md 4 | shell-operator 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/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/220-execution-rate/crontab-recreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while true ; do 4 | for i in `seq 1 4` ; do 5 | (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/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 | -------------------------------------------------------------------------------- /examples/220-execution-rate/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: example-220-acc 6 | labels: 7 | heritage: example-220 8 | --- 9 | apiVersion: rbac.authorization.k8s.io/v1beta1 10 | kind: ClusterRole 11 | metadata: 12 | name: example-220 13 | labels: 14 | heritage: example-220 15 | rules: 16 | - apiGroups: ["stable.example.com"] 17 | resources: ["crontabs"] 18 | verbs: ["get", "list", "watch"] 19 | --- 20 | apiVersion: rbac.authorization.k8s.io/v1beta1 21 | kind: ClusterRoleBinding 22 | metadata: 23 | name: example-220 24 | labels: 25 | heritage: example-220 26 | roleRef: 27 | apiGroup: rbac.authorization.k8s.io 28 | kind: ClusterRole 29 | name: example-220 30 | subjects: 31 | - kind: ServiceAccount 32 | name: example-220-acc 33 | namespace: {{ .Release.Namespace }} 34 | -------------------------------------------------------------------------------- /examples/220-execution-rate/values.yaml: -------------------------------------------------------------------------------- 1 | shellOperator: 2 | image: localhost:5000/shell-operator:example-220 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/230-configmap-python/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:startup-python 10 | imagePullPolicy: Always 11 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/deckhouse/deckhouse/pkg/log" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestConfig_Register(t *testing.T) { 13 | c := NewConfig(log.NewNop()) 14 | 15 | c.Register("log.level", "", "info", nil, nil) 16 | 17 | logLevel := c.Value("log.level") 18 | assert.Equal(t, "info", logLevel) 19 | 20 | c.Set("log.level", "debug") 21 | 22 | logLevel = c.Value("log.level") 23 | assert.Equal(t, "debug", logLevel) 24 | 25 | c.Unset("log.level") 26 | 27 | logLevel = c.Value("log.level") 28 | assert.Equal(t, "info", logLevel) 29 | 30 | params := c.List() 31 | 32 | assert.Len(t, params, 1) 33 | } 34 | 35 | func TestConfig_OnChange(t *testing.T) { 36 | c := NewConfig(log.NewNop()) 37 | 38 | newValue := "" 39 | c.Register("log.level", "", "info", func(_ string, n string) error { 40 | newValue = n 41 | return nil 42 | }, nil) 43 | 44 | c.Set("log.level", "debug") 45 | assert.Equal(t, "debug", newValue, "onChange not called for Set") 46 | 47 | c.Unset("log.level") 48 | assert.Equal(t, "info", newValue, "onChange not called for Unset after Set") 49 | 50 | // Temporal value 51 | c.SetTemporarily("log.level", "debug", 10*time.Second) 52 | assert.Equal(t, "debug", newValue, "onChange not called for SetTemporarily") 53 | 54 | c.Unset("log.level") 55 | assert.Equal(t, "info", newValue, "onChange not called for Unset after SetTemporarily") 56 | 57 | // Set after SetTemporarily 58 | c.SetTemporarily("log.level", "debug", 10*time.Second) 59 | assert.Equal(t, "debug", newValue, "onChange not called for SetTemporarily") 60 | 61 | c.Set("log.level", "error") 62 | assert.Equal(t, "error", newValue, "onChange not called for Set after SetTemporarily") 63 | 64 | c.Unset("log.level") 65 | assert.Equal(t, "info", newValue, "onChange not called for Unset after SetTemporarily+Set") 66 | } 67 | 68 | func TestConfig_Errors(t *testing.T) { 69 | var err error 70 | c := NewConfig(log.NewNop()) 71 | 72 | c.Register("log.level", "", "info", func(_ string, n string) error { 73 | if n == "debug" { 74 | return nil 75 | } 76 | return fmt.Errorf("unknown value") 77 | }, nil) 78 | 79 | c.Set("log.level", "bad-value") 80 | err = c.LastError("log.level") 81 | assert.Error(t, err, "Set should save error about unknown value") 82 | 83 | c.Set("log.level", "debug") 84 | err = c.LastError("log.level") 85 | assert.NoError(t, err, "Set should clean error after success in onChange handler") 86 | } 87 | -------------------------------------------------------------------------------- /pkg/debug/client.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | 11 | "github.com/flant/shell-operator/pkg/app" 12 | utils "github.com/flant/shell-operator/pkg/utils/file" 13 | ) 14 | 15 | type Client struct { 16 | SocketPath string 17 | } 18 | 19 | func NewClient() *Client { 20 | return &Client{} 21 | } 22 | 23 | func (c *Client) WithSocketPath(path string) { 24 | c.SocketPath = path 25 | } 26 | 27 | func (c *Client) newHttpClient() (http.Client, error) { 28 | exists, err := utils.FileExists(c.SocketPath) 29 | if err != nil { 30 | return http.Client{}, fmt.Errorf("check debug socket '%s': %s", c.SocketPath, err) 31 | } 32 | if !exists { 33 | return http.Client{}, fmt.Errorf("debug socket '%s' is not exists", c.SocketPath) 34 | } 35 | 36 | return http.Client{ 37 | Transport: &http.Transport{ 38 | DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { 39 | return net.Dial("unix", c.SocketPath) 40 | }, 41 | }, 42 | }, nil 43 | } 44 | 45 | func DefaultClient() *Client { 46 | cl := NewClient() 47 | cl.WithSocketPath(app.DebugUnixSocket) 48 | return cl 49 | } 50 | 51 | func (c *Client) Get(url string) ([]byte, error) { 52 | httpc, err := c.newHttpClient() 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | resp, err := httpc.Get(url) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer resp.Body.Close() 62 | 63 | bodyBuf := new(bytes.Buffer) 64 | _, err = io.Copy(bodyBuf, resp.Body) 65 | if err != nil { 66 | return nil, err 67 | } 68 | return bodyBuf.Bytes(), nil 69 | } 70 | 71 | func (c *Client) Post(targetUrl string, data map[string][]string) ([]byte, error) { 72 | httpc, err := c.newHttpClient() 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | resp, err := httpc.PostForm(targetUrl, data) 78 | if err != nil { 79 | return nil, err 80 | } 81 | defer resp.Body.Close() 82 | 83 | bodyBuf := new(bytes.Buffer) 84 | _, err = io.Copy(bodyBuf, resp.Body) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return bodyBuf.Bytes(), nil 89 | } 90 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/hook/config/schemas_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_GetSchema(t *testing.T) { 8 | schemas := []string{"v0", "v1"} 9 | 10 | for _, schema := range schemas { 11 | s := GetSchema(schema) 12 | if s == nil { 13 | t.Fatalf("schema '%s' should not be nil", schema) 14 | } 15 | } 16 | } 17 | 18 | func Test_LoadSchema_From_Schemas(t *testing.T) { 19 | for schemaVer := range Schemas { 20 | s, err := LoadSchema(schemaVer) 21 | if err != nil { 22 | t.Fatalf("schema '%s' should load: %v", schemaVer, err) 23 | } 24 | if s == nil { 25 | t.Fatalf("schema '%s' should not be nil: %v", schemaVer, err) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/hook/testdata/hook_manager/configMapHooks/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $1 == "--config" ]] ; then 4 | cat < maxDelay.Nanoseconds() { 60 | return maxDelay 61 | } 62 | return delay 63 | } 64 | -------------------------------------------------------------------------------- /pkg/utils/exponential_backoff/delay_test.go: -------------------------------------------------------------------------------- 1 | package exponential_backoff 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func Test_Delay(t *testing.T) { 9 | var initialSeconds int64 = 5 10 | initialDelay := time.Duration(5) * time.Second 11 | for i := 0; i < 64; i++ { 12 | delay := CalculateDelay(initialDelay, i) 13 | seconds := int64(delay.Seconds()) 14 | 15 | switch { 16 | case i == 0: 17 | if seconds != initialSeconds { 18 | t.Fatalf("delay for retry %d should be %d seconds, actual delay is %s", i, initialSeconds, delay) 19 | } 20 | case i <= ExponentialCalculationsCount: 21 | if seconds > 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/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 | -------------------------------------------------------------------------------- /pkg/utils/labels/labels.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log/slog" 5 | "sort" 6 | 7 | "github.com/deckhouse/deckhouse/pkg/log" 8 | ) 9 | 10 | // MergeLabels merges several maps into one. Last map keys overrides keys from first maps. 11 | // 12 | // Can be used to copy a map if just one argument is used. 13 | func MergeLabels(labelsMaps ...map[string]string) map[string]string { 14 | labels := make(map[string]string) 15 | for _, labelsMap := range labelsMaps { 16 | for k, v := range labelsMap { 17 | labels[k] = v 18 | } 19 | } 20 | return labels 21 | } 22 | 23 | func EnrichLoggerWithLabels(logger *log.Logger, labelsMaps ...map[string]string) *log.Logger { 24 | loggerEntry := logger 25 | 26 | for _, labels := range labelsMaps { 27 | for k, v := range labels { 28 | loggerEntry = loggerEntry.With(slog.String(k, v)) 29 | } 30 | } 31 | 32 | return loggerEntry 33 | } 34 | 35 | // LabelNames returns sorted label keys 36 | func LabelNames(labels map[string]string) []string { 37 | names := make([]string, 0) 38 | for labelName := range labels { 39 | names = append(names, labelName) 40 | } 41 | sort.Strings(names) 42 | return names 43 | } 44 | 45 | func LabelValues(labels map[string]string, labelNames []string) []string { 46 | values := make([]string, 0) 47 | for _, name := range labelNames { 48 | values = append(values, labels[name]) 49 | } 50 | return values 51 | } 52 | 53 | func DefaultIfEmpty(m map[string]string, def map[string]string) map[string]string { 54 | if len(m) == 0 { 55 | return def 56 | } 57 | return m 58 | } 59 | 60 | // IsSubset checks if a set contains b subset 61 | func IsSubset(a, b []string) bool { 62 | aMap := make(map[string]struct{}, len(a)) 63 | for _, v := range a { 64 | aMap[v] = struct{}{} 65 | } 66 | 67 | for _, v := range b { 68 | if _, found := aMap[v]; !found { 69 | return false 70 | } 71 | } 72 | return true 73 | } 74 | -------------------------------------------------------------------------------- /pkg/utils/measure/measure.go: -------------------------------------------------------------------------------- 1 | package measure 2 | 3 | import "time" 4 | 5 | // Duration returns a function that can be deferred to measure an execution time of a method call 6 | func Duration(resultCallback func(d time.Duration)) func() { 7 | start := time.Now() 8 | return func() { 9 | if resultCallback != nil { 10 | resultCallback(time.Since(start)) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /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/utils/string_helper/apigroup.go: -------------------------------------------------------------------------------- 1 | package string_helper 2 | 3 | import "strings" 4 | 5 | // TrimGroup removes prefix with a slash. 6 | // 7 | // E.g. input string "stable.example.com/v1beta1" 8 | // gives "v1beta1". 9 | func TrimGroup(apiVersion string) string { 10 | idx := strings.IndexRune(apiVersion, '/') 11 | if idx >= 0 { 12 | // 13 | return apiVersion[idx+1:] 14 | } 15 | return apiVersion 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/utils/string_helper/safe_url.go: -------------------------------------------------------------------------------- 1 | package string_helper 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var safeReList = []*regexp.Regexp{ 9 | regexp.MustCompile(`([A-Z])`), 10 | regexp.MustCompile(`[^a-z0-9-/]`), 11 | regexp.MustCompile(`[-]+`), 12 | } 13 | 14 | func SafeURLString(s string) string { 15 | s = safeReList[0].ReplaceAllString(s, "-$1") 16 | s = strings.ToLower(s) 17 | s = safeReList[1].ReplaceAllString(s, "-") 18 | return safeReList[2].ReplaceAllString(s, "-") 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/admission/event.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import ( 4 | v1 "k8s.io/api/admission/v1" 5 | ) 6 | 7 | type Event struct { 8 | WebhookId string 9 | ConfigurationId string 10 | Request *v1.AdmissionRequest 11 | } 12 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/webhook/admission/settings.go: -------------------------------------------------------------------------------- 1 | package admission 2 | 3 | import "github.com/flant/shell-operator/pkg/webhook/server" 4 | 5 | type WebhookSettings struct { 6 | server.Settings 7 | CAPath string 8 | CABundle []byte 9 | ConfigurationName string 10 | DefaultFailurePolicy string 11 | } 12 | -------------------------------------------------------------------------------- /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/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-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJXjpbUMivR3vePl+E6lbwbP1QfbnP92SFnEVkl1RSWNoAoGCCqGSM49 3 | AwEHoUQDQgAE3oesOLHfn+I/tBFQ29EEdi9p7PbYKmPE52BC6KoC8JUDcznAzJYJ 4 | 8lWT18MlIDOK2n2h+80Y+gdUN6SJKyyTVQ== 5 | -----END EC PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_allow.json: -------------------------------------------------------------------------------- 1 | {"allowed":true} -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_allow_warnings.json: -------------------------------------------------------------------------------- 1 | {"allowed":true, "warnings": ["Warning 1", "Warning 2"]} -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_deny.json: -------------------------------------------------------------------------------- 1 | {"allowed":false,"message": "Denied"} -------------------------------------------------------------------------------- /pkg/webhook/admission/testdata/response/good_deny_quiet.json: -------------------------------------------------------------------------------- 1 | {"allowed":false} -------------------------------------------------------------------------------- /pkg/webhook/conversion/chain_test.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | func Test_VersionsMatched(t *testing.T) { 10 | g := NewWithT(t) 11 | var v0 string 12 | var v1 string 13 | var res bool 14 | 15 | v0 = "v1beta1" 16 | v1 = "v1beta1" 17 | res = VersionsMatched(v0, v1) 18 | g.Expect(res).Should(BeTrue(), "Expect that '%s' is matching '%s'.") 19 | 20 | v0 = "a/v1beta1" 21 | v1 = "v1beta1" 22 | res = VersionsMatched(v0, v1) 23 | g.Expect(res).Should(BeTrue(), "Expect that '%s' is matching '%s'.") 24 | 25 | v0 = "a/v1beta1" 26 | v1 = "a/v1beta1" 27 | res = VersionsMatched(v0, v1) 28 | g.Expect(res).Should(BeTrue(), "Expect that '%s' is matching '%s'.") 29 | 30 | v0 = "v1beta1" 31 | v1 = "a/v1beta1" 32 | res = VersionsMatched(v0, v1) 33 | g.Expect(res).Should(BeTrue(), "Expect that '%s' is matching '%s'.") 34 | 35 | // Negative 36 | v0 = "b/v1beta1" 37 | v1 = "a/v1beta1" 38 | res = VersionsMatched(v0, v1) 39 | g.Expect(res).Should(BeFalse(), "Expect that '%s' is not matching '%s'.") 40 | } 41 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ) 12 | 13 | // A clientConfig for a particular CRD. 14 | type CrdClientConfig struct { 15 | KubeClient *klient.Client 16 | CrdName string 17 | Namespace string 18 | ServiceName string 19 | Path string 20 | CABundle []byte 21 | } 22 | 23 | var SupportedConversionReviewVersions = []string{"v1", "v1beta1"} 24 | 25 | func (c *CrdClientConfig) Update(ctx context.Context) error { 26 | var ( 27 | retryTimeout = 15 * time.Second 28 | retryBudget = 60 // 60 times * 15 sec = 15 min 29 | client = c.KubeClient 30 | ) 31 | 32 | tryToGetCRD: 33 | crd, err := client.ApiExt().CustomResourceDefinitions().Get(ctx, c.CrdName, metav1.GetOptions{}) 34 | if err != nil { 35 | if retryBudget > 0 { 36 | retryBudget-- 37 | time.Sleep(retryTimeout) 38 | goto tryToGetCRD 39 | } 40 | 41 | return err 42 | } 43 | 44 | if crd.Spec.Conversion == nil { 45 | crd.Spec.Conversion = new(extv1.CustomResourceConversion) 46 | } 47 | crd.Spec.Conversion.Strategy = extv1.WebhookConverter 48 | 49 | if crd.Spec.Conversion.Webhook == nil { 50 | crd.Spec.Conversion.Webhook = new(extv1.WebhookConversion) 51 | } 52 | crd.Spec.Conversion.Webhook.ClientConfig = &extv1.WebhookClientConfig{ 53 | URL: nil, 54 | Service: &extv1.ServiceReference{ 55 | Namespace: c.Namespace, 56 | Name: c.ServiceName, 57 | Path: &c.Path, 58 | }, 59 | CABundle: c.CABundle, 60 | } 61 | crd.Spec.Conversion.Webhook.ConversionReviewVersions = SupportedConversionReviewVersions 62 | 63 | _, err = client.ApiExt().CustomResourceDefinitions().Update(ctx, crd, metav1.UpdateOptions{}) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /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/webhook/conversion/settings.go: -------------------------------------------------------------------------------- 1 | package conversion 2 | 3 | import "github.com/flant/shell-operator/pkg/webhook/server" 4 | 5 | type WebhookSettings struct { 6 | server.Settings 7 | CAPath string 8 | CABundle []byte 9 | } 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/webhook/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/x509" 5 | "os" 6 | "testing" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func Test_ServerStart(t *testing.T) { 12 | s := &Settings{ 13 | ServerCertPath: "testdata/demo-certs/server.crt", 14 | ServerKeyPath: "testdata/demo-certs/server-key.pem", 15 | } 16 | 17 | rtr := chi.NewRouter() 18 | 19 | srv := &WebhookServer{ 20 | Settings: s, 21 | Router: rtr, 22 | } 23 | 24 | err := srv.Start() 25 | if err != nil { 26 | t.Fatalf("Server should start: %v", err) 27 | } 28 | } 29 | 30 | func Test_Client_CA(t *testing.T) { 31 | roots := x509.NewCertPool() 32 | 33 | s := Settings{} 34 | s.ClientCAPaths = []string{ 35 | "testdata/demo-certs/client-ca.pem", 36 | } 37 | 38 | for _, caPath := range s.ClientCAPaths { 39 | caBytes, err := os.ReadFile(caPath) 40 | if err != nil { 41 | t.Fatalf("ca '%s' should be read: %v", caPath, err) 42 | } 43 | 44 | ok := roots.AppendCertsFromPEM(caBytes) 45 | if !ok { 46 | t.Fatalf("ca '%s' should be parsed", caPath) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/webhook/server/settings.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | type Settings struct { 4 | ServiceName string 5 | ServerCertPath string 6 | ServerKeyPath string 7 | ClientCAPaths []string 8 | ListenPort string 9 | ListenAddr string 10 | } 11 | -------------------------------------------------------------------------------- /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/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-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJXjpbUMivR3vePl+E6lbwbP1QfbnP92SFnEVkl1RSWNoAoGCCqGSM49 3 | AwEHoUQDQgAE3oesOLHfn+I/tBFQ29EEdi9p7PbYKmPE52BC6KoC8JUDcznAzJYJ 4 | 8lWT18MlIDOK2n2h+80Y+gdUN6SJKyyTVQ== 5 | -----END EC PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/ci/extract-file.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function usage() { 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 | -------------------------------------------------------------------------------- /test/hook/context/README.md: -------------------------------------------------------------------------------- 1 | Binding Context Generator 2 | ========================= 3 | Binding Context Generator provides the ability to generate binding contexts for hooks testing purposes. 4 | 5 | Usage example: 6 | 1. Hook Config 7 | ```go 8 | config := ` 9 | configVersion: v1 10 | schedule: 11 | - name: every_minute 12 | crontab: '* * * * *' 13 | includeSnapshotsFrom: 14 | - pod 15 | kubernetes: 16 | - apiVersion: v1 17 | name: pod 18 | kind: Pod 19 | watchEvent: 20 | - Added 21 | - Modified 22 | - Deleted 23 | namespace: 24 | nameSelector: 25 | matchNames: 26 | - default` 27 | ``` 28 | 2. Initial objects state 29 | ```go 30 | initialState := ` 31 | --- 32 | apiVersion: v1 33 | kind: Pod 34 | metadata: 35 | name: pod-0 36 | --- 37 | apiVersion: v1 38 | kind: Pod 39 | metadata: 40 | name: pod-1 41 | ` 42 | ``` 43 | 3. New state 44 | ```go 45 | newState := ` 46 | --- 47 | apiVersion: v1 48 | kind: Pod 49 | metadata: 50 | name: pod-0 51 | ` 52 | ``` 53 | 4. Create new binding context controller 54 | ```go 55 | c := context.NewBindingContextController(config, initialState) 56 | ``` 57 | 5. Register CRDs (if it is necessary) in format [Group, Version, Kind, isNamespaced]: 58 | ```go 59 | c.RegisterCRD("example.com", "v1", "Example", true) 60 | c.RegisterCRD("example.com", "v1", "ClusterExample", false) 61 | ``` 62 | 6. Run controller to get initial binding context 63 | ```go 64 | contexts, err := c.Run() 65 | if err != nil { 66 | return err 67 | } 68 | testContexts(contexts) 69 | ``` 70 | 7. Change state to get new binding contexts 71 | ```go 72 | contexts, err = c.ChangeState(newState) 73 | if err != nil { 74 | return err 75 | } 76 | testNewContexts(contexts) 77 | ``` 78 | 8. Run schedule to get binding contexts for a cron task 79 | ```go 80 | contexts, err = c.RunSchedule("* * * * *") 81 | if err != nil { 82 | return err 83 | } 84 | testScheduleContexts(contexts) 85 | ``` 86 | -------------------------------------------------------------------------------- /test/integration/kube_event_manager/testdata/test-pod.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: test 6 | spec: 7 | containers: 8 | - name: test 9 | image: alpine:3.9 10 | imagePullPolicy: Always 11 | command: ["/bin/ash"] 12 | args: 13 | - "-c" 14 | - "trap : TERM INT; sleep 100000 & wait" 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/utils/assert.go: -------------------------------------------------------------------------------- 1 | //go:build test 2 | // +build test 3 | 4 | package utils 5 | 6 | import "strings" 7 | 8 | func HasField(m map[string]string, key string) bool { 9 | _, ok := m[key] 10 | return ok 11 | } 12 | 13 | func FieldContains(m map[string]string, key string, s string) bool { 14 | v, ok := m[key] 15 | return ok && strings.Contains(v, s) 16 | } 17 | 18 | func FieldHasPrefix(m map[string]string, key string, s string) bool { 19 | v, ok := m[key] 20 | return ok && strings.HasPrefix(v, s) 21 | } 22 | 23 | func FieldEquals(m map[string]string, key string, s string) bool { 24 | v, ok := m[key] 25 | return ok && v == s 26 | } 27 | -------------------------------------------------------------------------------- /test/utils/assert_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_MapAsserts(t *testing.T) { 13 | m := map[string]string{ 14 | "foo": "bar", 15 | "baz": "quux", 16 | } 17 | 18 | assert.True(t, HasField(m, "foo")) 19 | assert.True(t, HasField(m, "baz")) 20 | assert.False(t, HasField(m, "bar")) 21 | 22 | assert.True(t, FieldEquals(m, "foo", "bar")) 23 | assert.False(t, FieldEquals(m, "foo", "quux")) 24 | 25 | assert.True(t, FieldContains(m, "baz", "uu")) 26 | assert.False(t, FieldContains(m, "baz", "ZZ")) 27 | 28 | assert.True(t, FieldHasPrefix(m, "baz", "qu")) 29 | assert.False(t, FieldHasPrefix(m, "baz", "uux")) 30 | } 31 | -------------------------------------------------------------------------------- /test/utils/directory.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | utils_file "github.com/flant/shell-operator/pkg/utils/file" 10 | ) 11 | 12 | // TODO: remove useless code 13 | 14 | // ChooseExistedDirectoryPath returns first non-empty existed directory path from arguments. 15 | // 16 | // Each argument is prefixed with current directory if is not started with / 17 | // 18 | // Example: 19 | // dir, err := ExtractExistedDirectoryPath(os.Getenv("DIR"), defaultDir) 20 | 21 | func ChooseExistedDirectoryPath(paths ...string) (string, error) { 22 | var path string 23 | var err error 24 | 25 | for _, p := range paths { 26 | if p != "" { 27 | path = p 28 | break 29 | } 30 | } 31 | 32 | if path == "" { 33 | return "", nil 34 | } 35 | 36 | path, err = ToAbsolutePath(path) 37 | if err != nil { 38 | return path, err 39 | } 40 | 41 | if exists := utils_file.DirExists(path); !exists { 42 | return "", fmt.Errorf("no working dir") 43 | } 44 | 45 | return "", nil 46 | } 47 | 48 | func ToAbsolutePath(path string) (string, error) { 49 | if filepath.IsAbs(path) { 50 | return path, nil 51 | } 52 | 53 | if strings.HasPrefix(path, "./") { 54 | cwd, _ := os.Getwd() 55 | return strings.Replace(path, ".", cwd, 1), nil 56 | } 57 | 58 | p, err := filepath.Abs(path) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | return p, nil 64 | } 65 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------