├── .dockerignore ├── .gitlab-ci.yml ├── .gitlab └── renovate.json ├── Dockerfile ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── integration-tests ├── kubernetes_test.go ├── run_local.sh └── setup.sh ├── internal ├── database │ ├── database.go │ ├── mysql │ │ ├── manage.go │ │ └── manage_test.go │ └── postgres │ │ ├── manage.go │ │ └── manage_test.go ├── helper │ └── error.go ├── lifecycle │ ├── logic.go │ └── manager.go ├── metrics │ └── metrics.go └── resources │ └── v1 │ └── database.go ├── main.go └── manifests ├── crd.yaml ├── databases ├── cockroach.yaml ├── mariadb.yaml ├── mysql.yaml ├── percona.yaml └── postgres.yaml ├── deployment.yaml ├── rbac.yaml └── test-database.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | manifests/ 2 | integration-tests/ 3 | assets/ 4 | .gitlab/ 5 | .idea/ 6 | README.md 7 | .gitlab-ci.yml 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | stages: 3 | - test 4 | - release 5 | - build 6 | 7 | .go_template_defaults: 8 | stage: test 9 | .semver_template_defaults: 10 | stage: release 11 | .kaniko_template_defaults: 12 | stage: build 13 | include: 14 | - { project: bonsai-oss/organization/automate/ci-templates, file: templates/language/go.yml, ref: 1.0.10 } 15 | - { project: bonsai-oss/organization/automate/ci-templates, file: templates/release/semver.yml, ref: 1.0.10 } 16 | - { project: bonsai-oss/organization/automate/ci-templates, file: templates/release/kaniko.yml, ref: 1.0.10 } 17 | 18 | Integration Tests: 19 | image: ubuntu:latest 20 | stage: test 21 | needs: 22 | - go fmt 23 | tags: 24 | - hcloud 25 | parallel: 26 | matrix: 27 | - INSTALL_K3S_CHANNEL: [latest, stable] 28 | variables: 29 | HCLOUD_SERVER_TYPE: cpx31 30 | KUBECONFIG: /etc/rancher/k3s/k3s.yaml 31 | before_script: 32 | - (apt update && apt install -y curl git) > /dev/null 2>&1 33 | - curl -sSL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - 34 | - /usr/local/go/bin/go install gotest.tools/gotestsum@latest 35 | script: 36 | - bash -x integration-tests/setup.sh 37 | - eval $(cat .env) 38 | - /usr/local/go/bin/go test -v ./integration-tests/... --tags integration -json | /root/go/bin/gotestsum --junitfile report.xml --format testname --raw-command -- cat 39 | artifacts: 40 | reports: 41 | junit: report.xml 42 | when: always 43 | -------------------------------------------------------------------------------- /.gitlab/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "packageRules": [ 4 | { 5 | "matchPackageNames": ["ubuntu"], 6 | "matchManagers": ["gitlabci"], 7 | "enabled": false 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | WORKDIR /build 3 | COPY . . 4 | ENV CGO_ENABLED=0 5 | RUN apk add --no-cache ca-certificates 6 | RUN go build -trimpath -ldflags '-s -w' -o /bin/operator main.go 7 | 8 | FROM scratch 9 | COPY --from=builder /bin/operator /operator 10 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 11 | ENTRYPOINT ["/operator"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bonsai OpenSource Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # External-DB-Operator 2 | 3 | The External DB Operator is a project that aims to simplify the management of out-of-cluster databases. 4 | It was created to address the following problems: 5 | - Out-of-cluster databases are not managed by kubernetes and therefore not part of the cluster lifecycle. 6 | - Manually managing database connection information does not provide a good base for automation. 7 | 8 | Operator Key Features: 9 | - Lifecycle management of dbms side databases, user accounts and database grants 10 | - Exposing database details as kubernetes secret 11 | - Support for multiple database providers (see [Supported Databases](#supported-databases)) 12 | 13 | ### Requirements 14 | 15 | * Kubernetes (tested with >= v1.28.3) 16 | * Admin user access to one of the supported databases (see [Supported Databases](#supported-databases)) 17 | 18 | ## Supported Databases 19 | 20 | The following database management systems are supported and tested. Compatible products should work as well but are not tested. \ 21 | Please submit an issue if you encounter any problems or have a feature request. 22 | 23 | | Database | Provider | Library | 24 | |---------------------------|------------|---------------------------------------------------------------| 25 | | PostgreSQL / CockroachDB | `postgres` | [pgx](https://github.com/jackc/pgx) | 26 | | MySQL / MariaDB / Percona | `mysql` | [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) | 27 | 28 | 29 | Support for other databases can be added by implementing the [Provider](internal/database/database.go) interface. 30 | 31 | ## Usage 32 | 33 | ### Getting Started 34 | 35 | The operator can be deployed to a cluster via the example [manifests](manifests) directory.\ 36 | First, create the necessary rbac and crd resources: 37 | 38 | ```shell 39 | kubectl apply -f manifests/rbac.yaml 40 | kubectl apply -f manifests/crd.yaml 41 | ``` 42 | 43 | Modify the [manifests/deployment.yaml](manifests/deployment.yaml) file to include the correct database dsn and provider.\ 44 | Then, deploy the operator: 45 | 46 | ```shell 47 | kubectl apply -f manifests/deployment.yaml 48 | ``` 49 | 50 | --- 51 | 52 | Once the operator is deployed to the cluster, it will start watching for `bonsai-oss.org/v1/database` resources in all namespaces. 53 | 54 | The name of the operator is specified via the `--instance-name` / `-i` flag and the used database provider in pattern `-`. An example for PostgreSQL would be `postgres-default`.\ 55 | That name is used to select the operator instance responsible for a specific database resource and can be specified via the `bonsai-oss.org/external-db-operator` label. See [manifests/test-database.yaml](manifests/test-database.yaml) for an example. 56 | 57 | After creating the database resources, the operator will create a secret containing the database connection details (database, host, port, username, password) in the same namespace as the database resource. 58 | It is named with the pattern `-` (e.a. `edb-your-database`). 59 | 60 | When adding additional annotations / labels to the database resource, the operator will pass them to the secret as well. 61 | 62 | ### Parameters 63 | 64 | | Parameter | Description | Default | 65 | |---------------------------------------------------|------------------------------------------------------------------------------------------------|------------------------------------------------------| 66 | | `-p`, `--database-provider`, `$DATABASE_PROVIDER` | Database provider to use. | postgres | 67 | | `-d`, `--database-dsn`, `$DATABASE_DSN` | The DSN to use for the database provider.
Check the specific database libaray for format. | postgres://postgres:postgres@localhost:5432/postgres | 68 | | `-i`, `--instance-name`, `$INSTANCE_NAME` | Name of the operator instance | default | 69 | | `-s`, `--secret-prefix`, `$SECRET_PREFIX` | Prefix for the secret name | edb | 70 | 71 | ### Endpoints 72 | 73 | The operator exposes the following endpoints on http port `8080`. 74 | 75 | | Endpoint | Description | 76 | |------------|-----------------------------------------------------------------------------------------------------------------------------------| 77 | | `/status` | Health check endpoint. Returns 200 if the operator is running and healthy.
Also, some information is exposed in JSON format. | 78 | | `/metrics` | Prometheus metrics endpoint. | 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module external-db-operator 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/alecthomas/kingpin/v2 v2.4.0 9 | github.com/go-sql-driver/mysql v1.9.1 10 | github.com/google/uuid v1.6.0 11 | github.com/hellofresh/health-go/v5 v5.5.3 12 | github.com/jackc/pgx/v5 v5.7.4 13 | github.com/prometheus/client_golang v1.21.1 14 | github.com/stretchr/testify v1.10.0 15 | k8s.io/api v0.32.0 16 | k8s.io/apimachinery v0.32.0 17 | k8s.io/client-go v0.32.0 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 // indirect 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 25 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 26 | github.com/emicklei/go-restful/v3 v3.12.0 // indirect 27 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 28 | github.com/go-logr/logr v1.4.2 // indirect 29 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 30 | github.com/go-openapi/jsonreference v0.21.0 // indirect 31 | github.com/go-openapi/swag v0.23.0 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang/protobuf v1.5.4 // indirect 34 | github.com/google/gnostic-models v0.6.8 // indirect 35 | github.com/google/go-cmp v0.6.0 // indirect 36 | github.com/google/gofuzz v1.2.0 // indirect 37 | github.com/jackc/pgpassfile v1.0.0 // indirect 38 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 39 | github.com/josharian/intern v1.0.0 // indirect 40 | github.com/json-iterator/go v1.1.12 // indirect 41 | github.com/klauspost/compress v1.17.11 // indirect 42 | github.com/mailru/easyjson v0.7.7 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.2 // indirect 45 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 46 | github.com/pkg/errors v0.9.1 // indirect 47 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 48 | github.com/prometheus/client_model v0.6.1 // indirect 49 | github.com/prometheus/common v0.62.0 // indirect 50 | github.com/prometheus/procfs v0.15.1 // indirect 51 | github.com/spf13/pflag v1.0.5 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 54 | go.opentelemetry.io/otel v1.25.0 // indirect 55 | go.opentelemetry.io/otel/trace v1.25.0 // indirect 56 | golang.org/x/crypto v0.31.0 // indirect 57 | golang.org/x/net v0.33.0 // indirect 58 | golang.org/x/oauth2 v0.24.0 // indirect 59 | golang.org/x/sys v0.28.0 // indirect 60 | golang.org/x/term v0.27.0 // indirect 61 | golang.org/x/text v0.21.0 // indirect 62 | golang.org/x/time v0.7.0 // indirect 63 | google.golang.org/protobuf v1.36.1 // indirect 64 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 65 | gopkg.in/inf.v0 v0.9.1 // indirect 66 | gopkg.in/yaml.v3 v3.0.1 // indirect 67 | k8s.io/klog/v2 v2.130.1 // indirect 68 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 69 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 70 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 71 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 72 | sigs.k8s.io/yaml v1.4.0 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 4 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 5 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= 6 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 7 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= 16 | github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 17 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 18 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 19 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 20 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 22 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 23 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 24 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 25 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 26 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 27 | github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= 28 | github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 29 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 30 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 31 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 32 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 33 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 34 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 35 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 36 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 37 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 38 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 39 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 40 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 41 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 42 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 43 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 44 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 45 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 46 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 47 | github.com/hellofresh/health-go/v5 v5.5.3 h1:i+mfJcA8te/QhBzrBZxOw344XgIvHrc9IQzrEyn3OUQ= 48 | github.com/hellofresh/health-go/v5 v5.5.3/go.mod h1:maWprKoK7N9zno7l2ubFEGVF2SDmTHq5D9sV+lCFmGs= 49 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 50 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 51 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 52 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 53 | github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= 54 | github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= 55 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 56 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 57 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 58 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 59 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 60 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 61 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 62 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 63 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 64 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 66 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 67 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 68 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 69 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 70 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 71 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 72 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 73 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 74 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 75 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 76 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 77 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 78 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 79 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 80 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 81 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 82 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 83 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 84 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 85 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 86 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 87 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 88 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 89 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 90 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 91 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 92 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 93 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 94 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 95 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 96 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 97 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 98 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 99 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 100 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 101 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 103 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 104 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 105 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 106 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 107 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 108 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 109 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 110 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 111 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 112 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 113 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 114 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 115 | go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= 116 | go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg= 117 | go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= 118 | go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= 119 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 120 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 121 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 122 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 123 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 124 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 125 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 126 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 127 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 128 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 129 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 130 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 131 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 132 | golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= 133 | golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 134 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 135 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 136 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 137 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 138 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 139 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 140 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 141 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 142 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 143 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 144 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 145 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 146 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 147 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 148 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 149 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 150 | golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= 151 | golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 152 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 153 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 154 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 155 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 156 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 157 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 158 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 159 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 160 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 161 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 162 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 163 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 164 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 165 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 166 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 167 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 168 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 169 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 170 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 171 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 172 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 173 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 174 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 175 | k8s.io/api v0.32.0 h1:OL9JpbvAU5ny9ga2fb24X8H6xQlVp+aJMFlgtQjR9CE= 176 | k8s.io/api v0.32.0/go.mod h1:4LEwHZEf6Q/cG96F3dqR965sYOfmPM7rq81BLgsE0p0= 177 | k8s.io/apimachinery v0.32.0 h1:cFSE7N3rmEEtv4ei5X6DaJPHHX0C+upp+v5lVPiEwpg= 178 | k8s.io/apimachinery v0.32.0/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= 179 | k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8= 180 | k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8= 181 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 182 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 183 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= 184 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= 185 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 186 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 187 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 188 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 189 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= 190 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= 191 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 192 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 193 | -------------------------------------------------------------------------------- /integration-tests/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package integration_tests 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/google/uuid" 14 | appsv1 "k8s.io/api/apps/v1" 15 | batchv1 "k8s.io/api/batch/v1" 16 | corev1 "k8s.io/api/core/v1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 19 | "k8s.io/apimachinery/pkg/runtime/schema" 20 | "k8s.io/apimachinery/pkg/util/intstr" 21 | "k8s.io/client-go/dynamic" 22 | "k8s.io/client-go/kubernetes" 23 | "k8s.io/client-go/tools/clientcmd" 24 | ) 25 | 26 | func kubectl(args ...string) *exec.Cmd { 27 | return exec.Command("kubectl", args...) 28 | } 29 | 30 | func mapIncludesKeys[T any](m map[string]T, keys ...string) bool { 31 | for _, key := range keys { 32 | if _, ok := m[key]; !ok { 33 | return false 34 | } 35 | } 36 | return true 37 | } 38 | 39 | func deployOperator(kubernetesApiClient *kubernetes.Clientset, name, provider, dsn string) error { 40 | operatorLabels := map[string]string{ 41 | "app.kubernetes.io/name": "external-db-operator", 42 | "app.kubernetes.io/instance": name, 43 | } 44 | 45 | image, operatorImageGiven := os.LookupEnv("OPERATOR_IMAGE") 46 | if !operatorImageGiven { 47 | image = "registry.gitlab.com/bonsai-oss/kubernetes/external-db-operator:latest" 48 | } 49 | 50 | deploymentConfiguration := &appsv1.Deployment{ 51 | ObjectMeta: metav1.ObjectMeta{ 52 | Name: "external-db-operator-" + name, 53 | }, 54 | Spec: appsv1.DeploymentSpec{ 55 | Selector: &metav1.LabelSelector{ 56 | MatchLabels: operatorLabels, 57 | }, 58 | Template: corev1.PodTemplateSpec{ 59 | ObjectMeta: metav1.ObjectMeta{ 60 | Labels: operatorLabels, 61 | }, 62 | Spec: corev1.PodSpec{ 63 | ServiceAccountName: "external-db-operator-sa", 64 | RestartPolicy: corev1.RestartPolicyAlways, 65 | InitContainers: []corev1.Container{ 66 | { 67 | Name: "wait-for-database", 68 | Image: "alpine:latest", 69 | Command: []string{ 70 | "sh", 71 | "-c", 72 | "until getent hosts " + name + ".databases.svc.cluster.local; do echo 'Waiting for database connection...' && sleep 1; done", 73 | }, 74 | }, 75 | }, 76 | Containers: []corev1.Container{ 77 | { 78 | Name: "external-db-operator", 79 | Image: image, 80 | ReadinessProbe: &corev1.Probe{ 81 | InitialDelaySeconds: 5, 82 | PeriodSeconds: 2, 83 | ProbeHandler: corev1.ProbeHandler{ 84 | HTTPGet: &corev1.HTTPGetAction{ 85 | Path: "/status", 86 | Port: intstr.FromInt32(8080), 87 | }, 88 | }, 89 | }, 90 | Env: []corev1.EnvVar{ 91 | { 92 | Name: "DATABASE_PROVIDER", 93 | Value: provider, 94 | }, 95 | { 96 | Name: "DATABASE_DSN", 97 | Value: dsn, 98 | }, 99 | { 100 | Name: "INSTANCE_NAME", 101 | Value: name, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | } 110 | 111 | // check if deployment already exists 112 | _, getDeploymentError := kubernetesApiClient.AppsV1().Deployments("default").Get(context.Background(), deploymentConfiguration.Name, metav1.GetOptions{}) 113 | if getDeploymentError == nil { 114 | // deployment already exists, delete it 115 | deleteDeploymentError := kubernetesApiClient.AppsV1().Deployments("default").Delete(context.Background(), deploymentConfiguration.Name, metav1.DeleteOptions{}) 116 | if deleteDeploymentError != nil { 117 | return deleteDeploymentError 118 | } 119 | } 120 | 121 | _, operatorDeployError := kubernetesApiClient.AppsV1().Deployments("default").Create(context.Background(), deploymentConfiguration, metav1.CreateOptions{}) 122 | 123 | return operatorDeployError 124 | } 125 | 126 | func TestDatabase(t *testing.T) { 127 | clientConfig, _ := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) 128 | kubernetesApiClient := kubernetes.NewForConfigOrDie(clientConfig) 129 | kubernetesDynamicClient := dynamic.NewForConfigOrDie(clientConfig) 130 | 131 | kubectl("apply", "-f", "../manifests/crd.yaml").Run() 132 | kubectl("apply", "-f", "../manifests/rbac.yaml").Run() 133 | 134 | databaseNamespace := "databases" 135 | kubectl("delete", "namespace", databaseNamespace).Run() 136 | kubectl("create", "namespace", databaseNamespace).Run() 137 | 138 | type check struct { 139 | packages []string 140 | command string 141 | } 142 | 143 | postgresProviderCheck := check{ 144 | packages: []string{"postgresql-client"}, 145 | command: "PGPASSWORD=$(cat /etc/db-credentials/password) psql -h $(cat /etc/db-credentials/host) -p $(cat /etc/db-credentials/port) -U $(cat /etc/db-credentials/username) -d $(cat /etc/db-credentials/database) -c 'SELECT 1'", 146 | } 147 | mysqlProviderCheck := check{ 148 | packages: []string{"default-mysql-client"}, 149 | command: "mysql --skip-ssl-verify-server-cert -h $(cat /etc/db-credentials/host) -P $(cat /etc/db-credentials/port) -u $(cat /etc/db-credentials/username) -p$(cat /etc/db-credentials/password) $(cat /etc/db-credentials/database) -e 'SELECT 1'", 150 | } 151 | 152 | for _, testCase := range []struct { 153 | name string 154 | provider string 155 | dsn string 156 | check check 157 | }{ 158 | { 159 | name: "postgres", 160 | provider: "postgres", 161 | dsn: "postgres://postgres:postgres@postgres.databases.svc.cluster.local:5432/postgres?sslmode=disable", 162 | check: postgresProviderCheck, 163 | }, 164 | { 165 | name: "cockroach", 166 | provider: "postgres", 167 | dsn: "postgres://postgres:postgres@cockroach.databases.svc.cluster.local:5432/postgres?sslmode=disable", 168 | check: postgresProviderCheck, 169 | }, 170 | { 171 | name: "mysql", 172 | provider: "mysql", 173 | dsn: "root:password@tcp(mysql.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", 174 | check: mysqlProviderCheck, 175 | }, 176 | { 177 | name: "mariadb", 178 | provider: "mysql", 179 | dsn: "root:password@tcp(mariadb.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", 180 | check: mysqlProviderCheck, 181 | }, 182 | { 183 | name: "percona", 184 | provider: "mysql", 185 | dsn: "root:password@tcp(percona.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", 186 | check: mysqlProviderCheck, 187 | }, 188 | } { 189 | t.Run(testCase.name, func(t *testing.T) { 190 | databaseResourceNamespace := "test-namespace-" + testCase.name 191 | kubectl("delete", "namespace", databaseResourceNamespace).Run() 192 | kubectl("create", "namespace", databaseResourceNamespace).Run() 193 | 194 | // Deploy test database 195 | t.Run("deploy test database", func(t *testing.T) { 196 | if deployTestDatabaseOutput, deployTestDatabaseError := kubectl("apply", "-n", databaseNamespace, "-f", "../manifests/databases/"+testCase.name+".yaml").CombinedOutput(); deployTestDatabaseError != nil { 197 | t.Errorf("failed to deploy test database: %v \n %v", deployTestDatabaseError, string(deployTestDatabaseOutput)) 198 | return 199 | } else { 200 | t.Log(string(deployTestDatabaseOutput)) 201 | } 202 | }) 203 | 204 | // Deploy operator 205 | t.Run("deploy operator", func(t *testing.T) { 206 | deployOperatorError := deployOperator(kubernetesApiClient, testCase.name, testCase.provider, testCase.dsn) 207 | if deployOperatorError != nil { 208 | t.Fatalf("failed to deploy operator: %v", deployOperatorError) 209 | } 210 | 211 | // Wait for operator to be ready 212 | if output, waitError := kubectl("wait", "--for=condition=Available", "--timeout=5m", "deployment/external-db-operator-"+testCase.name).CombinedOutput(); waitError != nil { 213 | t.Errorf("failed to wait for operator: %v \n %v", waitError, string(output)) 214 | return 215 | } else { 216 | t.Log(string(output)) 217 | } 218 | }) 219 | 220 | // Create test database 221 | _, createTestDatabaseError := kubernetesDynamicClient.Resource(schema.GroupVersionResource(metav1.GroupVersionResource{ 222 | Group: "bonsai-oss.org", 223 | Version: "v1", 224 | Resource: "databases", 225 | })).Namespace(databaseResourceNamespace).Create(context.Background(), &unstructured.Unstructured{ 226 | Object: map[string]interface{}{ 227 | "apiVersion": "bonsai-oss.org/v1", 228 | "kind": "Database", 229 | "metadata": map[string]interface{}{ 230 | "name": "demo", 231 | "labels": map[string]interface{}{ 232 | "bonsai-oss.org/external-db-operator": testCase.provider + "-" + testCase.name, 233 | }, 234 | "annotations": map[string]interface{}{ 235 | "testing": "true", 236 | }, 237 | }, 238 | }, 239 | }, metav1.CreateOptions{}) 240 | 241 | if createTestDatabaseError != nil { 242 | t.Errorf("failed to create test database: %v", createTestDatabaseError) 243 | return 244 | } 245 | 246 | time.Sleep(5 * time.Second) 247 | t.Run("check secret integrity", func(t *testing.T) { 248 | secret, secretGetError := kubernetesApiClient. 249 | CoreV1(). 250 | Secrets(databaseResourceNamespace). 251 | Get(context.Background(), "edb-demo", metav1.GetOptions{}) 252 | if secretGetError != nil { 253 | t.Errorf("failed to get secret: %v", secretGetError) 254 | return 255 | } 256 | 257 | if !mapIncludesKeys(secret.Data, "username", "password", "host", "port", "database") { 258 | t.Errorf("secret does not contain all required keys") 259 | return 260 | } 261 | 262 | // check if secret has annotation testing=true 263 | if secret.Annotations["testing"] != "true" { 264 | t.Errorf("secret does not have annotation testing=true") 265 | return 266 | } 267 | }) 268 | 269 | t.Run("check database integrity", func(t *testing.T) { 270 | _, jobError := kubernetesApiClient.BatchV1().Jobs(databaseResourceNamespace).Create(context.Background(), &batchv1.Job{ 271 | ObjectMeta: metav1.ObjectMeta{ 272 | Name: "db-integrity-check-" + uuid.NewString(), 273 | }, 274 | Spec: batchv1.JobSpec{ 275 | BackoffLimit: func() *int32 { i := int32(0); return &i }(), 276 | Template: corev1.PodTemplateSpec{ 277 | Spec: corev1.PodSpec{ 278 | RestartPolicy: corev1.RestartPolicyNever, 279 | Volumes: []corev1.Volume{ 280 | { 281 | Name: "db-credentials", 282 | VolumeSource: corev1.VolumeSource{ 283 | Secret: &corev1.SecretVolumeSource{ 284 | SecretName: "edb-demo", 285 | }, 286 | }, 287 | }, 288 | }, 289 | Containers: []corev1.Container{ 290 | { 291 | Name: "db-integrity-check", 292 | Image: "debian:sid", 293 | Command: []string{ 294 | "bash", 295 | "-xc", 296 | "(apt update && apt install -y " + strings.Join(testCase.check.packages, " ") + ") >/dev/null 2>&1 && " + testCase.check.command, 297 | }, 298 | VolumeMounts: []corev1.VolumeMount{ 299 | { 300 | Name: "db-credentials", 301 | MountPath: "/etc/db-credentials", 302 | }, 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | }, 309 | }, metav1.CreateOptions{}) 310 | if jobError != nil { 311 | t.Errorf("failed to create job: %v", jobError) 312 | return 313 | } 314 | 315 | // check for job completion 316 | watcher, _ := kubernetesApiClient.BatchV1().Jobs(databaseResourceNamespace).Watch(context.Background(), metav1.ListOptions{ 317 | Watch: true, 318 | }) 319 | defer watcher.Stop() 320 | 321 | for event := range watcher.ResultChan() { 322 | job, _ := event.Object.(*batchv1.Job) 323 | if len(job.Status.Conditions) == 0 { 324 | continue 325 | } 326 | 327 | switch job.Status.Conditions[0].Type { 328 | case batchv1.JobComplete, batchv1.JobSuccessCriteriaMet: 329 | return 330 | default: 331 | t.Errorf("job failed: %v", job.Status.Conditions[0].Message) 332 | return 333 | } 334 | } 335 | }) 336 | }) 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /integration-tests/run_local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | set -o xtrace 7 | 8 | # This script is used to run integration tests locally using minikube. 9 | 10 | command -v minikube >/dev/null 2>&1 || { echo >&2 "minikube is required but not installed. Aborting."; exit 1; } 11 | command -v docker >/dev/null 2>&1 || { echo >&2 "docker is required but not installed. Aborting."; exit 1; } 12 | command -v kubectl >/dev/null 2>&1 || { echo >&2 "kubectl is required but not installed. Aborting."; exit 1; } 13 | 14 | kubectl get nodes | grep minikube || { echo >&2 "minikube is not running / used. Aborting."; exit 1; } 15 | 16 | eval $(minikube docker-env) 17 | 18 | export OPERATOR_IMAGE="external-dns-operator:$(git rev-parse --short HEAD)" 19 | docker buildx build -t "${OPERATOR_IMAGE}" --squash . 20 | 21 | # clean go test cache 22 | go clean -testcache 23 | go test --tags integration -v ./integration-tests/... 24 | -------------------------------------------------------------------------------- /integration-tests/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "${CI}" ]; then 4 | echo "This script is only intended to be run in CI" 5 | exit 1 6 | fi 7 | 8 | # Setup K3s 9 | curl -sfL https://get.k3s.io | sh - 10 | curl -sSL https://get.docker.com | sh 11 | 12 | CONTAINER_IMAGE="${CI_REGISTRY_IMAGE}:$(git rev-parse --short HEAD)" 13 | docker build -t "${CONTAINER_IMAGE}" . || exit 1 14 | 15 | # Wait for K3s to be ready with kubectl; retry up to 60 seconds 16 | while :; do 17 | kubectl wait --for=condition=Ready node --all --timeout=60s && break 18 | sleep 10 19 | done 20 | 21 | # Export the image to the local k3s installation 22 | docker save "${CONTAINER_IMAGE}" | k3s ctr images import - 23 | sed -i "s|image: .*|image: ${CONTAINER_IMAGE}|g" ./manifests/deployment.yaml 24 | 25 | echo "export OPERATOR_IMAGE=${CONTAINER_IMAGE}" >> .env -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | type Type string 10 | 11 | type Provider interface { 12 | Initialize(dsn string) error 13 | Apply(options CreateOptions) error 14 | Destroy(options DestroyOptions) error 15 | GetConnectionInfo() (ConnectionInfo, error) 16 | HealthCheck(ctx context.Context) error 17 | io.Closer 18 | } 19 | 20 | type ConnectionInfo struct { 21 | Host string 22 | Port uint16 23 | } 24 | 25 | type CreateOptions struct { 26 | Name string 27 | Password string 28 | } 29 | 30 | type DestroyOptions struct { 31 | Name string 32 | } 33 | 34 | var registeredProviders = map[string]ProviderInitializer{} 35 | 36 | type ProviderInitializer func() Provider 37 | 38 | func RegisterProvider(name string, initializer ProviderInitializer) { 39 | registeredProviders[name] = initializer 40 | } 41 | 42 | func ListProviders() []string { 43 | var providerNames []string 44 | for name := range registeredProviders { 45 | providerNames = append(providerNames, name) 46 | } 47 | return providerNames 48 | } 49 | 50 | type ErrUnknownProvider struct { 51 | Name string 52 | } 53 | 54 | func (e ErrUnknownProvider) Error() string { 55 | return fmt.Sprintf("unknown provider: %s", e.Name) 56 | } 57 | 58 | func Provide(name string) (Provider, error) { 59 | providerInitializer, found := registeredProviders[name] 60 | if !found { 61 | return nil, ErrUnknownProvider{Name: name} 62 | } 63 | 64 | return providerInitializer(), nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/database/mysql/manage.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log/slog" 7 | "net" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/go-sql-driver/mysql" 12 | 13 | "external-db-operator/internal/database" 14 | ) 15 | 16 | func init() { 17 | database.RegisterProvider("mysql", Provide) 18 | } 19 | 20 | func Provide() database.Provider { 21 | return &Provider{} 22 | } 23 | 24 | type Provider struct { 25 | dbConnection *sql.DB 26 | dsn string 27 | } 28 | 29 | var _ database.Provider = &Provider{} 30 | 31 | func (p *Provider) Initialize(dsn string) error { 32 | db, dbInitialisationError := sql.Open("mysql", dsn) 33 | if dbInitialisationError != nil { 34 | return dbInitialisationError 35 | } 36 | 37 | db.SetConnMaxLifetime(time.Minute * 3) 38 | db.SetMaxOpenConns(10) 39 | db.SetMaxIdleConns(10) 40 | 41 | p.dbConnection = db 42 | p.dsn = dsn 43 | 44 | return nil 45 | } 46 | 47 | func (p *Provider) Apply(options database.CreateOptions) error { 48 | slog.Info("creating database", slog.String("name", options.Name)) 49 | _, databaseCreateError := p.dbConnection.Exec("CREATE DATABASE IF NOT EXISTS " + options.Name) 50 | if databaseCreateError != nil { 51 | return databaseCreateError 52 | } 53 | 54 | // check if user exists 55 | var userExists bool 56 | checkUserError := p.dbConnection.QueryRow("SELECT EXISTS(SELECT 1 FROM mysql.user WHERE user = ?)", options.Name).Scan(&userExists) 57 | if checkUserError != nil { 58 | return checkUserError 59 | } 60 | 61 | if userExists { 62 | slog.Info("alter user", slog.String("name", options.Name)) 63 | if _, alterUserError := p.dbConnection.Exec("ALTER USER " + options.Name + " IDENTIFIED BY '" + options.Password + "'"); alterUserError != nil { 64 | return alterUserError 65 | } 66 | } else { 67 | slog.Info("create user", slog.String("name", options.Name)) 68 | if _, createUserError := p.dbConnection.Exec("CREATE USER IF NOT EXISTS " + options.Name + " IDENTIFIED BY '" + options.Password + "'"); createUserError != nil { 69 | return createUserError 70 | } 71 | } 72 | 73 | slog.Info("apply database ownership", slog.String("name", options.Name)) 74 | if _, grantPrivileges := p.dbConnection.Exec("GRANT ALL PRIVILEGES ON " + options.Name + ".* TO '" + options.Name + "'"); grantPrivileges != nil { 75 | return grantPrivileges 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (p *Provider) Destroy(options database.DestroyOptions) error { 82 | slog.Info("destroying database", slog.String("name", options.Name)) 83 | _, dbDestroyError := p.dbConnection.Exec("DROP DATABASE IF EXISTS " + options.Name) 84 | if dbDestroyError != nil { 85 | return dbDestroyError 86 | } 87 | 88 | slog.Info("destroying user", slog.String("name", options.Name)) 89 | _, userDestroyError := p.dbConnection.Exec("DROP USER IF EXISTS " + options.Name) 90 | if userDestroyError != nil { 91 | return userDestroyError 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func (p *Provider) GetConnectionInfo() (database.ConnectionInfo, error) { 98 | config, dsnParseError := mysql.ParseDSN(p.dsn) 99 | if dsnParseError != nil { 100 | return database.ConnectionInfo{}, dsnParseError 101 | } 102 | 103 | host, port, splitAddressError := net.SplitHostPort(config.Addr) 104 | if splitAddressError != nil { 105 | return database.ConnectionInfo{}, splitAddressError 106 | } 107 | portInt, _ := strconv.Atoi(port) 108 | 109 | return database.ConnectionInfo{ 110 | Host: host, 111 | Port: uint16(portInt), 112 | }, nil 113 | } 114 | 115 | func (p *Provider) HealthCheck(ctx context.Context) error { 116 | return p.dbConnection.PingContext(ctx) 117 | } 118 | 119 | func (p *Provider) Close() error { 120 | return p.dbConnection.Close() 121 | } 122 | -------------------------------------------------------------------------------- /internal/database/mysql/manage_test.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "external-db-operator/internal/database" 9 | ) 10 | 11 | func TestProvider_GetDSN(t *testing.T) { 12 | for _, testCase := range []struct { 13 | name string 14 | input string 15 | expected database.ConnectionInfo 16 | }{ 17 | { 18 | name: "success", 19 | input: "username:password@protocol(mysql:3306)/dbname?param=value", 20 | expected: database.ConnectionInfo{Host: "mysql", Port: 3306}, 21 | }, 22 | } { 23 | t.Run(testCase.name, func(t *testing.T) { 24 | provider := Provider{ 25 | dsn: testCase.input, 26 | } 27 | 28 | actual, getConnectionInfoError := provider.GetConnectionInfo() 29 | assert.NoError(t, getConnectionInfoError) 30 | assert.Equal(t, testCase.expected, actual) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/database/postgres/manage.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/jackc/pgx/v5" 9 | 10 | "external-db-operator/internal/database" 11 | "external-db-operator/internal/helper" 12 | ) 13 | 14 | func init() { 15 | database.RegisterProvider("postgres", Provide) 16 | } 17 | 18 | func Provide() database.Provider { 19 | return &Provider{} 20 | } 21 | 22 | type Provider struct { 23 | dsn string 24 | dbConnection *pgx.Conn 25 | } 26 | 27 | var _ database.Provider = &Provider{} 28 | 29 | func (p *Provider) GetConnectionInfo() (database.ConnectionInfo, error) { 30 | conn, dsnParseError := pgx.ParseConfig(p.dsn) 31 | if dsnParseError != nil { 32 | return database.ConnectionInfo{}, dsnParseError 33 | } 34 | return database.ConnectionInfo{ 35 | Host: conn.Host, 36 | Port: conn.Port, 37 | }, nil 38 | } 39 | 40 | func (p *Provider) Apply(options database.CreateOptions) error { 41 | slog.Info("creating database", slog.String("name", options.Name)) 42 | _, createDatabaseError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("CREATE DATABASE %q", options.Name)) 43 | if createDatabaseError != nil && !helper.IsAlreadyExistsError(createDatabaseError) { 44 | return createDatabaseError 45 | } 46 | 47 | var userExists bool 48 | if checkUserError := p.dbConnection.QueryRow(context.Background(), "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", options.Name).Scan(&userExists); checkUserError != nil { 49 | return checkUserError 50 | } 51 | 52 | if userExists { 53 | slog.Info("alter user", slog.String("name", options.Name)) 54 | _, updateUserError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("ALTER USER %s WITH PASSWORD '%s'", options.Name, options.Password)) 55 | if updateUserError != nil { 56 | return updateUserError 57 | } 58 | } else { 59 | slog.Info("create user", slog.String("name", options.Name)) 60 | _, createUserError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s'", options.Name, options.Password)) 61 | if createUserError != nil { 62 | return createUserError 63 | } 64 | } 65 | 66 | slog.Info("apply database ownership", slog.String("name", options.Name)) 67 | _, grantUserError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", options.Name, options.Name)) 68 | if grantUserError != nil { 69 | return grantUserError 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (p *Provider) Destroy(options database.DestroyOptions) error { 76 | slog.Info("destroying database", slog.String("name", options.Name)) 77 | _, dropDatabaseError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("DROP DATABASE %q", options.Name)) 78 | if dropDatabaseError != nil && !helper.IsNotExistsError(dropDatabaseError) { 79 | return dropDatabaseError 80 | } 81 | slog.Info("destroying user", slog.String("name", options.Name)) 82 | _, dropUserError := p.dbConnection.Exec(context.Background(), fmt.Sprintf("DROP USER %q", options.Name)) 83 | if dropUserError != nil && !helper.IsNotExistsError(dropUserError) { 84 | return dropUserError 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (p *Provider) Initialize(dsn string) error { 91 | p.dsn = dsn 92 | dbConnection, databaseConnectionError := pgx.Connect(context.Background(), dsn) 93 | if databaseConnectionError != nil { 94 | return databaseConnectionError 95 | } 96 | p.dbConnection = dbConnection 97 | return nil 98 | } 99 | 100 | func (p *Provider) Close() error { 101 | return p.dbConnection.Close(context.Background()) 102 | } 103 | 104 | func (p *Provider) HealthCheck(ctx context.Context) error { 105 | return p.dbConnection.Ping(ctx) 106 | } 107 | -------------------------------------------------------------------------------- /internal/database/postgres/manage_test.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "external-db-operator/internal/database" 9 | ) 10 | 11 | func TestProvider_GetDSN(t *testing.T) { 12 | for _, testCase := range []struct { 13 | name string 14 | input string 15 | expected database.ConnectionInfo 16 | }{ 17 | { 18 | name: "success", 19 | input: "postgres://postgres:postgres@localhost:5432/postgres", 20 | expected: database.ConnectionInfo{Host: "localhost", Port: 5432}, 21 | }, 22 | { 23 | name: "success with password", 24 | input: "postgres://foo:bar@1.2.3.4:3040/postgres", 25 | expected: database.ConnectionInfo{Host: "1.2.3.4", Port: 3040}, 26 | }, 27 | } { 28 | t.Run(testCase.name, func(t *testing.T) { 29 | provider := Provider{ 30 | dsn: testCase.input, 31 | } 32 | 33 | actual, getConnectionInfoError := provider.GetConnectionInfo() 34 | assert.NoError(t, getConnectionInfoError) 35 | assert.Equal(t, testCase.expected, actual) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/helper/error.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func IsAlreadyExistsError(err error) bool { 8 | return err != nil && strings.Contains(err.Error(), "already exists") 9 | } 10 | 11 | func IsNotExistsError(err error) bool { 12 | return err != nil && strings.Contains(err.Error(), "does not exist") 13 | } 14 | -------------------------------------------------------------------------------- /internal/lifecycle/logic.go: -------------------------------------------------------------------------------- 1 | package lifecycle 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/google/uuid" 9 | corev1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/watch" 13 | 14 | "external-db-operator/internal/database" 15 | resourcesv1 "external-db-operator/internal/resources/v1" 16 | ) 17 | 18 | func (m *Manager) handleEvent(event watch.Event) error { 19 | databaseResourceData, convertError := resourcesv1.FromUnstructured(event.Object) 20 | if convertError != nil { 21 | return fmt.Errorf("failed to convert unstructured object: %w", convertError) 22 | } 23 | 24 | // TODO: handle GetConnectionInfo error 25 | connectionInfo, _ := m.clients.Database.GetConnectionInfo() 26 | 27 | secretData := &corev1.Secret{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: m.secretPrefix + "-" + databaseResourceData.Name, 30 | Annotations: databaseResourceData.Annotations, 31 | Labels: databaseResourceData.Labels, 32 | }, 33 | StringData: map[string]string{ 34 | "username": databaseResourceData.AssembleDatabaseName(), 35 | "password": uuid.NewString(), 36 | "host": connectionInfo.Host, 37 | "port": fmt.Sprintf("%d", connectionInfo.Port), 38 | "database": databaseResourceData.AssembleDatabaseName(), 39 | }, 40 | } 41 | 42 | existingSecret, getExistingSecretError := m.clients.Kubernetes.CoreV1().Secrets(databaseResourceData.Namespace).Get(context.Background(), secretData.Name, metav1.GetOptions{}) 43 | if getExistingSecretError != nil && !errors.IsNotFound(getExistingSecretError) { 44 | panic(getExistingSecretError.Error()) 45 | } 46 | 47 | if !errors.IsNotFound(getExistingSecretError) { 48 | // existingSecret.StringData is not populated by the Get() method 49 | secretData.StringData["password"] = string(existingSecret.Data["password"]) 50 | } 51 | 52 | var databaseActionError error 53 | switch event.Type { 54 | case watch.Modified: 55 | fallthrough 56 | case watch.Added: 57 | databaseActionError = m.clients.Database.Apply(database.CreateOptions{ 58 | Name: databaseResourceData.AssembleDatabaseName(), 59 | Password: secretData.StringData["password"], 60 | }) 61 | 62 | var secretError error 63 | if errors.IsNotFound(getExistingSecretError) { 64 | slog.Info("creating secret", slog.String("name", secretData.Name), slog.String("namespace", databaseResourceData.Namespace)) 65 | _, secretError = m.clients.Kubernetes.CoreV1().Secrets(databaseResourceData.Namespace).Create(context.Background(), secretData, metav1.CreateOptions{}) 66 | } else { 67 | slog.Info("updating secret", slog.String("name", secretData.Name), slog.String("namespace", databaseResourceData.Namespace)) 68 | _, secretError = m.clients.Kubernetes.CoreV1().Secrets(databaseResourceData.Namespace).Update(context.Background(), secretData, metav1.UpdateOptions{}) 69 | } 70 | if secretError != nil { 71 | panic(secretError.Error()) 72 | } 73 | case watch.Deleted: 74 | databaseActionError = m.clients.Database.Destroy(database.DestroyOptions{ 75 | Name: databaseResourceData.AssembleDatabaseName(), 76 | }) 77 | 78 | slog.Info("deleting secret", slog.String("name", secretData.Name), slog.String("namespace", databaseResourceData.Namespace)) 79 | secretDeleteError := m.clients.Kubernetes.CoreV1().Secrets(databaseResourceData.Namespace).Delete(context.Background(), secretData.Name, metav1.DeleteOptions{}) 80 | if secretDeleteError != nil && !errors.IsNotFound(secretDeleteError) { 81 | panic(secretDeleteError.Error()) 82 | } 83 | } 84 | if databaseActionError != nil { 85 | panic(databaseActionError.Error()) 86 | } 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/lifecycle/manager.go: -------------------------------------------------------------------------------- 1 | package lifecycle 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "k8s.io/apimachinery/pkg/watch" 10 | "k8s.io/client-go/dynamic" 11 | "k8s.io/client-go/kubernetes" 12 | 13 | "external-db-operator/internal/database" 14 | "external-db-operator/internal/metrics" 15 | ) 16 | 17 | type Manager struct { 18 | Events chan watch.Event 19 | clients Clients 20 | secretPrefix string 21 | } 22 | 23 | type Clients struct { 24 | Kubernetes *kubernetes.Clientset 25 | KubernetesDynamic *dynamic.DynamicClient 26 | Database database.Provider 27 | } 28 | 29 | func NewManager(clients Clients, secretPrefix string) *Manager { 30 | if clients.Kubernetes == nil { 31 | panic("kubernetes client is required") 32 | } 33 | if clients.KubernetesDynamic == nil { 34 | panic("kubernetes dynamic client is required") 35 | } 36 | if clients.Database == nil { 37 | panic("database provider is required") 38 | } 39 | if secretPrefix == "" { 40 | panic("secret prefix is required") 41 | } 42 | return &Manager{ 43 | Events: make(chan watch.Event), 44 | clients: clients, 45 | secretPrefix: secretPrefix, 46 | } 47 | } 48 | 49 | func (m *Manager) Run(ctx context.Context) { 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return 54 | case event := <-m.Events: 55 | start := time.Now() 56 | handlingError := m.handleEvent(event) 57 | if handlingError != nil { 58 | slog.Error("failed to handle event", slog.String("error", handlingError.Error())) 59 | } 60 | metrics.EventProcessing.With(prometheus.Labels{ 61 | "event_type": string(event.Type), 62 | }).Observe(time.Since(start).Seconds()) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/promauto" 6 | ) 7 | 8 | var ( 9 | EventProcessing = promauto.NewHistogramVec(prometheus.HistogramOpts{ 10 | Namespace: "external_db_operator", 11 | Name: "event_processing_duration_seconds", 12 | }, []string{"event_type"}) 13 | ) 14 | -------------------------------------------------------------------------------- /internal/resources/v1/database.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "regexp" 7 | 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | type Database struct { 12 | metav1.TypeMeta `json:",inline"` 13 | metav1.ObjectMeta `json:"metadata,omitempty"` 14 | 15 | Spec DatabaseSpec `json:"spec,omitempty"` 16 | } 17 | 18 | type DatabaseSpec struct{} 19 | 20 | func (d *Database) AssembleDatabaseName() string { 21 | return removeIllegalDatabaseCharacters(d.Namespace + "_" + d.Name) 22 | } 23 | 24 | func removeIllegalDatabaseCharacters(input string) string { 25 | return regexp.MustCompile("[.-]+").ReplaceAllString(input, "_") 26 | } 27 | 28 | func FromUnstructured(data any) (*Database, error) { 29 | buf := bytes.NewBuffer(nil) 30 | databaseResourceData := &Database{} 31 | if encodeError := json.NewEncoder(buf).Encode(data); encodeError != nil { 32 | return nil, encodeError 33 | } 34 | decodeError := json.NewDecoder(buf).Decode(databaseResourceData) 35 | 36 | return databaseResourceData, decodeError 37 | } 38 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/alecthomas/kingpin/v2" 13 | "github.com/hellofresh/health-go/v5" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/client-go/dynamic" 18 | "k8s.io/client-go/kubernetes" 19 | "k8s.io/client-go/rest" 20 | "k8s.io/client-go/tools/clientcmd" 21 | 22 | "external-db-operator/internal/database" 23 | _ "external-db-operator/internal/database/mysql" 24 | _ "external-db-operator/internal/database/postgres" 25 | "external-db-operator/internal/lifecycle" 26 | ) 27 | 28 | func mustParseSettings() Settings { 29 | var settings Settings 30 | app := kingpin.New(programName, "A Kubernetes operator for managing external databases.") 31 | app.HelpFlag.Short('h') 32 | 33 | app.Flag("database-provider", "The database provider to use."). 34 | Short('p'). 35 | Envar("DATABASE_PROVIDER"). 36 | Default("postgres"). 37 | EnumVar(&settings.DatabaseProvider, database.ListProviders()...) 38 | 39 | app.Flag("database-dsn", "The DSN to use for the database provider."). 40 | Short('d'). 41 | Envar("DATABASE_DSN"). 42 | Default("postgres://postgres:postgres@localhost:5432/postgres"). 43 | StringVar(&settings.DatabaseDsn) 44 | 45 | app.Flag("instance-name", "The name of the instance."). 46 | Short('i'). 47 | Envar("INSTANCE_NAME"). 48 | Default("default"). 49 | StringVar(&settings.InstanceName) 50 | 51 | app.Flag("secret-prefix", "The prefix to use for the secret names."). 52 | Short('s'). 53 | Envar("SECRET_PREFIX"). 54 | Default("edb"). 55 | StringVar(&settings.SecretPrefix) 56 | 57 | kingpin.MustParse(app.Parse(os.Args[1:])) 58 | 59 | return settings 60 | } 61 | 62 | func (app *Application) mustConfigureDatabaseProvider(settings Settings) { 63 | databaseBackend, providerError := database.Provide(settings.DatabaseProvider) 64 | if providerError != nil { 65 | slog.Error("failed to provide database backend", slog.String("error", providerError.Error())) 66 | os.Exit(1) 67 | } 68 | databaseInitializationError := databaseBackend.Initialize(settings.DatabaseDsn) 69 | if databaseInitializationError != nil { 70 | slog.Error("failed to initialize database backend", slog.String("error", databaseInitializationError.Error())) 71 | os.Exit(1) 72 | } 73 | 74 | app.Clients.Database = databaseBackend 75 | } 76 | 77 | func (app *Application) mustConfigureKubernetesClient() { 78 | var clientConfig *rest.Config 79 | var clientConfigError error 80 | if os.Getenv("KUBECONFIG") == "" { 81 | clientConfig, clientConfigError = rest.InClusterConfig() 82 | } else { 83 | clientConfig, clientConfigError = clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) 84 | } 85 | if clientConfigError != nil { 86 | panic(clientConfigError.Error()) 87 | } 88 | 89 | var clientConfigurationError error 90 | app.Clients.KubernetesDynamic, clientConfigurationError = dynamic.NewForConfig(clientConfig) 91 | app.Clients.Kubernetes, clientConfigurationError = kubernetes.NewForConfig(clientConfig) 92 | if clientConfigurationError != nil { 93 | slog.Error("failed to create Kubernetes client", slog.String("error", clientConfigurationError.Error())) 94 | os.Exit(1) 95 | } 96 | } 97 | 98 | func (app *Application) startSelfService(ctx context.Context) { 99 | healthCheck, _ := health.New( 100 | health.WithComponent(health.Component{Name: programName}), 101 | health.WithChecks(health.Config{ 102 | Name: "database", 103 | Check: app.Clients.Database.HealthCheck, 104 | })) 105 | 106 | healthCheckHandler := http.NewServeMux() 107 | healthCheckHandler.Handle("/status", healthCheck.Handler()) 108 | healthCheckHandler.Handle("/metrics", promhttp.Handler()) 109 | server := &http.Server{Addr: ":8080", Handler: healthCheckHandler} 110 | 111 | go func() { 112 | if err := server.ListenAndServe(); err != nil { 113 | ctx.Done() 114 | } 115 | }() 116 | defer server.Shutdown(ctx) 117 | 118 | <-ctx.Done() 119 | } 120 | 121 | func init() { 122 | _, isDebug := os.LookupEnv("DEBUG") 123 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 124 | AddSource: isDebug, 125 | Level: slog.LevelDebug, 126 | }))) 127 | } 128 | 129 | type Settings struct { 130 | DatabaseProvider string 131 | DatabaseDsn string 132 | InstanceName string 133 | SecretPrefix string 134 | } 135 | 136 | type Application struct { 137 | Clients lifecycle.Clients 138 | } 139 | 140 | const ( 141 | programName = "external-db-operator" 142 | // resourceLabelDifferentiator is used to differentiate between different instances of the operator. This needs to be set in the resource definition of the database objects. 143 | resourceLabelDifferentiator = "bonsai-oss.org/" + programName 144 | // maxEmptyEventsCount describes the maximum number of empty events to receive before terminating the operator. 145 | maxEmptyEventsCount = 10 146 | ) 147 | 148 | func main() { 149 | rootContext, cancelRootContext := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, os.Interrupt) 150 | defer cancelRootContext() 151 | 152 | settings := mustParseSettings() 153 | application := &Application{} 154 | application.mustConfigureDatabaseProvider(settings) 155 | defer application.Clients.Database.Close() 156 | application.mustConfigureKubernetesClient() 157 | go application.startSelfService(rootContext) 158 | 159 | slog.Info("checking database connection") 160 | if healthCheckError := application.Clients.Database.HealthCheck(rootContext); healthCheckError != nil { 161 | slog.Error("failed to connect to database", slog.String("error", healthCheckError.Error())) 162 | return 163 | } 164 | 165 | lifecycleManager := lifecycle.NewManager(application.Clients, settings.SecretPrefix) 166 | go lifecycleManager.Run(rootContext) 167 | 168 | labelSelectorValue := fmt.Sprintf("%s-%s", settings.DatabaseProvider, settings.InstanceName) 169 | slog.Info("watching resources with", slog.String(resourceLabelDifferentiator, labelSelectorValue)) 170 | 171 | for { 172 | select { 173 | case <-rootContext.Done(): 174 | return 175 | default: 176 | watcher, watchInitError := application.Clients.KubernetesDynamic.Resource(schema.GroupVersionResource(metav1.GroupVersionResource{ 177 | Group: "bonsai-oss.org", 178 | Version: "v1", 179 | Resource: "databases", 180 | })).Namespace("").Watch(rootContext, metav1.ListOptions{ 181 | Watch: true, 182 | LabelSelector: fmt.Sprintf("%s=%s", resourceLabelDifferentiator, labelSelectorValue), 183 | }) 184 | if watchInitError != nil { 185 | slog.Error("failed to initialize watch", slog.String("error", watchInitError.Error())) 186 | os.Exit(1) 187 | } 188 | 189 | eventProcessorError := func() error { 190 | for { 191 | select { 192 | case <-rootContext.Done(): 193 | return fmt.Errorf("received termination signal, shutting down") 194 | case event, ok := <-watcher.ResultChan(): 195 | if !ok { 196 | return fmt.Errorf("watcher closed unexpectedly") 197 | } 198 | lifecycleManager.Events <- event 199 | } 200 | } 201 | }() 202 | slog.Error("failed to process events", slog.String("error", eventProcessorError.Error())) 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /manifests/crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: databases.bonsai-oss.org 5 | spec: 6 | group: bonsai-oss.org 7 | versions: 8 | - name: v1 9 | served: true 10 | storage: true 11 | schema: 12 | openAPIV3Schema: 13 | type: object 14 | scope: Namespaced 15 | names: 16 | plural: databases 17 | singular: database 18 | kind: Database 19 | -------------------------------------------------------------------------------- /manifests/databases/cockroach.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: cockroach 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: cockroach 10 | template: 11 | metadata: 12 | labels: 13 | app: cockroach 14 | spec: 15 | volumes: 16 | - name: data 17 | emptyDir: {} 18 | containers: 19 | - name: cockroach 20 | image: cockroachdb/cockroach 21 | args: 22 | - start-single-node 23 | - --accept-sql-without-tls 24 | - --sql-addr=0.0.0.0:5432 25 | volumeMounts: 26 | - name: data 27 | mountPath: /cockroach/cockroach-data 28 | env: 29 | - name: COCKROACH_PASSWORD 30 | value: "postgres" 31 | - name: COCKROACH_DB 32 | value: "postgres" 33 | - name: COCKROACH_USER 34 | value: "postgres" 35 | --- 36 | apiVersion: v1 37 | kind: Service 38 | metadata: 39 | name: cockroach 40 | spec: 41 | selector: 42 | app: cockroach 43 | clusterIP: None 44 | ports: 45 | - name: cockroach 46 | port: 5432 47 | targetPort: 5432 48 | -------------------------------------------------------------------------------- /manifests/databases/mariadb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: mariadb 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: mariadb 10 | template: 11 | metadata: 12 | labels: 13 | app: mariadb 14 | spec: 15 | volumes: 16 | - name: data 17 | emptyDir: {} 18 | containers: 19 | - name: mariadb 20 | image: mariadb:latest 21 | volumeMounts: 22 | - name: data 23 | mountPath: /var/lib/mysql 24 | env: 25 | - name: MARIADB_ROOT_PASSWORD 26 | value: "password" 27 | --- 28 | apiVersion: v1 29 | kind: Service 30 | metadata: 31 | name: mariadb 32 | spec: 33 | selector: 34 | app: mariadb 35 | clusterIP: None 36 | ports: 37 | - name: mariadb 38 | port: 3306 39 | targetPort: 3306 40 | -------------------------------------------------------------------------------- /manifests/databases/mysql.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: mysql 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: mysql 10 | template: 11 | metadata: 12 | labels: 13 | app: mysql 14 | spec: 15 | volumes: 16 | - name: data 17 | emptyDir: {} 18 | containers: 19 | - name: mysql 20 | image: mysql:latest 21 | volumeMounts: 22 | - name: data 23 | mountPath: /var/lib/mysql 24 | env: 25 | - name: MYSQL_ROOT_PASSWORD 26 | value: "password" 27 | --- 28 | apiVersion: v1 29 | kind: Service 30 | metadata: 31 | name: mysql 32 | spec: 33 | selector: 34 | app: mysql 35 | clusterIP: None 36 | ports: 37 | - name: mysql 38 | port: 3306 39 | targetPort: 3306 40 | -------------------------------------------------------------------------------- /manifests/databases/percona.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: percona 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: percona 10 | template: 11 | metadata: 12 | labels: 13 | app: percona 14 | spec: 15 | volumes: 16 | - name: data 17 | emptyDir: {} 18 | containers: 19 | - name: percona 20 | image: percona/percona-server:latest 21 | volumeMounts: 22 | - name: data 23 | mountPath: /var/lib/mysql 24 | env: 25 | - name: MYSQL_ROOT_PASSWORD 26 | value: "password" 27 | --- 28 | apiVersion: v1 29 | kind: Service 30 | metadata: 31 | name: percona 32 | spec: 33 | selector: 34 | app: percona 35 | clusterIP: None 36 | ports: 37 | - name: percona 38 | port: 3306 39 | targetPort: 3306 40 | -------------------------------------------------------------------------------- /manifests/databases/postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: postgres 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: postgres 10 | template: 11 | metadata: 12 | labels: 13 | app: postgres 14 | spec: 15 | volumes: 16 | - name: data 17 | emptyDir: {} 18 | containers: 19 | - name: postgres 20 | image: postgres:latest 21 | volumeMounts: 22 | - name: data 23 | mountPath: /var/lib/postgresql/data 24 | env: 25 | - name: POSTGRES_PASSWORD 26 | value: "postgres" 27 | - name: POSTGRES_DB 28 | value: "postgres" 29 | - name: POSTGRES_USER 30 | value: "postgres" 31 | --- 32 | apiVersion: v1 33 | kind: Service 34 | metadata: 35 | name: postgres 36 | spec: 37 | selector: 38 | app: postgres 39 | clusterIP: None 40 | ports: 41 | - name: postgres 42 | port: 5432 43 | targetPort: 5432 44 | -------------------------------------------------------------------------------- /manifests/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: external-db-operator 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: external-db-operator 9 | template: 10 | metadata: 11 | annotations: 12 | prometheus.io/scrape: 'true' 13 | prometheus.io/port: '8080' 14 | labels: 15 | app: external-db-operator 16 | spec: 17 | serviceAccountName: external-db-operator-sa 18 | restartPolicy: Always 19 | containers: 20 | - name: external-db-operator 21 | image: registry.gitlab.com/bonsai-oss/kubernetes/external-db-operator:latest 22 | resources: 23 | limits: 24 | memory: 1Gi 25 | readinessProbe: 26 | httpGet: 27 | path: /status 28 | port: 8080 29 | initialDelaySeconds: 5 30 | periodSeconds: 5 31 | env: 32 | - name: DATABASE_DSN 33 | value: "postgres://postgres:password@postgres:5432/postgres?sslmode=disable" 34 | - name: DATABASE_PROVIDER 35 | value: "postgres" 36 | -------------------------------------------------------------------------------- /manifests/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: external-db-operator-sa 5 | --- 6 | apiVersion: rbac.authorization.k8s.io/v1 7 | kind: ClusterRole 8 | metadata: 9 | name: external-db-operator-role 10 | rules: 11 | - apiGroups: ["bonsai-oss.org"] 12 | resources: ["databases"] 13 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 14 | - apiGroups: [""] 15 | resources: ["secrets"] 16 | verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] 17 | --- 18 | apiVersion: rbac.authorization.k8s.io/v1 19 | kind: ClusterRoleBinding 20 | metadata: 21 | name: external-db-operator-rolebinding 22 | subjects: 23 | - kind: ServiceAccount 24 | name: external-db-operator-sa 25 | namespace: default 26 | roleRef: 27 | kind: ClusterRole 28 | name: external-db-operator-role 29 | apiGroup: rbac.authorization.k8s.io 30 | -------------------------------------------------------------------------------- /manifests/test-database.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: foo 6 | --- 7 | apiVersion: bonsai-oss.org/v1 8 | kind: Database 9 | metadata: 10 | name: demo 11 | namespace: foo 12 | labels: 13 | bonsai-oss.org/external-db-operator: postgres-default 14 | --------------------------------------------------------------------------------