├── .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 |
--------------------------------------------------------------------------------