├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── chart.yml │ ├── e2e-test.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── postgres_types.go │ ├── postgresuser_types.go │ └── zz_generated.deepcopy.go ├── charts └── ext-postgres-operator │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── crds │ ├── db.movetokube.com_postgres_crd.yaml │ └── db.movetokube.com_postgresusers_crd.yaml │ ├── templates │ ├── _helpers.tpl │ ├── clusterrole.yaml │ ├── clusterrole_binding.yaml │ ├── operator.yaml │ ├── role.yaml │ ├── role_binding.yaml │ ├── secret.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── cmd └── main.go ├── config ├── crd │ ├── bases │ │ ├── db.movetokube.com_postgres.yaml │ │ └── db.movetokube.com_postgresusers.yaml │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── default │ └── kustomization.yaml ├── manager │ ├── kustomization.yaml │ └── operator.yaml ├── manifests │ └── kustomization.yaml ├── namespace.yaml ├── rbac │ ├── cluster_role.yaml │ ├── cluster_role_binding.yaml │ ├── kustomization.yaml │ ├── role.yaml │ ├── role_binding.yaml │ └── service_account.yaml ├── samples │ ├── db_v1alpha1_postgres.yaml │ ├── db_v1alpha1_postgresuser.yaml │ └── kustomization.yaml └── secret.yaml ├── go.mod ├── go.sum ├── hack └── boilerplate.go.txt ├── internal └── controller │ ├── postgres_controller.go │ ├── postgres_controller_test.go │ ├── postgresuser_controller.go │ ├── postgresuser_controller_test.go │ └── suite_test.go ├── pkg ├── config │ └── config.go ├── postgres │ ├── aws.go │ ├── azure.go │ ├── database.go │ ├── gcp.go │ ├── mock │ │ └── postgres.go │ ├── postgres.go │ └── role.go └── utils │ ├── annotationfilter.go │ ├── annotationfilter_test.go │ ├── env.go │ ├── random.go │ ├── random_test.go │ ├── suite_test.go │ ├── template.go │ └── template_test.go └── tests ├── e2e └── basic-operations │ ├── 01-assert.yaml │ ├── 01-postgres.yaml │ ├── 02-assert.yaml │ ├── 02-postgresuser.yaml │ ├── 03-assert.yaml │ ├── 03-delete-postgresuser.yaml │ ├── 04-assert.yaml │ └── 04-delete-postgres.yaml └── kuttl-test-self-hosted-postgres.yaml /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [hitman99] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | target-branch: "master" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | labels: 10 | - dependencies 11 | - package-ecosystem: "docker" 12 | target-branch: "master" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | open-pull-requests-limit: 10 17 | labels: 18 | - dependencies 19 | - package-ecosystem: "gomod" 20 | target-branch: "master" 21 | directory: "/" 22 | schedule: 23 | interval: "weekly" 24 | open-pull-requests-limit: 10 25 | labels: 26 | - dependencies 27 | -------------------------------------------------------------------------------- /.github/workflows/chart.yml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - charts/** 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Configure Git 22 | run: | 23 | git config user.name "$GITHUB_ACTOR" 24 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 25 | 26 | - name: Install Helm 27 | uses: azure/setup-helm@v4 28 | with: 29 | version: v3.10.0 30 | 31 | - name: Run chart-releaser 32 | uses: helm/chart-releaser-action@v1.7.0 33 | with: 34 | charts_dir: charts 35 | env: 36 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 37 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: E2E Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | e2e: 13 | runs-on: ubuntu-latest 14 | name: End-to-End Test 15 | timeout-minutes: 30 # Add timeout to prevent hanging jobs 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v3 21 | - name: Build Docker image 22 | uses: docker/build-push-action@v6 23 | with: 24 | context: . 25 | push: false 26 | load: true 27 | tags: | 28 | postgres-operator:build 29 | - name: Install kubectl & krew 30 | uses: marcofranssen/setup-kubectl@v1.3.0 31 | with: 32 | enablePlugins: true 33 | - name: Install KUTTL 34 | run: kubectl krew install kuttl 35 | - name: Run tests 36 | run: kubectl kuttl test --config ./tests/kuttl-test-self-hosted-postgres.yaml 37 | - name: Upload test artifacts 38 | if: always() # Run even if tests fail 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: test-results 42 | path: tests/kind-logs-*/ 43 | retention-days: 7 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Operator 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | packages: write 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Generate Docker Tag 17 | run: | 18 | echo ${{ github.ref }} | cut -d '/' -f 3 > DOCKER_TAG 19 | echo "DOCKER_TAG=$(cat DOCKER_TAG)" >> $GITHUB_ENV 20 | 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v3 29 | with: 30 | username: ${{ secrets.DOCKER_USER }} 31 | password: ${{ secrets.DOCKER_TOKEN }} 32 | 33 | - name: Login to GitHub Container Registry 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | context: . 44 | platforms: linux/amd64,linux/arm64 45 | push: true 46 | tags: | 47 | ghcr.io/movetokube/postgres-operator:latest 48 | ghcr.io/movetokube/postgres-operator:${{ env.DOCKER_TAG }} 49 | movetokube/postgres-operator:${{ env.DOCKER_TAG }} 50 | movetokube/postgres-operator:latest 51 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | name: Go test 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.24.2" 20 | - run: | 21 | make test 22 | build: 23 | runs-on: ubuntu-latest 24 | name: Go build 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-go@v5 28 | with: 29 | go-version: "1.24.2" 30 | - run: | 31 | make build 32 | file bin/manager 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin/* 8 | Dockerfile.cross 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Go workspace file 17 | go.work 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | !vendor/**/zz_generated.* 21 | 22 | # editor and IDE paraphernalia 23 | .idea 24 | .vscode 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | #MacOS 30 | .DS_Store 31 | 32 | deploy/secret.yaml 33 | # kuttl/kind 34 | tests/kind-logs-*/ 35 | kubeconfig 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | You can contribute to this project by opening a PR to merge to `master`, or one of the `vX.X.X` branches. 4 | 5 | ## Branching 6 | 7 | `master` branch contains the latest source code with all the features. `vX.X.X` contains code for the specific major versions. 8 | i.e. `v0.4.x` contains the latest code for 0.4 version of the operator. See compatibility matrix below. 9 | 10 | ## Tests 11 | 12 | Please write tests and fix any broken tests before you open a PR. Tests should cover at least 80% of your code. 13 | 14 | ## e2e-tests 15 | 16 | End-to-end tests are implemented using [kuttl](https://kuttl.dev/), a Kubernetes test framework. To execute these tests locally, first install kuttl on your system, then run the command `make e2e` from the project root directory. 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.24 AS builder 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | WORKDIR /workspace 7 | # Copy the Go Modules manifests 8 | COPY go.mod go.mod 9 | COPY go.sum go.sum 10 | # cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN go mod download 13 | 14 | # Copy the go source 15 | COPY cmd/main.go cmd/main.go 16 | COPY api/ api/ 17 | COPY internal/controller/ internal/controller/ 18 | COPY pkg/ pkg/ 19 | 20 | # Build 21 | # the GOARCH has not a default value to allow the binary be built according to the host where the command 22 | # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO 23 | # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, 24 | # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. 25 | RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go 26 | 27 | # Use distroless as minimal base image to package the manager binary 28 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 29 | FROM gcr.io/distroless/static:nonroot 30 | WORKDIR / 31 | COPY --from=builder /workspace/manager . 32 | USER 65532:65532 33 | 34 | ENTRYPOINT ["/manager"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tomas 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # VERSION defines the project version for the bundle. 2 | # Update this value when you upgrade the version of your project. 3 | # To re-generate a bundle for another specific version without changing the standard setup, you can: 4 | # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) 5 | # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) 6 | VERSION ?= 0.0.1 7 | 8 | # CHANNELS define the bundle channels used in the bundle. 9 | # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") 10 | # To re-generate a bundle for other specific channels without changing the standard setup, you can: 11 | # - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable) 12 | # - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable") 13 | ifneq ($(origin CHANNELS), undefined) 14 | BUNDLE_CHANNELS := --channels=$(CHANNELS) 15 | endif 16 | 17 | # DEFAULT_CHANNEL defines the default channel used in the bundle. 18 | # Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") 19 | # To re-generate a bundle for any other default channel without changing the default setup, you can: 20 | # - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable) 21 | # - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable") 22 | ifneq ($(origin DEFAULT_CHANNEL), undefined) 23 | BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) 24 | endif 25 | BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) 26 | 27 | # IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. 28 | # This variable is used to construct full image tags for bundle and catalog images. 29 | # 30 | # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both 31 | # movetokube.com/postgres-operator-bundle:$VERSION and movetokube.com/postgres-operator-catalog:$VERSION. 32 | IMAGE_TAG_BASE ?= movetokube.com/postgres-operator 33 | 34 | # BUNDLE_IMG defines the image:tag used for the bundle. 35 | # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) 36 | BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) 37 | 38 | # BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command 39 | BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) 40 | 41 | # USE_IMAGE_DIGESTS defines if images are resolved via tags or digests 42 | # You can enable this value if you would like to use SHA Based Digests 43 | # To enable set flag to true 44 | USE_IMAGE_DIGESTS ?= false 45 | ifeq ($(USE_IMAGE_DIGESTS), true) 46 | BUNDLE_GEN_FLAGS += --use-image-digests 47 | endif 48 | 49 | # Set the Operator SDK version to use. By default, what is installed on the system is used. 50 | # This is useful for CI or a project to utilize a specific version of the operator-sdk toolkit. 51 | OPERATOR_SDK_VERSION ?= v1.39.2 52 | # Image URL to use all building/pushing image targets 53 | IMG ?= controller:latest 54 | # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. 55 | ENVTEST_K8S_VERSION = 1.31.0 56 | 57 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 58 | ifeq (,$(shell go env GOBIN)) 59 | GOBIN=$(shell go env GOPATH)/bin 60 | else 61 | GOBIN=$(shell go env GOBIN) 62 | endif 63 | 64 | # CONTAINER_TOOL defines the container tool to be used for building images. 65 | # Be aware that the target commands are only tested with Docker which is 66 | # scaffolded by default. However, you might want to replace it to use other 67 | # tools. (i.e. podman) 68 | CONTAINER_TOOL ?= docker 69 | 70 | # Setting SHELL to bash allows bash commands to be executed by recipes. 71 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 72 | SHELL = /usr/bin/env bash -o pipefail 73 | .SHELLFLAGS = -ec 74 | 75 | .PHONY: all 76 | all: build 77 | 78 | ##@ General 79 | 80 | # The help target prints out all targets with their descriptions organized 81 | # beneath their categories. The categories are represented by '##@' and the 82 | # target descriptions by '##'. The awk command is responsible for reading the 83 | # entire set of makefiles included in this invocation, looking for lines of the 84 | # file as xyz: ## something, and then pretty-format the target and help. Then, 85 | # if there's a line with ##@ something, that gets pretty-printed as a category. 86 | # More info on the usage of ANSI control characters for terminal formatting: 87 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 88 | # More info on the awk command: 89 | # http://linuxcommand.org/lc3_adv_awk.php 90 | 91 | .PHONY: help 92 | help: ## Display this help. 93 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 94 | 95 | ##@ Development 96 | 97 | .PHONY: manifests 98 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 99 | $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases 100 | 101 | .PHONY: generate 102 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 103 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 104 | 105 | .PHONY: fmt 106 | fmt: ## Run go fmt against code. 107 | go fmt ./... 108 | 109 | .PHONY: vet 110 | vet: ## Run go vet against code. 111 | go vet ./... 112 | 113 | .PHONY: test 114 | test: manifests generate fmt vet envtest ## Run tests. 115 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out 116 | 117 | ##@ Build 118 | 119 | .PHONY: build 120 | build: manifests generate fmt vet ## Build manager binary. 121 | go build -o bin/manager cmd/main.go 122 | 123 | .PHONY: run 124 | run: manifests generate fmt vet ## Run a controller from your host. 125 | go run ./cmd/main.go 126 | 127 | # If you wish to build the manager image targeting other platforms you can use the --platform flag. 128 | # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. 129 | # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 130 | .PHONY: docker-build 131 | docker-build: ## Build docker image with the manager. 132 | $(CONTAINER_TOOL) build -t ${IMG} . 133 | 134 | .PHONY: docker-push 135 | docker-push: ## Push docker image with the manager. 136 | $(CONTAINER_TOOL) push ${IMG} 137 | 138 | # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple 139 | # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: 140 | # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ 141 | # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ 142 | # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) 143 | # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. 144 | PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le 145 | .PHONY: docker-buildx 146 | docker-buildx: ## Build and push docker image for the manager for cross-platform support 147 | # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile 148 | sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross 149 | - $(CONTAINER_TOOL) buildx create --name postgres-operator-builder 150 | $(CONTAINER_TOOL) buildx use postgres-operator-builder 151 | - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . 152 | - $(CONTAINER_TOOL) buildx rm postgres-operator-builder 153 | rm Dockerfile.cross 154 | 155 | .PHONY: build-installer 156 | build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. 157 | mkdir -p dist 158 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 159 | $(KUSTOMIZE) build config/default > dist/install.yaml 160 | 161 | ##@ Deployment 162 | 163 | ifndef ignore-not-found 164 | ignore-not-found = false 165 | endif 166 | 167 | .PHONY: install 168 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 169 | $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - 170 | 171 | .PHONY: uninstall 172 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 173 | $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 174 | 175 | .PHONY: deploy 176 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 177 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 178 | $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - 179 | 180 | .PHONY: undeploy 181 | undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 182 | $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - 183 | 184 | ##@ Dependencies 185 | 186 | ## Location to install dependencies to 187 | LOCALBIN ?= $(shell pwd)/bin 188 | $(LOCALBIN): 189 | mkdir -p $(LOCALBIN) 190 | 191 | ## Tool Binaries 192 | KUBECTL ?= kubectl 193 | KUSTOMIZE ?= $(LOCALBIN)/kustomize 194 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen 195 | ENVTEST ?= $(LOCALBIN)/setup-envtest 196 | 197 | ## Tool Versions 198 | KUSTOMIZE_VERSION ?= v5.4.3 199 | CONTROLLER_TOOLS_VERSION ?= v0.16.1 200 | ENVTEST_VERSION ?= release-0.19 201 | 202 | .PHONY: kustomize 203 | kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. 204 | $(KUSTOMIZE): $(LOCALBIN) 205 | $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) 206 | 207 | .PHONY: controller-gen 208 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 209 | $(CONTROLLER_GEN): $(LOCALBIN) 210 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) 211 | 212 | .PHONY: envtest 213 | envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. 214 | $(ENVTEST): $(LOCALBIN) 215 | $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) 216 | 217 | # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist 218 | # $1 - target path with name of binary 219 | # $2 - package url which can be installed 220 | # $3 - specific version of package 221 | define go-install-tool 222 | @[ -f "$(1)-$(3)" ] || { \ 223 | set -e; \ 224 | package=$(2)@$(3) ;\ 225 | echo "Downloading $${package}" ;\ 226 | rm -f $(1) || true ;\ 227 | GOBIN=$(LOCALBIN) go install $${package} ;\ 228 | mv $(1) $(1)-$(3) ;\ 229 | } ;\ 230 | ln -sf $(1)-$(3) $(1) 231 | endef 232 | 233 | .PHONY: operator-sdk 234 | OPERATOR_SDK ?= $(LOCALBIN)/operator-sdk 235 | operator-sdk: ## Download operator-sdk locally if necessary. 236 | ifeq (,$(wildcard $(OPERATOR_SDK))) 237 | ifeq (, $(shell which operator-sdk 2>/dev/null)) 238 | @{ \ 239 | set -e ;\ 240 | mkdir -p $(dir $(OPERATOR_SDK)) ;\ 241 | OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ 242 | curl -sSLo $(OPERATOR_SDK) https://github.com/operator-framework/operator-sdk/releases/download/$(OPERATOR_SDK_VERSION)/operator-sdk_$${OS}_$${ARCH} ;\ 243 | chmod +x $(OPERATOR_SDK) ;\ 244 | } 245 | else 246 | OPERATOR_SDK = $(shell which operator-sdk) 247 | endif 248 | endif 249 | 250 | .PHONY: bundle 251 | bundle: manifests kustomize operator-sdk ## Generate bundle manifests and metadata, then validate generated files. 252 | $(OPERATOR_SDK) generate kustomize manifests -q 253 | cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) 254 | $(KUSTOMIZE) build config/manifests | $(OPERATOR_SDK) generate bundle $(BUNDLE_GEN_FLAGS) 255 | $(OPERATOR_SDK) bundle validate ./bundle 256 | 257 | .PHONY: bundle-build 258 | bundle-build: ## Build the bundle image. 259 | docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) . 260 | 261 | .PHONY: bundle-push 262 | bundle-push: ## Push the bundle image. 263 | $(MAKE) docker-push IMG=$(BUNDLE_IMG) 264 | 265 | .PHONY: opm 266 | OPM = $(LOCALBIN)/opm 267 | opm: ## Download opm locally if necessary. 268 | ifeq (,$(wildcard $(OPM))) 269 | ifeq (,$(shell which opm 2>/dev/null)) 270 | @{ \ 271 | set -e ;\ 272 | mkdir -p $(dir $(OPM)) ;\ 273 | OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ 274 | curl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.23.0/$${OS}-$${ARCH}-opm ;\ 275 | chmod +x $(OPM) ;\ 276 | } 277 | else 278 | OPM = $(shell which opm) 279 | endif 280 | endif 281 | 282 | # A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0). 283 | # These images MUST exist in a registry and be pull-able. 284 | BUNDLE_IMGS ?= $(BUNDLE_IMG) 285 | 286 | # The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0). 287 | CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION) 288 | 289 | # Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image. 290 | ifneq ($(origin CATALOG_BASE_IMG), undefined) 291 | FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG) 292 | endif 293 | 294 | # Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'. 295 | # This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see: 296 | # https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator 297 | .PHONY: catalog-build 298 | catalog-build: opm ## Build a catalog image. 299 | $(OPM) index add --container-tool docker --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT) 300 | 301 | # Push the catalog image. 302 | .PHONY: catalog-push 303 | catalog-push: ## Push a catalog image. 304 | $(MAKE) docker-push IMG=$(CATALOG_IMG) 305 | 306 | .PHONY: e2e 307 | e2e: 308 | kubectl kuttl test --config ./tests/kuttl-test-self-hosted-postgres.yaml 309 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | domain: movetokube.com 6 | layout: 7 | - go.kubebuilder.io/v4 8 | plugins: 9 | manifests.sdk.operatorframework.io/v2: {} 10 | scorecard.sdk.operatorframework.io/v2: {} 11 | projectName: postgres-operator 12 | repo: github.com/movetokube/postgres-operator 13 | resources: 14 | - api: 15 | crdVersion: v1 16 | namespaced: true 17 | controller: true 18 | domain: movetokube.com 19 | group: db 20 | kind: Postgres 21 | path: github.com/movetokube/postgres-operator/api/v1alpha1 22 | version: v1alpha1 23 | - api: 24 | crdVersion: v1 25 | namespaced: true 26 | controller: true 27 | domain: movetokube.com 28 | group: db 29 | kind: PostgresUser 30 | path: github.com/movetokube/postgres-operator/api/v1alpha1 31 | version: v1alpha1 32 | version: "3" 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # External PostgreSQL Server Operator for Kubernetes 2 | 3 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/ext-postgres-operator)](https://artifacthub.io/packages/search?repo=ext-postgres-operator) 4 | [![Sponsor](https://img.shields.io/badge/Sponsor_on_GitHub-ff69b4?style=for-the-badge&logo=github)](https://github.com/sponsors/hitman99) 5 | 6 | Manage external PostgreSQL databases in Kubernetes with ease—supporting AWS RDS, Azure Database for PostgreSQL, GCP Cloud SQL, and more. 7 | 8 | --- 9 | 10 | ## Table of Contents 11 | 12 | - [Sponsors](#sponsors) 13 | - [Features](#features) 14 | - [Supported Cloud Providers](#supported-cloud-providers) 15 | - [Configuration](#configuration) 16 | - [Installation](#installation) 17 | - [Custom Resources (CRs)](#custom-resources-crs) 18 | - [Multiple Operator Support](#multiple-operator-support) 19 | - [Secret Templating](#secret-templating) 20 | - [Compatibility](#compatibility) 21 | - [Contributing](#contributing) 22 | - [License](#license) 23 | 24 | --- 25 | 26 | ## Sponsors 27 | 28 | Please consider supporting this project! 29 | 30 | **Current Sponsors:** 31 | _None yet. [Become a sponsor!](https://github.com/sponsors/hitman99)_ 32 | 33 | ## Features 34 | 35 | - Create databases and roles using Kubernetes CRs 36 | - Automatic creation of randomized usernames and passwords 37 | - Supports multiple user roles per database 38 | - Auto-generates Kubernetes secrets with PostgreSQL connection URIs 39 | - Supports AWS RDS, Azure Database for PostgreSQL, and GCP Cloud SQL 40 | - Handles CRs in dynamically created namespaces 41 | - Customizable secret values using templates 42 | 43 | --- 44 | 45 | ## Supported Cloud Providers 46 | 47 | ### AWS 48 | 49 | Set `POSTGRES_CLOUD_PROVIDER` to `AWS` via environment variable, Kubernetes Secret, or deployment manifest (`operator.yaml`). 50 | 51 | ### Azure Database for PostgreSQL – Flexible Server 52 | 53 | > **Note:** Azure Single Server is deprecated as of v2.x. Only Flexible Server is supported. 54 | 55 | - `POSTGRES_CLOUD_PROVIDER=Azure` 56 | - `POSTGRES_DEFAULT_DATABASE=postgres` 57 | 58 | ### GCP 59 | 60 | - `POSTGRES_CLOUD_PROVIDER=GCP` 61 | - Configure a PostgreSQL connection secret 62 | - Manually create a Master role and reference it in your CRs 63 | - Master roles are never dropped by the operator 64 | 65 | ## Configuration 66 | 67 | Set environment variables in [`config/manager/operator.yaml`](config/manager/operator.yaml): 68 | 69 | | Name | Description | Default | 70 | | --- | --- | --- | 71 | | `WATCH_NAMESPACE` | Namespace to watch. Empty string = all namespaces. | (all namespaces) | 72 | | `POSTGRES_INSTANCE` | Operator identity for multi-instance deployments. | (empty) | 73 | | `KEEP_SECRET_NAME` | Use user-provided secret names instead of auto-generated ones. | disabled | 74 | 75 | > **Note:** 76 | > If enabling `KEEP_SECRET_NAME`, ensure there are no secret name conflicts in your namespace to avoid reconcile loops. 77 | 78 | ## Installation 79 | 80 | ### Install Using Helm (Recommended) 81 | 82 | The Helm chart for this operator is located in the `charts/ext-postgres-operator` subdirectory. Follow these steps to install: 83 | 84 | 1. Add the Helm repository: 85 | ```bash 86 | helm repo add ext-postgres-operator https://movetokube.github.io/postgres-operator/ 87 | ``` 88 | 89 | 2. Install the operator: 90 | ```bash 91 | helm install -n operators ext-postgres-operator ext-postgres-operator/ext-postgres-operator 92 | ``` 93 | 94 | 3. Customize the installation by modifying the values in [values.yaml](charts/ext-postgres-operator/values.yaml). 95 | 96 | ### Install Using Kustomize 97 | 98 | This operator requires a Kubernetes Secret to be created in the same namespace as the operator itself. 99 | The Secret should contain these keys: `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASS`, `POSTGRES_URI_ARGS`, `POSTGRES_CLOUD_PROVIDER`, `POSTGRES_DEFAULT_DATABASE`. 100 | 101 | Example: 102 | 103 | ```yaml 104 | apiVersion: v1 105 | kind: Secret 106 | metadata: 107 | name: ext-postgres-operator 108 | namespace: operators 109 | type: Opaque 110 | data: 111 | POSTGRES_HOST: cG9zdGdyZXM= 112 | POSTGRES_USER: cG9zdGdyZXM= 113 | POSTGRES_PASS: YWRtaW4= 114 | POSTGRES_URI_ARGS: IA== 115 | POSTGRES_CLOUD_PROVIDER: QVdT 116 | POSTGRES_DEFAULT_DATABASE: cG9zdGdyZXM= 117 | ``` 118 | 119 | To install the operator using Kustomize, follow these steps: 120 | 121 | 1. Configure Postgres credentials for the operator in `config/secret.yaml`. 122 | 123 | 2. Create the namespace if needed: 124 | ```bash 125 | kubectl apply -f config/namespace.yaml 126 | ``` 127 | 128 | 3. Apply the secret: 129 | ```bash 130 | kubectl apply -f deploy/secret.yaml 131 | ``` 132 | 133 | 4. Deploy the operator: 134 | ```bash 135 | kubectl kustomize config/default/ | kubectl apply -f - 136 | ``` 137 | 138 | Alternatively, use [Kustomize](https://github.com/kubernetes-sigs/kustomize) directly: 139 | ```bash 140 | kustomize build config/default/ | kubectl apply -f - 141 | ``` 142 | 143 | ## Custom Resources (CRs) 144 | 145 | ### Postgres 146 | 147 | ```yaml 148 | apiVersion: db.movetokube.com/v1alpha1 149 | kind: Postgres 150 | metadata: 151 | name: my-db 152 | namespace: app 153 | annotations: 154 | # OPTIONAL 155 | # use this to target which instance of operator should process this CR. See General config 156 | postgres.db.movetokube.com/instance: POSTGRES_INSTANCE 157 | spec: 158 | database: test-db # Name of database created in PostgreSQL 159 | dropOnDelete: false # Set to true if you want the operator to drop the database and role when this CR is deleted (optional) 160 | masterRole: test-db-group (optional) 161 | schemas: # List of schemas the operator should create in database (optional) 162 | - stores 163 | - customers 164 | extensions: # List of extensions that should be created in the database (optional) 165 | - fuzzystrmatch 166 | - pgcrypto 167 | ``` 168 | 169 | This creates a database called `test-db` and a role `test-db-group` that is set as the owner of the database. 170 | Reader and writer roles are also created. These roles have read and write permissions to all tables in the schemas created by the operator, if any. 171 | 172 | ### PostgresUser 173 | 174 | ```yaml 175 | apiVersion: db.movetokube.com/v1alpha1 176 | kind: PostgresUser 177 | metadata: 178 | name: my-db-user 179 | namespace: app 180 | annotations: 181 | # OPTIONAL 182 | # use this to target which instance of operator should process this CR. See general config 183 | postgres.db.movetokube.com/instance: POSTGRES_INSTANCE 184 | spec: 185 | role: username 186 | database: my-db # This references the Postgres CR 187 | secretName: my-secret 188 | privileges: OWNER # Can be OWNER/READ/WRITE 189 | annotations: # Annotations to be propagated to the secrets metadata section (optional) 190 | foo: "bar" 191 | labels: 192 | foo: "bar" # Labels to be propagated to the secrets metadata section (optional) 193 | secretTemplate: # Output secrets can be customized using standard Go templates 194 | PQ_URL: "host={{.Host}} user={{.Role}} password={{.Password}} dbname={{.Database}}" 195 | ``` 196 | 197 | This creates a user role `username-` and grants role `test-db-group`, `test-db-writer` or `test-db-reader` depending on `privileges` property. Its credentials are put in secret `my-secret-my-db-user` (unless `KEEP_SECRET_NAME` is enabled). 198 | 199 | `PostgresUser` needs to reference a `Postgres` in the same namespace. 200 | 201 | Two `Postgres` referencing the same database can exist in more than one namespace. The last CR referencing a database will drop the group role and transfer database ownership to the role used by the operator. 202 | Every PostgresUser has a generated Kubernetes secret attached to it, which contains the following data (i.e.): 203 | 204 | | Key | Comment | 205 | |----------------------|---------------------| 206 | | `DATABASE_NAME` | Name of the database, same as in `Postgres` CR, copied for convenience | 207 | | `HOST` | PostgreSQL server host | 208 | | `PASSWORD` | Autogenerated password for user | 209 | | `ROLE` | Autogenerated role with login enabled (user) | 210 | | `LOGIN` | Same as `ROLE`. In case `POSTGRES_CLOUD_PROVIDER` is set to "Azure", `LOGIN` it will be set to `{role}@{serverName}`, serverName is extracted from `POSTGRES_USER` from operator's config. | 211 | | `POSTGRES_URL` | Connection string for Posgres, could be used for Go applications | 212 | | `POSTGRES_JDBC_URL` | JDBC compatible Postgres URI, formatter as `jdbc:postgresql://{POSTGRES_HOST}/{DATABASE_NAME}` | 213 | 214 | ### Multiple operator support 215 | 216 | Run multiple operator instances by setting unique POSTGRES_INSTANCE values and using annotations in your CRs to assign them. 217 | 218 | #### Annotations Use Case 219 | 220 | With the help of annotations it is possible to create annotation-based copies of secrets in other namespaces. 221 | 222 | For more information and an example, see [kubernetes-replicator#pull-based-replication](https://github.com/mittwald/kubernetes-replicator#pull-based-replication) 223 | 224 | ### Secret Templating 225 | 226 | Users can specify the structure and content of secrets based on their unique requirements using standard 227 | [Go templates](https://pkg.go.dev/text/template#hdr-Actions). This flexibility allows for a more tailored approach to 228 | meeting the specific needs of different applications. 229 | 230 | Available context: 231 | 232 | | Variable | Meaning | 233 | |-------------|--------------------------| 234 | | `.Host` | Database host | 235 | | `.Role` | Generated user/role name | 236 | | `.Database` | Referenced database name | 237 | | `.Password` | Generated role password | 238 | 239 | ### Compatibility 240 | 241 | Postgres operator uses Operator SDK, which uses kubernetes client. Kubernetes client compatibility with Kubernetes cluster 242 | can be found [here](https://github.com/kubernetes/client-go/blob/master/README.md#compatibility-matrix) 243 | 244 | Postgres operator compatibility with Operator SDK version is in the table below 245 | 246 | | | Operator SDK version | apiextensions.k8s.io | 247 | |---------------------------|----------------------|----------------------| 248 | | `postgres-operator 0.4.x` | v0.17 | v1beta1 | 249 | | `postgres-operator 1.x.x` | v0.18 | v1 | 250 | | `postgres-operator 2.x.x` | v1.39 | v1 | 251 | | `HEAD` | v1.39 | v1 | 252 | 253 | 254 | ## Contributing 255 | 256 | See [CONTRIBUTING.md](CONTRIBUTING.md) 257 | 258 | ## License 259 | 260 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 261 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 contains API Schema definitions for the db v1alpha1 API group 2 | // +kubebuilder:object:generate=true 3 | // +groupName=db.movetokube.com 4 | package v1alpha1 5 | 6 | import ( 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | var ( 12 | // GroupVersion is group version used to register these objects 13 | GroupVersion = schema.GroupVersion{Group: "db.movetokube.com", Version: "v1alpha1"} 14 | 15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 17 | 18 | // AddToScheme adds the types in this group-version to the given scheme. 19 | AddToScheme = SchemeBuilder.AddToScheme 20 | ) 21 | -------------------------------------------------------------------------------- /api/v1alpha1/postgres_types.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 8 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 9 | 10 | // PostgresSpec defines the desired state of Postgres 11 | type PostgresSpec struct { 12 | Database string `json:"database"` 13 | // +optional 14 | MasterRole string `json:"masterRole,omitempty"` 15 | // +optional 16 | DropOnDelete bool `json:"dropOnDelete,omitempty"` 17 | // +optional 18 | // +listType=set 19 | Schemas []string `json:"schemas,omitempty"` 20 | // +optional 21 | // +listType=set 22 | Extensions []string `json:"extensions,omitempty"` 23 | } 24 | 25 | // PostgresStatus defines the observed state of Postgres 26 | type PostgresStatus struct { 27 | Succeeded bool `json:"succeeded"` 28 | Roles PostgresRoles `json:"roles"` 29 | // +optional 30 | // +listType=set 31 | Schemas []string `json:"schemas,omitempty"` 32 | // +optional 33 | // +listType=set 34 | Extensions []string `json:"extensions,omitempty"` 35 | } 36 | 37 | // PostgresRoles stores the different group roles for database 38 | type PostgresRoles struct { 39 | Owner string `json:"owner"` 40 | Reader string `json:"reader"` 41 | Writer string `json:"writer"` 42 | } 43 | 44 | // +kubebuilder:object:root=true 45 | // +kubebuilder:subresource:status 46 | // +kubebuilder:resource:scope=Namespaced 47 | 48 | // Postgres is the Schema for the postgres API 49 | type Postgres struct { 50 | metav1.TypeMeta `json:",inline"` 51 | metav1.ObjectMeta `json:"metadata,omitempty"` 52 | 53 | Spec PostgresSpec `json:"spec,omitempty"` 54 | Status PostgresStatus `json:"status,omitempty"` 55 | } 56 | 57 | // +kubebuilder:object:root=true 58 | 59 | // PostgresList contains a list of Postgres 60 | type PostgresList struct { 61 | metav1.TypeMeta `json:",inline"` 62 | metav1.ListMeta `json:"metadata,omitempty"` 63 | Items []Postgres `json:"items"` 64 | } 65 | 66 | func init() { 67 | SchemeBuilder.Register(&Postgres{}, &PostgresList{}) 68 | } 69 | -------------------------------------------------------------------------------- /api/v1alpha1/postgresuser_types.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 8 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 9 | 10 | // PostgresUserSpec defines the desired state of PostgresUser 11 | type PostgresUserSpec struct { 12 | Role string `json:"role"` 13 | Database string `json:"database"` 14 | SecretName string `json:"secretName"` 15 | // +optional 16 | SecretTemplate map[string]string `json:"secretTemplate,omitempty"` // key-value, where key is secret field, value is go template 17 | // +optional 18 | Privileges string `json:"privileges"` 19 | // +optional 20 | Annotations map[string]string `json:"annotations,omitempty"` 21 | // +optional 22 | Labels map[string]string `json:"labels,omitempty"` 23 | } 24 | 25 | // PostgresUserStatus defines the observed state of PostgresUser 26 | type PostgresUserStatus struct { 27 | Succeeded bool `json:"succeeded"` 28 | PostgresRole string `json:"postgresRole"` 29 | PostgresLogin string `json:"postgresLogin"` 30 | PostgresGroup string `json:"postgresGroup"` 31 | DatabaseName string `json:"databaseName"` 32 | } 33 | 34 | // +kubebuilder:object:root=true 35 | // +kubebuilder:subresource:status 36 | // +kubebuilder:resource:scope=Namespaced 37 | 38 | // PostgresUser is the Schema for the postgresusers API 39 | type PostgresUser struct { 40 | metav1.TypeMeta `json:",inline"` 41 | metav1.ObjectMeta `json:"metadata,omitempty"` 42 | 43 | Spec PostgresUserSpec `json:"spec,omitempty"` 44 | Status PostgresUserStatus `json:"status,omitempty"` 45 | } 46 | 47 | // +kubebuilder:object:root=true 48 | 49 | // PostgresUserList contains a list of PostgresUser 50 | type PostgresUserList struct { 51 | metav1.TypeMeta `json:",inline"` 52 | metav1.ListMeta `json:"metadata,omitempty"` 53 | Items []PostgresUser `json:"items"` 54 | } 55 | 56 | func init() { 57 | SchemeBuilder.Register(&PostgresUser{}, &PostgresUserList{}) 58 | } 59 | -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1alpha1 6 | 7 | import ( 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | ) 10 | 11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 12 | func (in *Postgres) DeepCopyInto(out *Postgres) { 13 | *out = *in 14 | out.TypeMeta = in.TypeMeta 15 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 16 | in.Spec.DeepCopyInto(&out.Spec) 17 | in.Status.DeepCopyInto(&out.Status) 18 | } 19 | 20 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Postgres. 21 | func (in *Postgres) DeepCopy() *Postgres { 22 | if in == nil { 23 | return nil 24 | } 25 | out := new(Postgres) 26 | in.DeepCopyInto(out) 27 | return out 28 | } 29 | 30 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 31 | func (in *Postgres) DeepCopyObject() runtime.Object { 32 | if c := in.DeepCopy(); c != nil { 33 | return c 34 | } 35 | return nil 36 | } 37 | 38 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 39 | func (in *PostgresList) DeepCopyInto(out *PostgresList) { 40 | *out = *in 41 | out.TypeMeta = in.TypeMeta 42 | in.ListMeta.DeepCopyInto(&out.ListMeta) 43 | if in.Items != nil { 44 | in, out := &in.Items, &out.Items 45 | *out = make([]Postgres, len(*in)) 46 | for i := range *in { 47 | (*in)[i].DeepCopyInto(&(*out)[i]) 48 | } 49 | } 50 | } 51 | 52 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresList. 53 | func (in *PostgresList) DeepCopy() *PostgresList { 54 | if in == nil { 55 | return nil 56 | } 57 | out := new(PostgresList) 58 | in.DeepCopyInto(out) 59 | return out 60 | } 61 | 62 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 63 | func (in *PostgresList) DeepCopyObject() runtime.Object { 64 | if c := in.DeepCopy(); c != nil { 65 | return c 66 | } 67 | return nil 68 | } 69 | 70 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 71 | func (in *PostgresRoles) DeepCopyInto(out *PostgresRoles) { 72 | *out = *in 73 | } 74 | 75 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresRoles. 76 | func (in *PostgresRoles) DeepCopy() *PostgresRoles { 77 | if in == nil { 78 | return nil 79 | } 80 | out := new(PostgresRoles) 81 | in.DeepCopyInto(out) 82 | return out 83 | } 84 | 85 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 86 | func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) { 87 | *out = *in 88 | if in.Schemas != nil { 89 | in, out := &in.Schemas, &out.Schemas 90 | *out = make([]string, len(*in)) 91 | copy(*out, *in) 92 | } 93 | if in.Extensions != nil { 94 | in, out := &in.Extensions, &out.Extensions 95 | *out = make([]string, len(*in)) 96 | copy(*out, *in) 97 | } 98 | } 99 | 100 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresSpec. 101 | func (in *PostgresSpec) DeepCopy() *PostgresSpec { 102 | if in == nil { 103 | return nil 104 | } 105 | out := new(PostgresSpec) 106 | in.DeepCopyInto(out) 107 | return out 108 | } 109 | 110 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 111 | func (in *PostgresStatus) DeepCopyInto(out *PostgresStatus) { 112 | *out = *in 113 | out.Roles = in.Roles 114 | if in.Schemas != nil { 115 | in, out := &in.Schemas, &out.Schemas 116 | *out = make([]string, len(*in)) 117 | copy(*out, *in) 118 | } 119 | if in.Extensions != nil { 120 | in, out := &in.Extensions, &out.Extensions 121 | *out = make([]string, len(*in)) 122 | copy(*out, *in) 123 | } 124 | } 125 | 126 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresStatus. 127 | func (in *PostgresStatus) DeepCopy() *PostgresStatus { 128 | if in == nil { 129 | return nil 130 | } 131 | out := new(PostgresStatus) 132 | in.DeepCopyInto(out) 133 | return out 134 | } 135 | 136 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 137 | func (in *PostgresUser) DeepCopyInto(out *PostgresUser) { 138 | *out = *in 139 | out.TypeMeta = in.TypeMeta 140 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 141 | in.Spec.DeepCopyInto(&out.Spec) 142 | out.Status = in.Status 143 | } 144 | 145 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUser. 146 | func (in *PostgresUser) DeepCopy() *PostgresUser { 147 | if in == nil { 148 | return nil 149 | } 150 | out := new(PostgresUser) 151 | in.DeepCopyInto(out) 152 | return out 153 | } 154 | 155 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 156 | func (in *PostgresUser) DeepCopyObject() runtime.Object { 157 | if c := in.DeepCopy(); c != nil { 158 | return c 159 | } 160 | return nil 161 | } 162 | 163 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 164 | func (in *PostgresUserList) DeepCopyInto(out *PostgresUserList) { 165 | *out = *in 166 | out.TypeMeta = in.TypeMeta 167 | in.ListMeta.DeepCopyInto(&out.ListMeta) 168 | if in.Items != nil { 169 | in, out := &in.Items, &out.Items 170 | *out = make([]PostgresUser, len(*in)) 171 | for i := range *in { 172 | (*in)[i].DeepCopyInto(&(*out)[i]) 173 | } 174 | } 175 | } 176 | 177 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserList. 178 | func (in *PostgresUserList) DeepCopy() *PostgresUserList { 179 | if in == nil { 180 | return nil 181 | } 182 | out := new(PostgresUserList) 183 | in.DeepCopyInto(out) 184 | return out 185 | } 186 | 187 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 188 | func (in *PostgresUserList) DeepCopyObject() runtime.Object { 189 | if c := in.DeepCopy(); c != nil { 190 | return c 191 | } 192 | return nil 193 | } 194 | 195 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 196 | func (in *PostgresUserSpec) DeepCopyInto(out *PostgresUserSpec) { 197 | *out = *in 198 | if in.SecretTemplate != nil { 199 | in, out := &in.SecretTemplate, &out.SecretTemplate 200 | *out = make(map[string]string, len(*in)) 201 | for key, val := range *in { 202 | (*out)[key] = val 203 | } 204 | } 205 | if in.Annotations != nil { 206 | in, out := &in.Annotations, &out.Annotations 207 | *out = make(map[string]string, len(*in)) 208 | for key, val := range *in { 209 | (*out)[key] = val 210 | } 211 | } 212 | if in.Labels != nil { 213 | in, out := &in.Labels, &out.Labels 214 | *out = make(map[string]string, len(*in)) 215 | for key, val := range *in { 216 | (*out)[key] = val 217 | } 218 | } 219 | } 220 | 221 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserSpec. 222 | func (in *PostgresUserSpec) DeepCopy() *PostgresUserSpec { 223 | if in == nil { 224 | return nil 225 | } 226 | out := new(PostgresUserSpec) 227 | in.DeepCopyInto(out) 228 | return out 229 | } 230 | 231 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 232 | func (in *PostgresUserStatus) DeepCopyInto(out *PostgresUserStatus) { 233 | *out = *in 234 | } 235 | 236 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserStatus. 237 | func (in *PostgresUserStatus) DeepCopy() *PostgresUserStatus { 238 | if in == nil { 239 | return nil 240 | } 241 | out := new(PostgresUserStatus) 242 | in.DeepCopyInto(out) 243 | return out 244 | } 245 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: ext-postgres-operator 3 | description: | 4 | A Helm chart for the External Postgres operator 5 | 6 | helm repo add ext-postgres-operator https://movetokube.github.io/postgres-operator/ 7 | helm upgrade --install -n operators ext-postgres-operator ext-postgres-operator/ext-postgres-operator 8 | 9 | type: application 10 | 11 | version: 2.0.0 12 | appVersion: "2.0.0" 13 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/README.md: -------------------------------------------------------------------------------- 1 | # ext-postgres-operator Helm Chart 2 | 3 | This Helm chart deploys the External Postgres Operator, which provides a way to manage PostgreSQL databases and users in a Kubernetes environment. 4 | 5 | ## Installation 6 | 7 | To install the chart, add the repository and use the `helm upgrade --install` command: 8 | 9 | ```bash 10 | helm repo add ext-postgres-operator https://movetokube.github.io/postgres-operator/ 11 | helm upgrade --install -n operators ext-postgres-operator ext-postgres-operator/ext-postgres-operator 12 | ``` 13 | 14 | ## Compatibility 15 | 16 | **NOTE:** Helm chart version `2.0.0` is only compatible with the Postgres Operator version `2.0.0`. Ensure that you are using the correct versions to avoid compatibility issues. 17 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/crds/db.movetokube.com_postgres_crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: postgres.db.movetokube.com 5 | spec: 6 | group: db.movetokube.com 7 | names: 8 | kind: Postgres 9 | listKind: PostgresList 10 | plural: postgres 11 | singular: postgres 12 | scope: Namespaced 13 | versions: 14 | - name: v1alpha1 15 | schema: 16 | openAPIV3Schema: 17 | description: Postgres is the Schema for the postgres API 18 | properties: 19 | apiVersion: 20 | description: 'APIVersion defines the versioned schema of this representation 21 | of an object. Servers should convert recognized schemas to the latest 22 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 23 | type: string 24 | kind: 25 | description: 'Kind is a string value representing the REST resource this 26 | object represents. Servers may infer this from the endpoint the client 27 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 28 | type: string 29 | metadata: 30 | type: object 31 | spec: 32 | description: PostgresSpec defines the desired state of Postgres 33 | properties: 34 | database: 35 | type: string 36 | dropOnDelete: 37 | type: boolean 38 | extensions: 39 | items: 40 | type: string 41 | type: array 42 | x-kubernetes-list-type: set 43 | masterRole: 44 | type: string 45 | schemas: 46 | items: 47 | type: string 48 | type: array 49 | x-kubernetes-list-type: set 50 | required: 51 | - database 52 | type: object 53 | status: 54 | description: PostgresStatus defines the observed state of Postgres 55 | properties: 56 | extensions: 57 | items: 58 | type: string 59 | type: array 60 | x-kubernetes-list-type: set 61 | roles: 62 | description: PostgresRoles stores the different group roles for database 63 | properties: 64 | owner: 65 | type: string 66 | reader: 67 | type: string 68 | writer: 69 | type: string 70 | required: 71 | - owner 72 | - reader 73 | - writer 74 | type: object 75 | schemas: 76 | items: 77 | type: string 78 | type: array 79 | x-kubernetes-list-type: set 80 | succeeded: 81 | type: boolean 82 | required: 83 | - roles 84 | - succeeded 85 | type: object 86 | type: object 87 | served: true 88 | storage: true 89 | subresources: 90 | status: {} 91 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/crds/db.movetokube.com_postgresusers_crd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: postgresusers.db.movetokube.com 5 | spec: 6 | group: db.movetokube.com 7 | names: 8 | kind: PostgresUser 9 | listKind: PostgresUserList 10 | plural: postgresusers 11 | singular: postgresuser 12 | scope: Namespaced 13 | versions: 14 | - name: v1alpha1 15 | schema: 16 | openAPIV3Schema: 17 | description: PostgresUser is the Schema for the postgresusers API 18 | properties: 19 | apiVersion: 20 | description: 'APIVersion defines the versioned schema of this representation 21 | of an object. Servers should convert recognized schemas to the latest 22 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 23 | type: string 24 | kind: 25 | description: 'Kind is a string value representing the REST resource this 26 | object represents. Servers may infer this from the endpoint the client 27 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 28 | type: string 29 | metadata: 30 | type: object 31 | spec: 32 | description: PostgresUserSpec defines the desired state of PostgresUser 33 | properties: 34 | annotations: 35 | additionalProperties: 36 | type: string 37 | type: object 38 | database: 39 | type: string 40 | labels: 41 | additionalProperties: 42 | type: string 43 | type: object 44 | privileges: 45 | type: string 46 | role: 47 | type: string 48 | secretName: 49 | type: string 50 | secretTemplate: 51 | additionalProperties: 52 | type: string 53 | type: object 54 | required: 55 | - database 56 | - role 57 | - secretName 58 | type: object 59 | status: 60 | description: PostgresUserStatus defines the observed state of PostgresUser 61 | properties: 62 | databaseName: 63 | type: string 64 | postgresGroup: 65 | type: string 66 | postgresLogin: 67 | type: string 68 | postgresRole: 69 | type: string 70 | succeeded: 71 | type: boolean 72 | required: 73 | - databaseName 74 | - postgresGroup 75 | - postgresLogin 76 | - postgresRole 77 | - succeeded 78 | type: object 79 | type: object 80 | served: true 81 | storage: true 82 | subresources: 83 | status: {} 84 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "chart.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "chart.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "chart.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "chart.labels" -}} 37 | helm.sh/chart: {{ include "chart.chart" . }} 38 | {{ include "chart.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "chart.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "chart.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{- define "chart.selectorLabelsDev" -}} 54 | app.kubernetes.io/name: {{ include "chart.name" . }}-dev 55 | app.kubernetes.io/instance: {{ .Release.Name }}-dev 56 | {{- end }} 57 | 58 | {{/* 59 | Create the name of the service account to use 60 | */}} 61 | {{- define "chart.serviceAccountName" -}} 62 | {{- default (include "chart.fullname" .) .Values.serviceAccount.name }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "chart.fullname" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - secrets 12 | verbs: 13 | - "*" 14 | - apiGroups: 15 | - apps 16 | resourceNames: 17 | - ext-postgres-operator 18 | resources: 19 | - deployments/finalizers 20 | verbs: 21 | - update 22 | - apiGroups: 23 | - db.movetokube.com 24 | resources: 25 | - "*" 26 | verbs: 27 | - "*" 28 | - apiGroups: 29 | - monitoring.coreos.com 30 | resources: 31 | - servicemonitors 32 | verbs: 33 | - "*" 34 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/clusterrole_binding.yaml: -------------------------------------------------------------------------------- 1 | kind: ClusterRoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: {{ include "chart.fullname" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | subjects: 8 | - kind: ServiceAccount 9 | name: {{ include "chart.serviceAccountName" . }} 10 | namespace: {{ .Release.Namespace }} 11 | roleRef: 12 | kind: ClusterRole 13 | name: {{ include "chart.fullname" . }} 14 | apiGroup: rbac.authorization.k8s.io 15 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/operator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "chart.fullname" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | namespace: {{ .Release.Namespace }} 8 | {{- with .Values.deploymentAnnotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | spec: 13 | replicas: {{ .Values.replicaCount }} 14 | revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} 15 | selector: 16 | matchLabels: 17 | {{- include "chart.selectorLabels" . | nindent 6 }} 18 | template: 19 | metadata: 20 | {{- with .Values.podAnnotations }} 21 | annotations: 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | labels: 25 | {{- include "chart.selectorLabels" . | nindent 8 }} 26 | {{- with .Values.podLabels }} 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | spec: 30 | serviceAccountName: {{ include "chart.serviceAccountName" . }} 31 | securityContext: 32 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | securityContext: 36 | {{- toYaml .Values.securityContext | nindent 12 }} 37 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ .Values.image.pullPolicy }} 39 | envFrom: 40 | - secretRef: 41 | {{- if .Values.existingSecret }} 42 | name: {{ .Values.existingSecret }} 43 | {{- else }} 44 | name: {{ include "chart.fullname" . }} 45 | {{- end }} 46 | env: 47 | - name: WATCH_NAMESPACE 48 | value: {{ .Values.watchNamespace | default "" }} 49 | - name: POD_NAME 50 | valueFrom: 51 | fieldRef: 52 | fieldPath: metadata.name 53 | {{- range $key, $value := .Values.env }} 54 | - name: {{ $key }} 55 | value: {{ $value | quote }} 56 | {{- end }} 57 | {{- if .Values.resources }} 58 | resources: 59 | {{- toYaml .Values.resources | nindent 12 }} 60 | {{- end }} 61 | {{- if .Values.volumeMounts }} 62 | volumeMounts: 63 | {{- toYaml .Values.volumeMounts | nindent 12 }} 64 | {{- end }} 65 | {{- if .Values.volumes }} 66 | volumes: 67 | {{- toYaml .Values.volumes | nindent 8 }} 68 | {{- end }} 69 | nodeSelector: 70 | {{- toYaml .Values.nodeSelector | nindent 8 }} 71 | tolerations: 72 | {{- toYaml .Values.tolerations | nindent 8 }} 73 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: {{ include "chart.fullname" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - configmaps 12 | - secrets 13 | - services 14 | verbs: 15 | - "*" 16 | - apiGroups: 17 | - "" 18 | resources: 19 | - pods 20 | verbs: 21 | - "get" 22 | - apiGroups: 23 | - "apps" 24 | resources: 25 | - replicasets 26 | - deployments 27 | verbs: 28 | - "get" 29 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/role_binding.yaml: -------------------------------------------------------------------------------- 1 | kind: RoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: {{ include "chart.fullname" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | subjects: 8 | - kind: ServiceAccount 9 | name: {{ include "chart.serviceAccountName" . }} 10 | namespace: {{ .Release.Namespace }} 11 | roleRef: 12 | kind: Role 13 | name: {{ include "chart.fullname" . }} 14 | apiGroup: rbac.authorization.k8s.io 15 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if (not .Values.existingSecret) }} 2 | --- 3 | apiVersion: v1 4 | kind: Secret 5 | metadata: 6 | annotations: 7 | "helm.sh/resource-policy": keep 8 | name: {{ include "chart.fullname" . }} 9 | namespace: {{ .Release.namespace }} 10 | labels: 11 | {{- include "chart.labels" . | nindent 4 }} 12 | type: Opaque 13 | data: 14 | POSTGRES_HOST: {{ .Values.postgres.host | b64enc | quote }} 15 | POSTGRES_USER: {{ .Values.postgres.user | b64enc | quote }} 16 | POSTGRES_PASS: {{ .Values.postgres.password | b64enc | quote }} 17 | POSTGRES_URI_ARGS: {{ .Values.postgres.uri_args | b64enc | quote }} 18 | POSTGRES_CLOUD_PROVIDER: {{ .Values.postgres.cloud_provider | b64enc | quote }} 19 | POSTGRES_DEFAULT_DATABASE: {{ .Values.postgres.default_database | b64enc | quote }} 20 | {{- end }} 21 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "chart.serviceAccountName" . }} 5 | labels: 6 | {{- include "chart.labels" . | nindent 4 }} 7 | {{- with .Values.serviceAccount.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | namespace: {{ .Release.Namespace }} 12 | 13 | -------------------------------------------------------------------------------- /charts/ext-postgres-operator/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for chart. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | revisionHistoryLimit: 10 8 | 9 | image: 10 | repository: ghcr.io/movetokube/postgres-operator 11 | pullPolicy: IfNotPresent 12 | # Overrides the image tag whose default is the chart appVersion. 13 | tag: "" 14 | 15 | # Override chart name, defaults to Chart.name 16 | nameOverride: "" 17 | # Full chart name override 18 | fullnameOverride: "" 19 | 20 | serviceAccount: 21 | # Annotations to add to the service account 22 | annotations: {} 23 | # The name of the service account to use. 24 | # If not set and create is true, a name is generated using the fullname template 25 | name: "" 26 | 27 | deploymentAnnotations: {} 28 | 29 | podAnnotations: {} 30 | 31 | # Additionnal labels to add to the pod. 32 | podLabels: {} 33 | 34 | podSecurityContext: 35 | runAsNonRoot: true 36 | # fsGroup: 2000 37 | 38 | securityContext: 39 | allowPrivilegeEscalation: false 40 | capabilities: 41 | drop: 42 | - "ALL" 43 | 44 | resources: 45 | {} 46 | # We usually recommend not to specify default resources and to leave this as a conscious 47 | # choice for the user. This also increases chances charts run on environments with little 48 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 49 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 50 | # limits: 51 | # cpu: 100m 52 | # memory: 128Mi 53 | # requests: 54 | # cpu: 100m 55 | # memory: 128Mi 56 | 57 | # Which namespace to watch in kubernetes, empty string means all namespaces 58 | watchNamespace: "" 59 | 60 | # Define connection to postgres database server 61 | postgres: 62 | # postgres hostname 63 | host: "localhost" 64 | # postgres admin user and password 65 | user: "admin" 66 | password: "password" 67 | # additional connection args to pg driver 68 | uri_args: "" 69 | # postgres cloud provider, could be AWS, Azure, GCP or empty (default) 70 | cloud_provider: "" 71 | # default database to use 72 | default_database: "postgres" 73 | 74 | # Volumes to add to the pod. 75 | volumes: [] 76 | 77 | # Volumes to mount onto the pod. 78 | volumeMounts: [] 79 | 80 | # Existing secret where values to connect to Postgres are defined. 81 | # If not set a new secret will be created, filled with information under the postgres key above. 82 | existingSecret: "" 83 | 84 | # Additionnal environment variables to add to the pod (map of key / value) 85 | env: {} 86 | 87 | nodeSelector: {} 88 | 89 | tolerations: [] 90 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 10 | // to ensure that exec-entrypoint and run can make use of them. 11 | _ "k8s.io/client-go/plugin/pkg/client/auth" 12 | 13 | "k8s.io/apimachinery/pkg/runtime" 14 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 15 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 16 | ctrl "sigs.k8s.io/controller-runtime" 17 | "sigs.k8s.io/controller-runtime/pkg/healthz" 18 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 19 | "sigs.k8s.io/controller-runtime/pkg/metrics/filters" 20 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 21 | "sigs.k8s.io/controller-runtime/pkg/webhook" 22 | 23 | dbv1alpha1 "github.com/movetokube/postgres-operator/api/v1alpha1" 24 | "github.com/movetokube/postgres-operator/internal/controller" 25 | "github.com/movetokube/postgres-operator/pkg/config" 26 | "github.com/movetokube/postgres-operator/pkg/postgres" 27 | "sigs.k8s.io/controller-runtime/pkg/cache" 28 | // +kubebuilder:scaffold:imports 29 | ) 30 | 31 | var ( 32 | scheme = runtime.NewScheme() 33 | setupLog = ctrl.Log.WithName("setup") 34 | ) 35 | 36 | func init() { 37 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 38 | 39 | utilruntime.Must(dbv1alpha1.AddToScheme(scheme)) 40 | // +kubebuilder:scaffold:scheme 41 | } 42 | 43 | func main() { 44 | var metricsAddr string 45 | var enableLeaderElection bool 46 | var probeAddr string 47 | var secureMetrics bool 48 | var enableHTTP2 bool 49 | var tlsOpts []func(*tls.Config) 50 | flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ 51 | "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") 52 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 53 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 54 | "Enable leader election for controller manager. "+ 55 | "Enabling this will ensure there is only one active controller manager.") 56 | flag.BoolVar(&secureMetrics, "metrics-secure", true, 57 | "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") 58 | flag.BoolVar(&enableHTTP2, "enable-http2", false, 59 | "If set, HTTP/2 will be enabled for the metrics and webhook servers") 60 | opts := zap.Options{ 61 | Development: true, 62 | } 63 | opts.BindFlags(flag.CommandLine) 64 | flag.Parse() 65 | 66 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 67 | 68 | // if the enable-http2 flag is false (the default), http/2 should be disabled 69 | // due to its vulnerabilities. More specifically, disabling http/2 will 70 | // prevent from being vulnerable to the HTTP/2 Stream Cancellation and 71 | // Rapid Reset CVEs. For more information see: 72 | // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 73 | // - https://github.com/advisories/GHSA-4374-p667-p6c8 74 | disableHTTP2 := func(c *tls.Config) { 75 | setupLog.Info("disabling http/2") 76 | c.NextProtos = []string{"http/1.1"} 77 | } 78 | 79 | if !enableHTTP2 { 80 | tlsOpts = append(tlsOpts, disableHTTP2) 81 | } 82 | 83 | webhookServer := webhook.NewServer(webhook.Options{ 84 | TLSOpts: tlsOpts, 85 | }) 86 | 87 | // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. 88 | // More info: 89 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/metrics/server 90 | // - https://book.kubebuilder.io/reference/metrics.html 91 | metricsServerOptions := metricsserver.Options{ 92 | BindAddress: metricsAddr, 93 | SecureServing: secureMetrics, 94 | // TODO(user): TLSOpts is used to allow configuring the TLS config used for the server. If certificates are 95 | // not provided, self-signed certificates will be generated by default. This option is not recommended for 96 | // production environments as self-signed certificates do not offer the same level of trust and security 97 | // as certificates issued by a trusted Certificate Authority (CA). The primary risk is potentially allowing 98 | // unauthorized access to sensitive metrics data. Consider replacing with CertDir, CertName, and KeyName 99 | // to provide certificates, ensuring the server communicates using trusted and secure certificates. 100 | TLSOpts: tlsOpts, 101 | } 102 | 103 | if secureMetrics { 104 | // FilterProvider is used to protect the metrics endpoint with authn/authz. 105 | // These configurations ensure that only authorized users and service accounts 106 | // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: 107 | // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/metrics/filters#WithAuthenticationAndAuthorization 108 | metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization 109 | } 110 | 111 | cfg := config.Get() 112 | lockName := "lock" 113 | if cfg.AnnotationFilter == "" { 114 | setupLog.Info("No POSTGRES_INSTANCE set, this instance will only process CRs without an annotation") 115 | } else { 116 | setupLog.Info("POSTGRES_INSTANCE is set, this instance will only process CRs with the correct annotation", "annotation", cfg.AnnotationFilter) 117 | lockName += "-" + cfg.AnnotationFilter 118 | } 119 | cacheOpts := cache.Options{} 120 | namespace, found := os.LookupEnv("WATCH_NAMESPACE") 121 | if found { 122 | cacheOpts.DefaultNamespaces = map[string]cache.Config{ 123 | namespace: {}, 124 | } 125 | } 126 | 127 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 128 | Scheme: scheme, 129 | Metrics: metricsServerOptions, 130 | WebhookServer: webhookServer, 131 | HealthProbeBindAddress: probeAddr, 132 | LeaderElection: enableLeaderElection, 133 | LeaderElectionID: fmt.Sprintf("%s.db.movetokube.com", lockName), 134 | Cache: cacheOpts, 135 | // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily 136 | // when the Manager ends. This requires the binary to immediately end when the 137 | // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly 138 | // speeds up voluntary leader transitions as the new leader don't have to wait 139 | // LeaseDuration time first. 140 | // 141 | // In the default scaffold provided, the program ends immediately after 142 | // the manager stops, so would be fine to enable this option. However, 143 | // if you are doing or is intended to do any operation such as perform cleanups 144 | // after the manager stops then its usage might be unsafe. 145 | // LeaderElectionReleaseOnCancel: true, 146 | }) 147 | if err != nil { 148 | setupLog.Error(err, "unable to start manager") 149 | os.Exit(1) 150 | } 151 | 152 | pg, err := postgres.NewPG(cfg, ctrl.Log) 153 | if err != nil { 154 | setupLog.Error(err, "DB-Connection failed", "cfg", cfg) 155 | os.Exit(1) 156 | } 157 | 158 | if err = (controller.NewPostgresReconciler(mgr, cfg, pg)).SetupWithManager(mgr); err != nil { 159 | setupLog.Error(err, "unable to create controller", "controller", "Postgres") 160 | os.Exit(1) 161 | } 162 | if err = (controller.NewPostgresUserReconciler(mgr, cfg, pg)).SetupWithManager(mgr); err != nil { 163 | setupLog.Error(err, "unable to create controller", "controller", "PostgresUser") 164 | os.Exit(1) 165 | } 166 | // +kubebuilder:scaffold:builder 167 | 168 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 169 | setupLog.Error(err, "unable to set up health check") 170 | os.Exit(1) 171 | } 172 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 173 | setupLog.Error(err, "unable to set up ready check") 174 | os.Exit(1) 175 | } 176 | 177 | setupLog.Info("starting manager") 178 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 179 | setupLog.Error(err, "problem running manager") 180 | os.Exit(1) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /config/crd/bases/db.movetokube.com_postgres.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.16.1 7 | name: postgres.db.movetokube.com 8 | spec: 9 | group: db.movetokube.com 10 | names: 11 | kind: Postgres 12 | listKind: PostgresList 13 | plural: postgres 14 | singular: postgres 15 | scope: Namespaced 16 | versions: 17 | - name: v1alpha1 18 | schema: 19 | openAPIV3Schema: 20 | description: Postgres is the Schema for the postgres API 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: PostgresSpec defines the desired state of Postgres 41 | properties: 42 | database: 43 | type: string 44 | dropOnDelete: 45 | type: boolean 46 | extensions: 47 | items: 48 | type: string 49 | type: array 50 | x-kubernetes-list-type: set 51 | masterRole: 52 | type: string 53 | schemas: 54 | items: 55 | type: string 56 | type: array 57 | x-kubernetes-list-type: set 58 | required: 59 | - database 60 | type: object 61 | status: 62 | description: PostgresStatus defines the observed state of Postgres 63 | properties: 64 | extensions: 65 | items: 66 | type: string 67 | type: array 68 | x-kubernetes-list-type: set 69 | roles: 70 | description: PostgresRoles stores the different group roles for database 71 | properties: 72 | owner: 73 | type: string 74 | reader: 75 | type: string 76 | writer: 77 | type: string 78 | required: 79 | - owner 80 | - reader 81 | - writer 82 | type: object 83 | schemas: 84 | items: 85 | type: string 86 | type: array 87 | x-kubernetes-list-type: set 88 | succeeded: 89 | type: boolean 90 | required: 91 | - roles 92 | - succeeded 93 | type: object 94 | type: object 95 | served: true 96 | storage: true 97 | subresources: 98 | status: {} 99 | -------------------------------------------------------------------------------- /config/crd/bases/db.movetokube.com_postgresusers.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.16.1 7 | name: postgresusers.db.movetokube.com 8 | spec: 9 | group: db.movetokube.com 10 | names: 11 | kind: PostgresUser 12 | listKind: PostgresUserList 13 | plural: postgresusers 14 | singular: postgresuser 15 | scope: Namespaced 16 | versions: 17 | - name: v1alpha1 18 | schema: 19 | openAPIV3Schema: 20 | description: PostgresUser is the Schema for the postgresusers API 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: PostgresUserSpec defines the desired state of PostgresUser 41 | properties: 42 | annotations: 43 | additionalProperties: 44 | type: string 45 | type: object 46 | database: 47 | type: string 48 | labels: 49 | additionalProperties: 50 | type: string 51 | type: object 52 | privileges: 53 | type: string 54 | role: 55 | type: string 56 | secretName: 57 | type: string 58 | secretTemplate: 59 | additionalProperties: 60 | type: string 61 | type: object 62 | required: 63 | - database 64 | - role 65 | - secretName 66 | type: object 67 | status: 68 | description: PostgresUserStatus defines the observed state of PostgresUser 69 | properties: 70 | databaseName: 71 | type: string 72 | postgresGroup: 73 | type: string 74 | postgresLogin: 75 | type: string 76 | postgresRole: 77 | type: string 78 | succeeded: 79 | type: boolean 80 | required: 81 | - databaseName 82 | - postgresGroup 83 | - postgresLogin 84 | - postgresRole 85 | - succeeded 86 | type: object 87 | type: object 88 | served: true 89 | storage: true 90 | subresources: 91 | status: {} 92 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/db.movetokube.com_postgres.yaml 6 | - bases/db.movetokube.com_postgresusers.yaml 7 | # +kubebuilder:scaffold:crdkustomizeresource 8 | 9 | patches: 10 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 11 | # patches here are for enabling the conversion webhook for each CRD 12 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- path: patches/cainjection_in_postgres.yaml 17 | #- path: patches/cainjection_in_postgresusers.yaml 18 | # +kubebuilder:scaffold:crdkustomizecainjectionpatch 19 | 20 | # [WEBHOOK] To enable webhook, uncomment the following section 21 | # the following config is for teaching kustomize how to do kustomization for CRDs. 22 | 23 | #configurations: 24 | #- kustomizeconfig.yaml 25 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: operators 2 | 3 | resources: 4 | - namespace.yaml 5 | - ../crd 6 | - ../rbac 7 | - ../manager 8 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - operator.yaml 3 | -------------------------------------------------------------------------------- /config/manager/operator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: ext-postgres-operator 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | name: ext-postgres-operator 10 | template: 11 | metadata: 12 | labels: 13 | name: ext-postgres-operator 14 | spec: 15 | serviceAccountName: ext-postgres-operator 16 | securityContext: 17 | runAsNonRoot: true 18 | containers: 19 | - name: ext-postgres-operator 20 | image: movetokube/postgres-operator:2.0.0 21 | imagePullPolicy: Always 22 | envFrom: 23 | - secretRef: 24 | name: ext-postgres-operator 25 | env: 26 | - name: WATCH_NAMESPACE 27 | value: "" 28 | - name: KEEP_SECRET_NAME 29 | value: "false" 30 | - name: POD_NAME 31 | valueFrom: 32 | fieldRef: 33 | fieldPath: metadata.name 34 | securityContext: 35 | allowPrivilegeEscalation: false 36 | capabilities: 37 | drop: 38 | - "ALL" 39 | livenessProbe: 40 | httpGet: 41 | path: /healthz 42 | port: 8081 43 | initialDelaySeconds: 15 44 | periodSeconds: 20 45 | readinessProbe: 46 | httpGet: 47 | path: /readyz 48 | port: 8081 49 | initialDelaySeconds: 5 50 | periodSeconds: 10 51 | resources: 52 | limits: 53 | cpu: 500m 54 | memory: 128Mi 55 | requests: 56 | cpu: 10m 57 | memory: 64Mi 58 | terminationGracePeriodSeconds: 10 59 | -------------------------------------------------------------------------------- /config/manifests/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../default 3 | - ../samples 4 | -------------------------------------------------------------------------------- /config/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: operators 5 | -------------------------------------------------------------------------------- /config/rbac/cluster_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: ext-postgres-operator 5 | rules: 6 | - apiGroups: 7 | - "" 8 | resources: 9 | - secrets 10 | verbs: 11 | - "*" 12 | - apiGroups: 13 | - apps 14 | resourceNames: 15 | - ext-postgres-operator 16 | resources: 17 | - deployments/finalizers 18 | verbs: 19 | - update 20 | - apiGroups: 21 | - db.movetokube.com 22 | resources: 23 | - "*" 24 | verbs: 25 | - "*" 26 | - apiGroups: 27 | - monitoring.coreos.com 28 | resources: 29 | - servicemonitors 30 | verbs: 31 | - "*" 32 | -------------------------------------------------------------------------------- /config/rbac/cluster_role_binding.yaml: -------------------------------------------------------------------------------- 1 | kind: ClusterRoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: ext-postgres-operator 5 | subjects: 6 | - kind: ServiceAccount 7 | name: ext-postgres-operator 8 | roleRef: 9 | kind: ClusterRole 10 | name: ext-postgres-operator 11 | apiGroup: rbac.authorization.k8s.io 12 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - service_account.yaml 3 | - cluster_role.yaml 4 | - cluster_role_binding.yaml 5 | - role.yaml 6 | - role_binding.yaml 7 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: ext-postgres-operator 5 | rules: 6 | - apiGroups: 7 | - "" 8 | resources: 9 | - configmaps 10 | - secrets 11 | - services 12 | verbs: 13 | - "*" 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - pods 18 | verbs: 19 | - "get" 20 | - apiGroups: 21 | - "apps" 22 | resources: 23 | - replicasets 24 | - deployments 25 | verbs: 26 | - "get" 27 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | kind: RoleBinding 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: ext-postgres-operator 5 | subjects: 6 | - kind: ServiceAccount 7 | name: ext-postgres-operator 8 | roleRef: 9 | kind: Role 10 | name: ext-postgres-operator 11 | apiGroup: rbac.authorization.k8s.io 12 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: ext-postgres-operator 5 | -------------------------------------------------------------------------------- /config/samples/db_v1alpha1_postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: db.movetokube.com/v1alpha1 2 | kind: Postgres 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: postgres-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: my-db 8 | spec: 9 | database: test-db # Name of database created in PostgreSQL 10 | dropOnDelete: false # Set to true if you want the operator to drop the database and role when this CR is deleted 11 | masterRole: test-db-group 12 | schemas: # List of schemas the operator should create in database 13 | - stores 14 | - customers 15 | -------------------------------------------------------------------------------- /config/samples/db_v1alpha1_postgresuser.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: db.movetokube.com/v1alpha1 2 | kind: PostgresUser 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: postgres-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: my-db-user 8 | spec: 9 | role: username 10 | database: my-db # This references the Postgres CR 11 | secretName: my-secret 12 | privileges: OWNER # Can be OWNER/READ/WRITE 13 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - db_v1alpha1_postgres.yaml 4 | - db_v1alpha1_postgresuser.yaml 5 | # +kubebuilder:scaffold:manifestskustomizesamples 6 | -------------------------------------------------------------------------------- /config/secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: ext-postgres-operator 5 | type: Opaque 6 | data: 7 | POSTGRES_HOST: cG9zdGdyZXMuZGF0YWJhc2Vz 8 | POSTGRES_USER: cG9zdGdyZXM= 9 | POSTGRES_PASS: YWRtaW4xMjM= 10 | POSTGRES_URI_ARGS: c3NsbW9kZT1kaXNhYmxl 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/movetokube/postgres-operator 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/go-logr/logr v1.4.2 7 | github.com/lib/pq v1.10.9 8 | github.com/onsi/ginkgo/v2 v2.23.3 9 | github.com/onsi/gomega v1.37.0 10 | go.uber.org/mock v0.5.2 11 | k8s.io/api v0.33.1 12 | k8s.io/apimachinery v0.33.1 13 | k8s.io/client-go v0.33.1 14 | sigs.k8s.io/controller-runtime v0.21.0 15 | ) 16 | 17 | require ( 18 | cel.dev/expr v0.19.1 // indirect 19 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/blang/semver/v4 v4.0.0 // indirect 22 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 23 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 26 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 27 | github.com/felixge/httpsnoop v1.0.4 // indirect 28 | github.com/fsnotify/fsnotify v1.7.0 // indirect 29 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 30 | github.com/go-logr/stdr v1.2.2 // indirect 31 | github.com/go-logr/zapr v1.3.0 // indirect 32 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 33 | github.com/go-openapi/jsonreference v0.20.2 // indirect 34 | github.com/go-openapi/swag v0.23.0 // indirect 35 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/google/btree v1.1.3 // indirect 38 | github.com/google/cel-go v0.23.2 // indirect 39 | github.com/google/gnostic-models v0.6.9 // indirect 40 | github.com/google/go-cmp v0.7.0 // indirect 41 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 42 | github.com/google/uuid v1.6.0 // indirect 43 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect 44 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/json-iterator/go v1.1.12 // indirect 47 | github.com/mailru/easyjson v0.7.7 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 51 | github.com/pkg/errors v0.9.1 // indirect 52 | github.com/prometheus/client_golang v1.22.0 // indirect 53 | github.com/prometheus/client_model v0.6.1 // indirect 54 | github.com/prometheus/common v0.62.0 // indirect 55 | github.com/prometheus/procfs v0.15.1 // indirect 56 | github.com/spf13/cobra v1.8.1 // indirect 57 | github.com/spf13/pflag v1.0.5 // indirect 58 | github.com/stoewer/go-strcase v1.3.0 // indirect 59 | github.com/x448/float16 v0.8.4 // indirect 60 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 61 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 62 | go.opentelemetry.io/otel v1.33.0 // indirect 63 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 64 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect 65 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 66 | go.opentelemetry.io/otel/sdk v1.33.0 // indirect 67 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 68 | go.opentelemetry.io/proto/otlp v1.4.0 // indirect 69 | go.uber.org/multierr v1.11.0 // indirect 70 | go.uber.org/zap v1.27.0 // indirect 71 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 72 | golang.org/x/net v0.38.0 // indirect 73 | golang.org/x/oauth2 v0.27.0 // indirect 74 | golang.org/x/sync v0.12.0 // indirect 75 | golang.org/x/sys v0.31.0 // indirect 76 | golang.org/x/term v0.30.0 // indirect 77 | golang.org/x/text v0.23.0 // indirect 78 | golang.org/x/time v0.9.0 // indirect 79 | golang.org/x/tools v0.30.0 // indirect 80 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 81 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect 82 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect 83 | google.golang.org/grpc v1.68.1 // indirect 84 | google.golang.org/protobuf v1.36.5 // indirect 85 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 86 | gopkg.in/inf.v0 v0.9.1 // indirect 87 | gopkg.in/yaml.v3 v3.0.1 // indirect 88 | k8s.io/apiextensions-apiserver v0.33.0 // indirect 89 | k8s.io/apiserver v0.33.0 // indirect 90 | k8s.io/component-base v0.33.0 // indirect 91 | k8s.io/klog/v2 v2.130.1 // indirect 92 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 93 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 94 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect 95 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 96 | sigs.k8s.io/randfill v1.0.0 // indirect 97 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 98 | sigs.k8s.io/yaml v1.4.0 // indirect 99 | ) 100 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= 2 | cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= 3 | github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= 4 | github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 8 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 9 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 10 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 11 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 12 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 20 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 21 | github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= 22 | github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 23 | github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= 24 | github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 25 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 26 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 27 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 28 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 29 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 30 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 31 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 32 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 33 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 34 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 35 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 36 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 37 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 38 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 39 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 40 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 41 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 42 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 43 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 44 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 45 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 46 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 47 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 48 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 49 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 50 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 51 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 52 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 53 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 54 | github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= 55 | github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= 56 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 57 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 58 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 59 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 60 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 61 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 62 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 63 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 64 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 65 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 66 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 67 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 68 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= 69 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= 70 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 71 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 72 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 73 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 74 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 75 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 76 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 77 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 78 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 79 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 80 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 81 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 82 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 83 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 84 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 85 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 86 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 87 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 88 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 89 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 90 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 91 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 92 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 93 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 95 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 96 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 97 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 98 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 99 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 100 | github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= 101 | github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= 102 | github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 103 | github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 104 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 105 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 106 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 107 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 108 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 109 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 110 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 111 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 112 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 113 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 114 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 115 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 116 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 117 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 118 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 119 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 120 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 121 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 122 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 123 | github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= 124 | github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 125 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 126 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 127 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 128 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 129 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 130 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 131 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 132 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 133 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 134 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 135 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 136 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 137 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 138 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 139 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 140 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 141 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 142 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= 143 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 144 | go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= 145 | go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= 146 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 147 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 148 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= 149 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= 150 | go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= 151 | go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= 152 | go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= 153 | go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= 154 | go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= 155 | go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= 156 | go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= 157 | go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= 158 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 159 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 160 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 161 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 162 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 163 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 164 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 165 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 166 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 167 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 168 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 169 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 170 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 171 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 172 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 173 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 174 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 175 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 176 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 177 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 178 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 179 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 180 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 181 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 185 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 186 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 187 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 188 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 189 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 190 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 191 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 192 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 193 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 194 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 195 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 196 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 197 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 198 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 199 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 200 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 201 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 202 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 203 | golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= 204 | golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= 205 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 206 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 207 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 208 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 209 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 210 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 211 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= 212 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= 213 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= 214 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= 215 | google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= 216 | google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= 217 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 218 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 219 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 221 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 222 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 223 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 224 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 225 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 226 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 227 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 228 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 229 | k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw= 230 | k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw= 231 | k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= 232 | k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= 233 | k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= 234 | k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 235 | k8s.io/apiserver v0.33.0 h1:QqcM6c+qEEjkOODHppFXRiw/cE2zP85704YrQ9YaBbc= 236 | k8s.io/apiserver v0.33.0/go.mod h1:EixYOit0YTxt8zrO2kBU7ixAtxFce9gKGq367nFmqI8= 237 | k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4= 238 | k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA= 239 | k8s.io/component-base v0.33.0 h1:Ot4PyJI+0JAD9covDhwLp9UNkUja209OzsJ4FzScBNk= 240 | k8s.io/component-base v0.33.0/go.mod h1:aXYZLbw3kihdkOPMDhWbjGCO6sg+luw554KP51t8qCU= 241 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 242 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 243 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 244 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 245 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 246 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 247 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= 248 | sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= 249 | sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= 250 | sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= 251 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 252 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 253 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 254 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 255 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 256 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 257 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 258 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 259 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 260 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/movetokube/postgres-operator/ed05511251c1356a8d0cb4560e713c1beeec38df/hack/boilerplate.go.txt -------------------------------------------------------------------------------- /internal/controller/postgres_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | 8 | "k8s.io/apimachinery/pkg/api/errors" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | ctrl "sigs.k8s.io/controller-runtime" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 13 | "sigs.k8s.io/controller-runtime/pkg/log" 14 | "sigs.k8s.io/controller-runtime/pkg/manager" 15 | 16 | "github.com/go-logr/logr" 17 | dbv1alpha1 "github.com/movetokube/postgres-operator/api/v1alpha1" 18 | "github.com/movetokube/postgres-operator/pkg/config" 19 | "github.com/movetokube/postgres-operator/pkg/postgres" 20 | "github.com/movetokube/postgres-operator/pkg/utils" 21 | kerrors "k8s.io/apimachinery/pkg/util/errors" 22 | ) 23 | 24 | // PostgresReconciler reconciles a Postgres object 25 | type PostgresReconciler struct { 26 | client.Client 27 | Scheme *runtime.Scheme 28 | pg postgres.PG 29 | // pgHost string 30 | instanceFilter string 31 | } 32 | 33 | // NewPostgresReconciler returns a new reconcile.Reconciler 34 | func NewPostgresReconciler(mgr manager.Manager, c *config.Cfg, pg postgres.PG) *PostgresReconciler { 35 | return &PostgresReconciler{ 36 | Client: mgr.GetClient(), 37 | Scheme: mgr.GetScheme(), 38 | pg: pg, 39 | instanceFilter: c.AnnotationFilter, 40 | } 41 | } 42 | 43 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgres,verbs=get;list;watch;create;update;patch;delete 44 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgres/status,verbs=get;update;patch 45 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgres/finalizers,verbs=update 46 | 47 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 48 | // move the current state of the cluster closer to the desired state. 49 | // TODO(user): Modify the Reconcile function to compare the state specified by 50 | // the Postgres object against the actual cluster state, and then 51 | // perform operations to make the cluster state reflect the state specified by 52 | // the user. 53 | // 54 | // For more details, check Reconcile and its Result here: 55 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile 56 | func (r *PostgresReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 57 | log := log.FromContext(ctx) 58 | 59 | reqLogger := log.WithValues("Request.Namespace", req.Namespace, "Request.Name", req.Name) 60 | reqLogger.Info("Reconciling Postgres") 61 | 62 | instance := &dbv1alpha1.Postgres{} 63 | // Fetch the Postgres instance 64 | err := r.Get(ctx, req.NamespacedName, instance) 65 | if err != nil { 66 | if errors.IsNotFound(err) { 67 | // Request object not found, could have been deleted after reconcile request. 68 | // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. 69 | // Return and don't requeue 70 | return ctrl.Result{}, nil 71 | } 72 | // Error reading the object - requeue the request. 73 | return ctrl.Result{}, err 74 | } 75 | 76 | if !utils.MatchesInstanceAnnotation(instance.Annotations, r.instanceFilter) { 77 | return ctrl.Result{}, nil 78 | } 79 | before := instance.DeepCopy() 80 | 81 | // deletion logic 82 | if !instance.GetDeletionTimestamp().IsZero() { 83 | if r.shouldDropDB(ctx, instance, reqLogger) && instance.Status.Succeeded { 84 | if instance.Status.Roles.Owner != "" { 85 | err := r.pg.DropRole(instance.Status.Roles.Owner, r.pg.GetUser(), instance.Spec.Database, reqLogger) 86 | if err != nil { 87 | return ctrl.Result{}, err 88 | } 89 | instance.Status.Roles.Owner = "" 90 | } 91 | if instance.Status.Roles.Reader != "" { 92 | err = r.pg.DropRole(instance.Status.Roles.Reader, r.pg.GetUser(), instance.Spec.Database, reqLogger) 93 | if err != nil { 94 | return ctrl.Result{}, err 95 | } 96 | instance.Status.Roles.Reader = "" 97 | } 98 | if instance.Status.Roles.Writer != "" { 99 | err = r.pg.DropRole(instance.Status.Roles.Writer, r.pg.GetUser(), instance.Spec.Database, reqLogger) 100 | if err != nil { 101 | return ctrl.Result{}, err 102 | } 103 | instance.Status.Roles.Writer = "" 104 | } 105 | err = r.pg.DropDatabase(instance.Spec.Database, reqLogger) 106 | if err != nil { 107 | return ctrl.Result{}, err 108 | } 109 | } 110 | err = r.Status().Patch(ctx, instance, client.MergeFrom(before)) 111 | if err != nil { 112 | reqLogger.Error(err, "could not update db status") 113 | } 114 | controllerutil.RemoveFinalizer(instance, "finalizer.db.movetokube.com") 115 | err = r.Patch(ctx, instance, client.MergeFrom(before)) 116 | if err != nil { 117 | reqLogger.Error(err, "could not remove finalizer") 118 | } 119 | 120 | return ctrl.Result{}, nil 121 | } 122 | 123 | // Patch after every reconcile loop, if needed 124 | requeue := func(err error) (ctrl.Result, error) { 125 | reqLogger.Error(err, "Requeuing...") 126 | instance.Status.Succeeded = false 127 | updateErr := r.Status().Patch(ctx, instance, client.MergeFrom(before)) 128 | if updateErr != nil { 129 | err = kerrors.NewAggregate([]error{err, updateErr}) 130 | } 131 | return ctrl.Result{Requeue: true}, err 132 | } 133 | 134 | // creation logic 135 | if !instance.Status.Succeeded { 136 | owner := instance.Spec.MasterRole 137 | if owner == "" { 138 | owner = fmt.Sprintf("%s-group", instance.Spec.Database) 139 | } 140 | // Create owner role 141 | err = r.pg.CreateGroupRole(owner) 142 | if err != nil { 143 | return requeue(errors.NewInternalError(err)) 144 | } 145 | instance.Status.Roles.Owner = owner 146 | 147 | // Create database 148 | err = r.pg.CreateDB(instance.Spec.Database, owner) 149 | if err != nil { 150 | reqLogger.Error(err, "Could not create DB") 151 | return requeue(errors.NewInternalError(err)) 152 | } 153 | 154 | // Create reader role 155 | reader := fmt.Sprintf("%s-reader", instance.Spec.Database) 156 | err = r.pg.CreateGroupRole(reader) 157 | if err != nil { 158 | return requeue(errors.NewInternalError(err)) 159 | } 160 | instance.Status.Roles.Reader = reader 161 | 162 | // Create writer role 163 | writer := fmt.Sprintf("%s-writer", instance.Spec.Database) 164 | err = r.pg.CreateGroupRole(writer) 165 | if err != nil { 166 | return requeue(errors.NewInternalError(err)) 167 | } 168 | instance.Status.Roles.Writer = writer 169 | instance.Status.Succeeded = true 170 | } 171 | // create extensions 172 | for _, extension := range instance.Spec.Extensions { 173 | // Check if extension is already added. Skip if already is added. 174 | if slices.Contains(instance.Status.Extensions, extension) { 175 | continue 176 | } 177 | // Execute create extension SQL statement 178 | err = r.pg.CreateExtension(instance.Spec.Database, extension, reqLogger) 179 | if err != nil { 180 | reqLogger.Error(err, fmt.Sprintf("Could not add extensions %s", extension)) 181 | continue 182 | } 183 | instance.Status.Extensions = append(instance.Status.Extensions, extension) 184 | } 185 | // create schemas 186 | var ( 187 | database = instance.Spec.Database 188 | owner = instance.Status.Roles.Owner 189 | reader = instance.Status.Roles.Reader 190 | writer = instance.Status.Roles.Writer 191 | readerPrivs = "SELECT" 192 | writerPrivs = "SELECT,INSERT,DELETE,UPDATE" 193 | ) 194 | for _, schema := range instance.Spec.Schemas { 195 | // Schema was previously created 196 | if slices.Contains(instance.Status.Schemas, schema) { 197 | continue 198 | } 199 | 200 | // Create schema 201 | err = r.pg.CreateSchema(database, owner, schema, reqLogger) 202 | if err != nil { 203 | reqLogger.Error(err, fmt.Sprintf("Could not create schema %s", schema)) 204 | continue 205 | } 206 | 207 | // Set privileges on schema 208 | schemaPrivilegesReader := postgres.PostgresSchemaPrivileges{ 209 | DB: database, 210 | Creator: owner, 211 | Role: reader, 212 | Schema: schema, 213 | Privs: readerPrivs, 214 | CreateSchema: false, 215 | } 216 | err = r.pg.SetSchemaPrivileges(schemaPrivilegesReader, reqLogger) 217 | if err != nil { 218 | reqLogger.Error(err, fmt.Sprintf("Could not give %s permissions \"%s\"", reader, readerPrivs)) 219 | continue 220 | } 221 | schemaPrivilegesWriter := postgres.PostgresSchemaPrivileges{ 222 | DB: database, 223 | Creator: owner, 224 | Role: writer, 225 | Schema: schema, 226 | Privs: writerPrivs, 227 | CreateSchema: true, 228 | } 229 | err = r.pg.SetSchemaPrivileges(schemaPrivilegesWriter, reqLogger) 230 | if err != nil { 231 | reqLogger.Error(err, fmt.Sprintf("Could not give %s permissions \"%s\"", writer, writerPrivs)) 232 | continue 233 | } 234 | schemaPrivilegesOwner := postgres.PostgresSchemaPrivileges{ 235 | DB: database, 236 | Creator: owner, 237 | Role: owner, 238 | Schema: schema, 239 | Privs: writerPrivs, 240 | CreateSchema: true, 241 | } 242 | err = r.pg.SetSchemaPrivileges(schemaPrivilegesOwner, reqLogger) 243 | if err != nil { 244 | reqLogger.Error(err, fmt.Sprintf("Could not give %s permissions \"%s\"", writer, writerPrivs)) 245 | continue 246 | } 247 | 248 | instance.Status.Schemas = append(instance.Status.Schemas, schema) 249 | } 250 | err = r.Status().Patch(ctx, instance, client.MergeFrom(before)) 251 | if err != nil { 252 | return requeue(err) 253 | } 254 | before = instance.DeepCopy() 255 | if controllerutil.AddFinalizer(instance, "finalizer.db.movetokube.com") { 256 | err = r.Patch(ctx, instance, client.MergeFrom(before)) 257 | if err != nil { 258 | return requeue(err) 259 | } 260 | } 261 | 262 | reqLogger.Info("reconciler done", "CR.Namespace", instance.Namespace, "CR.Name", instance.Name) 263 | return ctrl.Result{}, nil 264 | } 265 | func (r *PostgresReconciler) addFinalizer(reqLogger logr.Logger, m *dbv1alpha1.Postgres) error { 266 | if len(m.GetFinalizers()) < 1 && m.GetDeletionTimestamp() == nil { 267 | reqLogger.Info("adding Finalizer for Postgres") 268 | m.SetFinalizers([]string{"finalizer.db.movetokube.com"}) 269 | } 270 | return nil 271 | } 272 | func (r *PostgresReconciler) requeue(cr *dbv1alpha1.Postgres, reason error) (ctrl.Result, error) { 273 | cr.Status.Succeeded = false 274 | return ctrl.Result{}, reason 275 | } 276 | 277 | func (r *PostgresReconciler) shouldDropDB(ctx context.Context, cr *dbv1alpha1.Postgres, logger logr.Logger) bool { 278 | // If DropOnDelete is false we don't need to check any further 279 | if !cr.Spec.DropOnDelete { 280 | return false 281 | } 282 | // Get a list of all Postgres 283 | dbs := dbv1alpha1.PostgresList{} 284 | err := r.List(ctx, &dbs, &client.ListOptions{}) 285 | if err != nil { 286 | logger.Info(fmt.Sprintf("%v", err)) 287 | return true 288 | } 289 | 290 | for _, db := range dbs.Items { 291 | // Skip database if it's the same as the one we're deleting 292 | if db.Name == cr.Name && db.Namespace == cr.Namespace { 293 | continue 294 | } 295 | // There already exists another Postgres who has the same database 296 | // Let's not drop the database 297 | if db.Spec.Database == cr.Spec.Database { 298 | return false 299 | } 300 | } 301 | 302 | return true 303 | } 304 | 305 | // SetupWithManager sets up the controller with the Manager. 306 | func (r *PostgresReconciler) SetupWithManager(mgr ctrl.Manager) error { 307 | return ctrl.NewControllerManagedBy(mgr). 308 | For(&dbv1alpha1.Postgres{}). 309 | Complete(r) 310 | } 311 | -------------------------------------------------------------------------------- /internal/controller/postgresuser_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | 8 | corev1 "k8s.io/api/core/v1" 9 | "k8s.io/apimachinery/pkg/api/errors" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/apimachinery/pkg/types" 13 | ctrl "sigs.k8s.io/controller-runtime" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 16 | "sigs.k8s.io/controller-runtime/pkg/log" 17 | "sigs.k8s.io/controller-runtime/pkg/manager" 18 | 19 | "github.com/go-logr/logr" 20 | dbv1alpha1 "github.com/movetokube/postgres-operator/api/v1alpha1" 21 | "github.com/movetokube/postgres-operator/pkg/config" 22 | "github.com/movetokube/postgres-operator/pkg/postgres" 23 | "github.com/movetokube/postgres-operator/pkg/utils" 24 | ) 25 | 26 | // PostgresUserReconciler reconciles a PostgresUser object 27 | type PostgresUserReconciler struct { 28 | client.Client 29 | Scheme *runtime.Scheme 30 | pg postgres.PG 31 | pgHost string 32 | instanceFilter string 33 | keepSecretName bool // use secret name as defined in PostgresUserSpec 34 | } 35 | 36 | // NewPostgresUserReconciler returns a new reconcile.Reconciler 37 | func NewPostgresUserReconciler(mgr manager.Manager, cfg *config.Cfg, pg postgres.PG) *PostgresUserReconciler { 38 | return &PostgresUserReconciler{ 39 | Client: mgr.GetClient(), 40 | Scheme: mgr.GetScheme(), 41 | pg: pg, 42 | pgHost: cfg.PostgresHost, 43 | instanceFilter: cfg.AnnotationFilter, 44 | keepSecretName: cfg.KeepSecretName, 45 | } 46 | } 47 | 48 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgresusers,verbs=get;list;watch;create;update;patch;delete 49 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgresusers/status,verbs=get;update;patch 50 | // +kubebuilder:rbac:groups=db.movetokube.com,resources=postgresusers/finalizers,verbs=update 51 | 52 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 53 | // move the current state of the cluster closer to the desired state. 54 | // 55 | // For more details, check Reconcile and its Result here: 56 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile 57 | func (r *PostgresUserReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 58 | log := log.FromContext(ctx) 59 | 60 | reqLogger := log.WithValues("Request.Namespace", req.Namespace, "Request.Name", req.Name) 61 | reqLogger.Info("Reconciling PostgresUser") 62 | 63 | // Fetch the PostgresUser instance 64 | instance := &dbv1alpha1.PostgresUser{} 65 | err := r.Get(ctx, req.NamespacedName, instance) 66 | if err != nil { 67 | if errors.IsNotFound(err) { 68 | // Request object not found, could have been deleted after reconcile request. 69 | // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. 70 | // Return and don't requeue 71 | return ctrl.Result{}, nil 72 | } 73 | // Error reading the object - requeue the request. 74 | return ctrl.Result{}, err 75 | } 76 | 77 | if !utils.MatchesInstanceAnnotation(instance.Annotations, r.instanceFilter) { 78 | return ctrl.Result{}, nil 79 | } 80 | 81 | // Deletion logic 82 | if instance.GetDeletionTimestamp() != nil { 83 | if instance.Status.Succeeded && instance.Status.PostgresRole != "" { 84 | // Initialize database name for connection with default database 85 | // in case postgres cr isn't here anymore 86 | db := r.pg.GetDefaultDatabase() 87 | // Search Postgres CR 88 | postgres, err := r.getPostgresCR(ctx, instance) 89 | // Check if error exists and not a not found error 90 | if err != nil && !errors.IsNotFound(err) { 91 | return ctrl.Result{}, err 92 | } 93 | // Check if postgres cr is found and not in deletion state 94 | if postgres != nil && postgres.GetDeletionTimestamp().IsZero() { 95 | db = instance.Status.DatabaseName 96 | } 97 | err = r.pg.DropRole(instance.Status.PostgresRole, instance.Status.PostgresGroup, 98 | db, reqLogger) 99 | if err != nil { 100 | return ctrl.Result{}, err 101 | } 102 | } 103 | controllerutil.RemoveFinalizer(instance, "finalizer.db.movetokube.com") 104 | 105 | // Update CR 106 | err = r.Update(ctx, instance) 107 | if err != nil { 108 | return ctrl.Result{}, err 109 | } 110 | return ctrl.Result{}, nil 111 | } 112 | 113 | // Creation logic 114 | var role, login string 115 | password, err := utils.GetSecureRandomString(15) 116 | 117 | if err != nil { 118 | return r.requeue(ctx, instance, err) 119 | } 120 | 121 | if instance.Status.PostgresRole == "" { 122 | // We need to get the Postgres CR to get the group role name 123 | database, err := r.getPostgresCR(ctx, instance) 124 | if err != nil { 125 | return r.requeue(ctx, instance, errors.NewInternalError(err)) 126 | } 127 | // Create user role 128 | suffix := utils.GetRandomString(6) 129 | role = fmt.Sprintf("%s-%s", instance.Spec.Role, suffix) 130 | login, err = r.pg.CreateUserRole(role, password) 131 | if err != nil { 132 | return r.requeue(ctx, instance, errors.NewInternalError(err)) 133 | } 134 | 135 | // Grant group role to user role 136 | var groupRole string 137 | switch instance.Spec.Privileges { 138 | case "READ": 139 | groupRole = database.Status.Roles.Reader 140 | case "WRITE": 141 | groupRole = database.Status.Roles.Writer 142 | default: 143 | groupRole = database.Status.Roles.Owner 144 | } 145 | 146 | err = r.pg.GrantRole(groupRole, role) 147 | if err != nil { 148 | return r.requeue(ctx, instance, errors.NewInternalError(err)) 149 | } 150 | 151 | // Alter default set role to group role 152 | // This is so that objects created by user gets owned by group role 153 | err = r.pg.AlterDefaultLoginRole(role, groupRole) 154 | if err != nil { 155 | return r.requeue(ctx, instance, errors.NewInternalError(err)) 156 | } 157 | 158 | instance.Status.PostgresRole = role 159 | instance.Status.PostgresGroup = groupRole 160 | instance.Status.PostgresLogin = login 161 | instance.Status.DatabaseName = database.Spec.Database 162 | err = r.Status().Update(ctx, instance) 163 | if err != nil { 164 | return r.requeue(ctx, instance, err) 165 | } 166 | } else { 167 | role = instance.Status.PostgresRole 168 | login = instance.Status.PostgresLogin 169 | } 170 | 171 | err = r.addFinalizer(ctx, reqLogger, instance) 172 | if err != nil { 173 | return r.requeue(ctx, instance, err) 174 | } 175 | err = r.addOwnerRef(ctx, reqLogger, instance) 176 | if err != nil { 177 | return r.requeue(ctx, instance, err) 178 | } 179 | 180 | secret, err := r.newSecretForCR(instance, role, password, login) 181 | if err != nil { 182 | return r.requeue(ctx, instance, err) 183 | } 184 | 185 | // Set PostgresUser instance as the owner and controller 186 | if err := controllerutil.SetControllerReference(instance, secret, r.Scheme); err != nil { 187 | return r.requeue(ctx, instance, err) 188 | } 189 | 190 | // Check if this Secret already exists 191 | found := &corev1.Secret{} 192 | err = r.Get(ctx, types.NamespacedName{Name: secret.Name, Namespace: secret.Namespace}, found) 193 | if err != nil && errors.IsNotFound(err) { 194 | // if role is already created, update password 195 | if instance.Status.Succeeded { 196 | err := r.pg.UpdatePassword(role, password) 197 | if err != nil { 198 | return r.requeue(ctx, instance, err) 199 | } 200 | } 201 | reqLogger.Info("Creating secret", "Secret.Namespace", secret.Namespace, "Secret.Name", secret.Name) 202 | err = r.Create(ctx, secret) 203 | if err != nil { 204 | return ctrl.Result{}, err 205 | } 206 | 207 | // Secret created successfully - don't requeue 208 | return r.finish(ctx, instance) 209 | } else if err != nil { 210 | return r.requeue(ctx, instance, err) 211 | } 212 | 213 | reqLogger.Info("reconciler done", "CR.Namespace", instance.Namespace, "CR.Name", instance.Name) 214 | return ctrl.Result{}, nil 215 | } 216 | 217 | func (r *PostgresUserReconciler) getPostgresCR(ctx context.Context, instance *dbv1alpha1.PostgresUser) (*dbv1alpha1.Postgres, error) { 218 | database := dbv1alpha1.Postgres{} 219 | err := r.Get(ctx, 220 | types.NamespacedName{Namespace: instance.Namespace, Name: instance.Spec.Database}, &database) 221 | if err != nil { 222 | return nil, err 223 | } 224 | if !utils.MatchesInstanceAnnotation(database.Annotations, r.instanceFilter) { 225 | err = fmt.Errorf("database \"%s\" is not managed by this operator", database.Name) 226 | return nil, err 227 | } 228 | if !database.Status.Succeeded { 229 | err = fmt.Errorf("database \"%s\" is not ready", database.Name) 230 | return nil, err 231 | } 232 | return &database, nil 233 | } 234 | 235 | func (r *PostgresUserReconciler) newSecretForCR(cr *dbv1alpha1.PostgresUser, role, password, login string) (*corev1.Secret, error) { 236 | pgUserUrl := fmt.Sprintf("postgresql://%s:%s@%s/%s", role, password, r.pgHost, cr.Status.DatabaseName) 237 | pgJDBCUrl := fmt.Sprintf("jdbc:postgresql://%s/%s", r.pgHost, cr.Status.DatabaseName) 238 | pgDotnetUrl := fmt.Sprintf("User ID=%s;Password=%s;Host=%s;Port=5432;Database=%s;", role, password, r.pgHost, cr.Status.DatabaseName) 239 | labels := map[string]string{ 240 | "app": cr.Name, 241 | } 242 | // Merge in user-defined secret labels 243 | maps.Copy(labels, cr.Spec.Labels) 244 | 245 | annotations := cr.Spec.Annotations 246 | name := fmt.Sprintf("%s-%s", cr.Spec.SecretName, cr.Name) 247 | if r.keepSecretName { 248 | name = cr.Spec.SecretName 249 | } 250 | 251 | templateData, err := utils.RenderTemplate(cr.Spec.SecretTemplate, utils.TemplateContext{ 252 | Role: role, 253 | Host: r.pgHost, 254 | Database: cr.Status.DatabaseName, 255 | Password: password, 256 | }) 257 | if err != nil { 258 | return nil, fmt.Errorf("render templated keys: %w", err) 259 | } 260 | 261 | data := map[string][]byte{ 262 | "POSTGRES_URL": []byte(pgUserUrl), 263 | "POSTGRES_JDBC_URL": []byte(pgJDBCUrl), 264 | "POSTGRES_DOTNET_URL": []byte(pgDotnetUrl), 265 | "HOST": []byte(r.pgHost), 266 | "DATABASE_NAME": []byte(cr.Status.DatabaseName), 267 | "ROLE": []byte(role), 268 | "PASSWORD": []byte(password), 269 | "LOGIN": []byte(login), 270 | } 271 | // templates may override standard keys 272 | if len(templateData) > 0 { 273 | maps.Copy(data, templateData) 274 | } 275 | 276 | return &corev1.Secret{ 277 | ObjectMeta: metav1.ObjectMeta{ 278 | Name: name, 279 | Namespace: cr.Namespace, 280 | Labels: labels, 281 | Annotations: annotations, 282 | }, 283 | Data: data, 284 | }, nil 285 | } 286 | 287 | func (r *PostgresUserReconciler) addFinalizer(ctx context.Context, reqLogger logr.Logger, m *dbv1alpha1.PostgresUser) error { 288 | if len(m.GetFinalizers()) < 1 && m.GetDeletionTimestamp() == nil { 289 | reqLogger.Info("adding Finalizer for Postgres") 290 | m.SetFinalizers([]string{"finalizer.db.movetokube.com"}) 291 | 292 | // Update CR 293 | err := r.Update(ctx, m) 294 | if err != nil { 295 | reqLogger.Error(err, "failed to update PosgresUser with finalizer") 296 | return err 297 | } 298 | } 299 | return nil 300 | } 301 | func (r *PostgresUserReconciler) addOwnerRef(ctx context.Context, reqLogger logr.Logger, instance *dbv1alpha1.PostgresUser) error { 302 | // Search postgres database CR 303 | pg, err := r.getPostgresCR(ctx, instance) 304 | if err != nil { 305 | return err 306 | } 307 | // Update owners 308 | err = controllerutil.SetControllerReference(pg, instance, r.Scheme) 309 | if err != nil { 310 | return err 311 | } 312 | // Update CR 313 | err = r.Update(ctx, instance) 314 | return err 315 | } 316 | 317 | func (r *PostgresUserReconciler) requeue(ctx context.Context, cr *dbv1alpha1.PostgresUser, reason error) (ctrl.Result, error) { 318 | cr.Status.Succeeded = false 319 | err := r.Status().Update(ctx, cr) 320 | if err != nil { 321 | return ctrl.Result{}, err 322 | } 323 | return ctrl.Result{}, reason 324 | } 325 | 326 | func (r *PostgresUserReconciler) finish(ctx context.Context, cr *dbv1alpha1.PostgresUser) (ctrl.Result, error) { 327 | cr.Status.Succeeded = true 328 | err := r.Status().Update(ctx, cr) 329 | if err != nil { 330 | return ctrl.Result{}, err 331 | } 332 | return ctrl.Result{}, nil 333 | } 334 | 335 | // SetupWithManager sets up the controller with the Manager. 336 | func (r *PostgresUserReconciler) SetupWithManager(mgr ctrl.Manager) error { 337 | return ctrl.NewControllerManagedBy(mgr). 338 | For(&dbv1alpha1.PostgresUser{}). 339 | Complete(r) 340 | } 341 | -------------------------------------------------------------------------------- /internal/controller/postgresuser_controller_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/types" 15 | "k8s.io/client-go/kubernetes/scheme" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 18 | 19 | dbv1alpha1 "github.com/movetokube/postgres-operator/api/v1alpha1" 20 | mockpg "github.com/movetokube/postgres-operator/pkg/postgres/mock" 21 | "github.com/movetokube/postgres-operator/pkg/utils" 22 | ) 23 | 24 | var _ = Describe("PostgresUser Controller", func() { 25 | const ( 26 | name = "test-user" 27 | namespace = "operator" 28 | databaseName = "test-db" 29 | secretName = "db-credentials" 30 | roleName = "app" 31 | ) 32 | 33 | var ( 34 | sc *runtime.Scheme 35 | req reconcile.Request 36 | mockCtrl *gomock.Controller 37 | pg *mockpg.MockPG 38 | rp *PostgresUserReconciler 39 | cl client.Client 40 | ) 41 | 42 | initClient := func(postgres *dbv1alpha1.Postgres, user *dbv1alpha1.PostgresUser, markAsDeleted bool) { 43 | if postgres != nil { 44 | pgStatusCopy := postgres.Status.DeepCopy() 45 | Expect(cl.Create(ctx, postgres)).To(BeNil()) 46 | pgStatusCopy.DeepCopyInto(&postgres.Status) 47 | Expect(cl.Status().Update(ctx, postgres)).To(BeNil()) 48 | } 49 | 50 | if user != nil { 51 | userStatusCopy := user.Status.DeepCopy() 52 | if markAsDeleted { 53 | user.SetFinalizers([]string{"finalizer.db.movetokube.com"}) 54 | } 55 | Expect(cl.Create(ctx, user)).To(BeNil()) 56 | userStatusCopy.DeepCopyInto(&user.Status) 57 | Expect(cl.Status().Update(ctx, user)).To(BeNil()) 58 | if markAsDeleted { 59 | Expect(cl.Delete(ctx, user, &client.DeleteOptions{GracePeriodSeconds: new(int64)})).To(BeNil()) 60 | } 61 | } 62 | } 63 | 64 | runReconcile := func(rp *PostgresUserReconciler, ctx context.Context, req reconcile.Request) (err error) { 65 | _, err = rp.Reconcile(ctx, req) 66 | if k8sManager != nil { 67 | k8sManager.GetCache().WaitForCacheSync(ctx) 68 | } 69 | return err 70 | } 71 | 72 | clearUsers := func(namespace string) error { 73 | l := dbv1alpha1.PostgresUserList{} 74 | err := k8sClient.List(ctx, &l, client.InNamespace(namespace)) 75 | Expect(err).ToNot(HaveOccurred()) 76 | for _, el := range l.Items { 77 | org := el.DeepCopy() 78 | el.SetFinalizers(nil) 79 | err = k8sClient.Patch(ctx, &el, client.MergeFrom(org)) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | return k8sClient.DeleteAllOf(ctx, &dbv1alpha1.PostgresUser{}, client.InNamespace(namespace)) 85 | } 86 | 87 | BeforeEach(func() { 88 | // Gomock 89 | mockCtrl = gomock.NewController(GinkgoT()) 90 | pg = mockpg.NewMockPG(mockCtrl) 91 | cl = k8sClient 92 | // Create runtime scheme 93 | sc = scheme.Scheme 94 | sc.AddKnownTypes(dbv1alpha1.GroupVersion, &dbv1alpha1.Postgres{}) 95 | sc.AddKnownTypes(dbv1alpha1.GroupVersion, &dbv1alpha1.PostgresList{}) 96 | sc.AddKnownTypes(dbv1alpha1.GroupVersion, &dbv1alpha1.PostgresUser{}) 97 | sc.AddKnownTypes(dbv1alpha1.GroupVersion, &dbv1alpha1.PostgresUserList{}) 98 | // Create PostgresUserReconciler 99 | rp = &PostgresUserReconciler{ 100 | Client: managerClient, 101 | Scheme: sc, 102 | pg: pg, 103 | pgHost: "postgres.local", 104 | } 105 | if k8sManager != nil { 106 | rp.SetupWithManager(k8sManager) 107 | } 108 | // Create mock reconcile request 109 | req = reconcile.Request{ 110 | NamespacedName: types.NamespacedName{ 111 | Name: name, 112 | Namespace: namespace, 113 | }, 114 | } 115 | }) 116 | 117 | AfterEach(func() { 118 | Expect(clearPgs(namespace)).To(BeNil()) 119 | Expect(clearUsers(namespace)).To(BeNil()) 120 | if k8sManager != nil { 121 | k8sManager.GetCache().WaitForCacheSync(ctx) 122 | } 123 | mockCtrl.Finish() 124 | }) 125 | 126 | It("should not requeue if PostgresUser does not exist", func() { 127 | // Call Reconcile 128 | res, err := rp.Reconcile(ctx, req) 129 | // No error should be returned 130 | Expect(err).NotTo(HaveOccurred()) 131 | // Request should not be requeued 132 | Expect(res.Requeue).To(BeFalse()) 133 | }) 134 | 135 | Describe("Checking deletion logic", func() { 136 | var ( 137 | postgresDB *dbv1alpha1.Postgres 138 | postgresUser *dbv1alpha1.PostgresUser 139 | ) 140 | 141 | BeforeEach(func() { 142 | postgresDB = &dbv1alpha1.Postgres{ 143 | ObjectMeta: metav1.ObjectMeta{ 144 | Name: databaseName, 145 | Namespace: namespace, 146 | }, 147 | Spec: dbv1alpha1.PostgresSpec{ 148 | Database: databaseName, 149 | }, 150 | Status: dbv1alpha1.PostgresStatus{ 151 | Succeeded: true, 152 | Roles: dbv1alpha1.PostgresRoles{ 153 | Owner: databaseName + "-group", 154 | Reader: databaseName + "-reader", 155 | Writer: databaseName + "-writer", 156 | }, 157 | }, 158 | } 159 | 160 | postgresUser = &dbv1alpha1.PostgresUser{ 161 | ObjectMeta: metav1.ObjectMeta{ 162 | Name: name, 163 | Namespace: namespace, 164 | }, 165 | Spec: dbv1alpha1.PostgresUserSpec{ 166 | Database: databaseName, 167 | SecretName: secretName, 168 | Role: roleName, 169 | Privileges: "WRITE", 170 | }, 171 | Status: dbv1alpha1.PostgresUserStatus{ 172 | Succeeded: true, 173 | PostgresGroup: databaseName + "-writer", 174 | PostgresRole: "mockuser", 175 | DatabaseName: databaseName, 176 | }, 177 | } 178 | }) 179 | 180 | Context("User deletion", func() { 181 | BeforeEach(func() { 182 | initClient(postgresDB, postgresUser, true) 183 | }) 184 | 185 | It("should drop the role and remove finalizer", func() { 186 | // Expect DropRole to be called 187 | pg.EXPECT().GetDefaultDatabase().Return("postgres") 188 | pg.EXPECT().DropRole(postgresUser.Status.PostgresRole, postgresUser.Status.PostgresGroup, 189 | databaseName, gomock.Any()).Return(nil) 190 | 191 | // Call Reconcile 192 | err := runReconcile(rp, ctx, req) 193 | Expect(err).NotTo(HaveOccurred()) 194 | 195 | // Check if PostgresUser was properly deleted 196 | foundUser := &dbv1alpha1.PostgresUser{} 197 | err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) 198 | if err != nil { 199 | Expect(errors.IsNotFound(err)).To(BeTrue()) 200 | } else { 201 | Expect(foundUser.GetFinalizers()).To(BeEmpty()) 202 | } 203 | }) 204 | 205 | It("should return an error if role dropping fails", func() { 206 | // Expect DropRole to fail 207 | pg.EXPECT().GetDefaultDatabase().Return("postgres") 208 | pg.EXPECT().DropRole(postgresUser.Status.PostgresRole, postgresUser.Status.PostgresGroup, 209 | databaseName, gomock.Any()).Return(fmt.Errorf("failed to drop role")) 210 | // Call Reconcile 211 | err := runReconcile(rp, ctx, req) 212 | Expect(err).To(HaveOccurred()) 213 | 214 | // Check if PostgresUser still has finalizer 215 | foundUser := &dbv1alpha1.PostgresUser{} 216 | err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) 217 | Expect(err).NotTo(HaveOccurred()) 218 | Expect(foundUser.GetFinalizers()).NotTo(BeEmpty()) 219 | }) 220 | }) 221 | }) 222 | 223 | Describe("Checking creation logic", func() { 224 | var ( 225 | postgresDB *dbv1alpha1.Postgres 226 | postgresUser *dbv1alpha1.PostgresUser 227 | ) 228 | 229 | BeforeEach(func() { 230 | postgresDB = &dbv1alpha1.Postgres{ 231 | ObjectMeta: metav1.ObjectMeta{ 232 | Name: databaseName, 233 | Namespace: namespace, 234 | }, 235 | Spec: dbv1alpha1.PostgresSpec{ 236 | Database: databaseName, 237 | }, 238 | Status: dbv1alpha1.PostgresStatus{ 239 | Succeeded: true, 240 | Roles: dbv1alpha1.PostgresRoles{ 241 | Owner: databaseName + "-group", 242 | Reader: databaseName + "-reader", 243 | Writer: databaseName + "-writer", 244 | }, 245 | }, 246 | } 247 | 248 | postgresUser = &dbv1alpha1.PostgresUser{ 249 | ObjectMeta: metav1.ObjectMeta{ 250 | Name: name, 251 | Namespace: namespace, 252 | }, 253 | Spec: dbv1alpha1.PostgresUserSpec{ 254 | Database: databaseName, 255 | SecretName: secretName, 256 | Role: roleName, 257 | Privileges: "WRITE", 258 | }, 259 | } 260 | }) 261 | 262 | Context("New PostgresUser creation", func() { 263 | BeforeEach(func() { 264 | // Create database but not the user yet 265 | initClient(postgresDB, nil, false) 266 | 267 | // Do not create the user yet, the reconciler will do it 268 | Expect(cl.Create(ctx, postgresUser)).To(Succeed()) 269 | }) 270 | 271 | AfterEach(func() { 272 | // Clean up any created secrets 273 | secretList := &corev1.SecretList{} 274 | Expect(cl.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) 275 | for _, secret := range secretList.Items { 276 | Expect(cl.Delete(ctx, &secret)).To(Succeed()) 277 | } 278 | }) 279 | 280 | It("should create user role, grant privileges, and create a secret", func() { 281 | var capturedRole string 282 | // Mock expected calls 283 | pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() 284 | pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).DoAndReturn( 285 | func(role, password string) (string, error) { 286 | Expect(role).To(HavePrefix(roleName + "-")) 287 | capturedRole = role 288 | return role, nil 289 | }) 290 | pg.EXPECT().GrantRole(databaseName+"-writer", gomock.Any()).DoAndReturn( 291 | func(groupRole, role string) error { 292 | Expect(role).To(Equal(capturedRole)) 293 | return nil 294 | }) 295 | pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).DoAndReturn( 296 | func(role, groupRole string) error { 297 | Expect(role).To(Equal(capturedRole)) 298 | Expect(groupRole).To(Equal(databaseName + "-writer")) 299 | return nil 300 | }) 301 | 302 | // Call Reconcile 303 | err := runReconcile(rp, ctx, req) 304 | Expect(err).NotTo(HaveOccurred()) 305 | 306 | // Check if PostgresUser status was properly updated 307 | foundUser := &dbv1alpha1.PostgresUser{} 308 | err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) 309 | Expect(err).NotTo(HaveOccurred()) 310 | Expect(foundUser.Status.Succeeded).To(BeTrue()) 311 | Expect(foundUser.Status.PostgresRole).To(HavePrefix(roleName + "-")) 312 | Expect(foundUser.Status.PostgresGroup).To(Equal(databaseName + "-writer")) 313 | Expect(foundUser.Status.DatabaseName).To(Equal(databaseName)) 314 | 315 | // Check if secret was created 316 | foundSecret := &corev1.Secret{} 317 | err = cl.Get(ctx, types.NamespacedName{Name: secretName + "-" + name, Namespace: namespace}, foundSecret) 318 | Expect(err).NotTo(HaveOccurred()) 319 | Expect(foundSecret.Data).To(HaveKey("DATABASE_NAME")) 320 | Expect(foundSecret.Data).To(HaveKey("HOST")) 321 | Expect(foundSecret.Data).To(HaveKey("LOGIN")) 322 | Expect(foundSecret.Data).To(HaveKey("PASSWORD")) 323 | Expect(foundSecret.Data).To(HaveKey("POSTGRES_DOTNET_URL")) 324 | Expect(foundSecret.Data).To(HaveKey("POSTGRES_JDBC_URL")) 325 | Expect(foundSecret.Data).To(HaveKey("POSTGRES_URL")) 326 | Expect(foundSecret.Data).To(HaveKey("ROLE")) 327 | }) 328 | 329 | It("should fail if the database does not exist", func() { 330 | // Delete the postgres DB 331 | Expect(cl.Delete(ctx, postgresDB)).To(Succeed()) 332 | 333 | // Set up a new PostgresUser with a non-existent database 334 | nonExistentUser := &dbv1alpha1.PostgresUser{ 335 | ObjectMeta: metav1.ObjectMeta{ 336 | Name: "nonexistent-user", 337 | Namespace: namespace, 338 | }, 339 | Spec: dbv1alpha1.PostgresUserSpec{ 340 | Database: "nonexistent-db", 341 | SecretName: secretName, 342 | Role: roleName, 343 | Privileges: "WRITE", 344 | }, 345 | } 346 | Expect(cl.Create(ctx, nonExistentUser)).To(Succeed()) 347 | 348 | // Call Reconcile 349 | req := reconcile.Request{ 350 | NamespacedName: types.NamespacedName{ 351 | Name: "nonexistent-user", 352 | Namespace: namespace, 353 | }, 354 | } 355 | _, err := rp.Reconcile(ctx, req) 356 | Expect(err).To(HaveOccurred()) 357 | 358 | // Check status 359 | foundUser := &dbv1alpha1.PostgresUser{} 360 | err = cl.Get(ctx, types.NamespacedName{Name: "nonexistent-user", Namespace: namespace}, foundUser) 361 | Expect(err).NotTo(HaveOccurred()) 362 | Expect(foundUser.Status.Succeeded).To(BeFalse()) 363 | }) 364 | }) 365 | 366 | Context("Instance filter", func() { 367 | BeforeEach(func() { 368 | // Set up annotated resources 369 | postgresDBWithAnnotation := postgresDB.DeepCopy() 370 | postgresDBWithAnnotation.Annotations = map[string]string{ 371 | utils.INSTANCE_ANNOTATION: "my-instance", 372 | } 373 | 374 | postgresUserWithAnnotation := postgresUser.DeepCopy() 375 | postgresUserWithAnnotation.Annotations = map[string]string{ 376 | utils.INSTANCE_ANNOTATION: "my-instance", 377 | } 378 | 379 | initClient(postgresDBWithAnnotation, postgresUserWithAnnotation, false) 380 | 381 | // Set up the reconciler with instance filter 382 | rp.instanceFilter = "my-instance" 383 | }) 384 | AfterEach(func() { 385 | // Clean up any created secrets 386 | secretList := &corev1.SecretList{} 387 | Expect(cl.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) 388 | for _, secret := range secretList.Items { 389 | Expect(cl.Delete(ctx, &secret)).To(Succeed()) 390 | } 391 | }) 392 | 393 | It("should process users with matching instance annotation", func() { 394 | // Mock expected calls for a successful reconciliation 395 | pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() 396 | pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).Return(roleName+"-mockrole", nil) 397 | pg.EXPECT().GrantRole(gomock.Any(), gomock.Any()).Return(nil) 398 | pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) 399 | 400 | // Call Reconcile 401 | err := runReconcile(rp, ctx, req) 402 | Expect(err).NotTo(HaveOccurred()) 403 | }) 404 | 405 | It("should not process users with non-matching instance annotation", func() { 406 | // Create a user with different annotation 407 | userWithDifferentAnnotation := postgresUser.DeepCopy() 408 | userWithDifferentAnnotation.Name = "different-annotation-user" 409 | userWithDifferentAnnotation.Annotations = map[string]string{ 410 | utils.INSTANCE_ANNOTATION: "different-instance", 411 | } 412 | Expect(cl.Create(ctx, userWithDifferentAnnotation)).To(Succeed()) 413 | 414 | // Call Reconcile with the different user 415 | reqDifferent := reconcile.Request{ 416 | NamespacedName: types.NamespacedName{ 417 | Name: "different-annotation-user", 418 | Namespace: namespace, 419 | }, 420 | } 421 | err := runReconcile(rp, ctx, reqDifferent) 422 | Expect(err).NotTo(HaveOccurred()) 423 | 424 | // Verify that the user wasn't processed (status.PostgresRole should be empty) 425 | foundUser := &dbv1alpha1.PostgresUser{} 426 | err = cl.Get(ctx, types.NamespacedName{Name: "different-annotation-user", Namespace: namespace}, foundUser) 427 | Expect(err).NotTo(HaveOccurred()) 428 | Expect(foundUser.Status.PostgresRole).To(Equal("")) 429 | }) 430 | }) 431 | 432 | Context("Secret template", func() { 433 | BeforeEach(func() { 434 | userWithTemplate := postgresUser.DeepCopy() 435 | userWithTemplate.Spec.SecretTemplate = map[string]string{ 436 | "CUSTOM_KEY": "User: {{.Role}}, DB: {{.Database}}", 437 | "PGPASSWORD": "{{.Password}}", 438 | } 439 | 440 | initClient(postgresDB, userWithTemplate, false) 441 | }) 442 | AfterEach(func() { 443 | // Clean up any created secrets 444 | secretList := &corev1.SecretList{} 445 | Expect(cl.List(ctx, secretList, client.InNamespace(namespace))).To(Succeed()) 446 | for _, secret := range secretList.Items { 447 | Expect(cl.Delete(ctx, &secret)).To(Succeed()) 448 | } 449 | }) 450 | 451 | It("should render templates in the secret", func() { 452 | // Mock expected calls 453 | pg.EXPECT().GetDefaultDatabase().Return("postgres").AnyTimes() 454 | pg.EXPECT().CreateUserRole(gomock.Any(), gomock.Any()).Return("app-mockedRole", nil) 455 | pg.EXPECT().GrantRole(gomock.Any(), gomock.Any()).Return(nil) 456 | pg.EXPECT().AlterDefaultLoginRole(gomock.Any(), gomock.Any()).Return(nil) 457 | 458 | // Call Reconcile 459 | err := runReconcile(rp, ctx, req) 460 | Expect(err).NotTo(HaveOccurred()) 461 | 462 | // Let's update the user status manually to mark it as succeeded 463 | // This should trigger creation of the secret with templates in our second reconcile 464 | foundUser := &dbv1alpha1.PostgresUser{} 465 | err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundUser) 466 | Expect(err).NotTo(HaveOccurred()) 467 | 468 | // Set the status to succeeded 469 | foundUser.Status.Succeeded = true 470 | err = cl.Status().Update(ctx, foundUser) 471 | Expect(err).NotTo(HaveOccurred()) 472 | 473 | // Run another reconcile which should update the secret with the correct templates 474 | err = runReconcile(rp, ctx, req) 475 | Expect(err).NotTo(HaveOccurred()) 476 | 477 | // Now check if the secret was created with the templated values 478 | foundSecret := &corev1.Secret{} 479 | name := fmt.Sprintf("%s-%s", secretName, name) 480 | GinkgoWriter.Printf("Getting secret %s\n", name) 481 | err = cl.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, foundSecret) 482 | Expect(err).NotTo(HaveOccurred()) 483 | 484 | // Get the role from the actual secret (since it might differ from what we expect) 485 | actualRole := string(foundSecret.Data["ROLE"]) 486 | GinkgoWriter.Printf("Actual role: %s\n", actualRole) 487 | 488 | // Check if POSTGRES_URL contains the actual role from the secret 489 | pgUrl := string(foundSecret.Data["POSTGRES_URL"]) 490 | Expect(pgUrl).To(ContainSubstring(actualRole)) 491 | 492 | // Check if the template was applied using the data in the actual secret 493 | // Directly check the custom keys we're expecting 494 | Expect(foundSecret.Data).To(HaveKey("CUSTOM_KEY")) 495 | customKey := string(foundSecret.Data["CUSTOM_KEY"]) 496 | Expect(customKey).To(ContainSubstring("User: " + actualRole)) 497 | Expect(customKey).To(ContainSubstring("DB: " + databaseName)) 498 | 499 | // Check PGPASSWORD is present (should be generated from template) 500 | Expect(foundSecret.Data).To(HaveKey("PGPASSWORD")) 501 | pgPassword := string(foundSecret.Data["PGPASSWORD"]) 502 | Expect(pgPassword).NotTo(BeEmpty()) 503 | }) 504 | }) 505 | }) 506 | Context("Secret creation with user-defined labels and annotations", func() { 507 | It("should create a secret with user-defined labels and annotations", func() { 508 | // Set up the reconciler with host and keepSecretName setting 509 | rp.pgHost = "localhost" 510 | rp.keepSecretName = false 511 | 512 | // Create a PostgresUser with custom labels and annotations 513 | cr := &dbv1alpha1.PostgresUser{ 514 | ObjectMeta: metav1.ObjectMeta{ 515 | Name: "myuser", 516 | Namespace: "myns", 517 | }, 518 | Spec: dbv1alpha1.PostgresUserSpec{ 519 | SecretName: "mysecret", 520 | Labels: map[string]string{ 521 | "custom": "label", 522 | "foo": "bar", 523 | }, 524 | }, 525 | Status: dbv1alpha1.PostgresUserStatus{ 526 | DatabaseName: "somedb", 527 | }, 528 | } 529 | 530 | // Call newSecretForCR with test values 531 | secret, err := rp.newSecretForCR(cr, "role1", "pass1", "login1") 532 | 533 | // Verify results 534 | Expect(err).NotTo(HaveOccurred()) 535 | 536 | // Check labels 537 | expectedLabels := map[string]string{ 538 | "app": "myuser", 539 | "custom": "label", 540 | "foo": "bar", 541 | } 542 | Expect(secret.Labels).To(Equal(expectedLabels)) 543 | 544 | // Check name and namespace 545 | Expect(secret.Name).To(Equal("mysecret-myuser")) 546 | Expect(secret.Namespace).To(Equal("myns")) 547 | 548 | // Check secret data 549 | Expect(string(secret.Data["ROLE"])).To(Equal("role1")) 550 | Expect(string(secret.Data["PASSWORD"])).To(Equal("pass1")) 551 | Expect(string(secret.Data["LOGIN"])).To(Equal("login1")) 552 | Expect(string(secret.Data["DATABASE_NAME"])).To(Equal("somedb")) 553 | Expect(string(secret.Data["HOST"])).To(Equal("localhost")) 554 | }) 555 | 556 | It("should handle empty labels map correctly", func() { 557 | // Set up the reconciler 558 | rp.pgHost = "localhost" 559 | rp.keepSecretName = false 560 | 561 | // Create a PostgresUser with empty labels 562 | cr := &dbv1alpha1.PostgresUser{ 563 | ObjectMeta: metav1.ObjectMeta{ 564 | Name: "myuser2", 565 | Namespace: "myns2", 566 | }, 567 | Spec: dbv1alpha1.PostgresUserSpec{ 568 | SecretName: "mysecret2", 569 | Labels: map[string]string{}, 570 | }, 571 | Status: dbv1alpha1.PostgresUserStatus{ 572 | DatabaseName: "somedb2", 573 | }, 574 | } 575 | 576 | // Call newSecretForCR 577 | secret, err := rp.newSecretForCR(cr, "role2", "pass2", "login2") 578 | 579 | // Verify results 580 | Expect(err).NotTo(HaveOccurred()) 581 | 582 | // Check that default labels are applied 583 | expectedLabels := map[string]string{ 584 | "app": "myuser2", 585 | } 586 | Expect(secret.Labels).To(Equal(expectedLabels)) 587 | 588 | // Check name and namespace 589 | Expect(secret.Name).To(Equal("mysecret2-myuser2")) 590 | Expect(secret.Namespace).To(Equal("myns2")) 591 | }) 592 | 593 | It("should respect keepSecretName setting when true", func() { 594 | // Set up the reconciler with keepSecretName=true 595 | rp.pgHost = "localhost" 596 | rp.keepSecretName = true 597 | 598 | // Create a PostgresUser 599 | cr := &dbv1alpha1.PostgresUser{ 600 | ObjectMeta: metav1.ObjectMeta{ 601 | Name: "myuser3", 602 | Namespace: "myns3", 603 | }, 604 | Spec: dbv1alpha1.PostgresUserSpec{ 605 | SecretName: "mysecret3", 606 | Labels: map[string]string{}, 607 | }, 608 | Status: dbv1alpha1.PostgresUserStatus{ 609 | DatabaseName: "somedb3", 610 | }, 611 | } 612 | 613 | // Call newSecretForCR 614 | secret, err := rp.newSecretForCR(cr, "role3", "pass3", "login3") 615 | 616 | // Verify results 617 | Expect(err).NotTo(HaveOccurred()) 618 | 619 | // Check that the original secret name is kept without appending the CR name 620 | Expect(secret.Name).To(Equal("mysecret3")) 621 | }) 622 | }) 623 | }) 624 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | 14 | corev1 "k8s.io/api/core/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | 17 | "k8s.io/client-go/kubernetes/scheme" 18 | "k8s.io/client-go/rest" 19 | ctrl "sigs.k8s.io/controller-runtime" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 22 | "sigs.k8s.io/controller-runtime/pkg/envtest" 23 | logf "sigs.k8s.io/controller-runtime/pkg/log" 24 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 25 | "sigs.k8s.io/controller-runtime/pkg/manager" 26 | 27 | "github.com/movetokube/postgres-operator/api/v1alpha1" 28 | dbv1alpha1 "github.com/movetokube/postgres-operator/api/v1alpha1" 29 | // +kubebuilder:scaffold:imports 30 | ) 31 | 32 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 33 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 34 | 35 | var cfg *rest.Config 36 | var k8sClient client.Client 37 | var managerClient client.Client 38 | var testEnv *envtest.Environment 39 | var ctx context.Context 40 | var cancel context.CancelFunc 41 | 42 | var k8sManager manager.Manager 43 | var realClient bool 44 | 45 | func TestControllers(t *testing.T) { 46 | RegisterFailHandler(Fail) 47 | 48 | RunSpecs(t, "Controller Suite") 49 | } 50 | 51 | func clearPgs(namespace string) (err error) { 52 | l := dbv1alpha1.PostgresList{} 53 | err = k8sClient.List(ctx, &l, client.InNamespace(namespace)) 54 | Expect(err).ToNot(HaveOccurred()) 55 | for _, el := range l.Items { 56 | org := el.DeepCopy() 57 | el.SetFinalizers(nil) 58 | err = k8sClient.Patch(ctx, &el, client.MergeFrom(org)) 59 | if err != nil { 60 | return 61 | } 62 | } 63 | return k8sClient.DeleteAllOf(ctx, &dbv1alpha1.Postgres{}, client.InNamespace(namespace)) 64 | } 65 | 66 | var _ = BeforeSuite(func() { 67 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 68 | 69 | ctx, cancel = context.WithCancel(context.TODO()) 70 | 71 | By("bootstrapping test environment") 72 | _, realClient = os.LookupEnv("ENVTEST_K8S_VERSION") 73 | var err error 74 | if realClient { 75 | testEnv = &envtest.Environment{ 76 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 77 | ErrorIfCRDPathMissing: true, 78 | 79 | // The BinaryAssetsDirectory is only required if you want to run the tests directly 80 | // without call the makefile target test. If not informed it will look for the 81 | // default path defined in controller-runtime which is /usr/local/kubebuilder/. 82 | // Note that you must have the required binaries setup under the bin directory to perform 83 | // the tests directly. When we run make test it will be setup and used automatically. 84 | BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s", 85 | fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), 86 | } 87 | 88 | var err error 89 | // cfg is defined in this file globally. 90 | cfg, err = testEnv.Start() 91 | Expect(err).NotTo(HaveOccurred()) 92 | Expect(cfg).NotTo(BeNil()) 93 | } 94 | 95 | err = dbv1alpha1.AddToScheme(scheme.Scheme) 96 | Expect(err).NotTo(HaveOccurred()) 97 | 98 | // +kubebuilder:scaffold:scheme 99 | 100 | if realClient { 101 | k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ 102 | Scheme: scheme.Scheme, 103 | }) 104 | Expect(err).NotTo(HaveOccurred()) 105 | go func() { 106 | defer GinkgoRecover() 107 | err = k8sManager.Start(ctx) 108 | Expect(err).ToNot(HaveOccurred(), "failed to run manager") 109 | }() 110 | managerClient = k8sManager.GetClient() 111 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 112 | Expect(k8sClient).NotTo(BeNil()) 113 | } else { 114 | k8sClient = fake.NewClientBuilder().WithScheme(scheme.Scheme).WithStatusSubresource(&v1alpha1.Postgres{}, &v1alpha1.PostgresUser{}).Build() 115 | managerClient = k8sClient 116 | } 117 | Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ 118 | Name: "operator", 119 | }})).NotTo(HaveOccurred()) 120 | 121 | }) 122 | 123 | var _ = AfterSuite(func() { 124 | By("tearing down the test environment") 125 | cancel() 126 | if testEnv != nil { 127 | err := testEnv.Stop() 128 | Expect(err).NotTo(HaveOccurred()) 129 | } 130 | }) 131 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "sync" 7 | 8 | "github.com/movetokube/postgres-operator/pkg/utils" 9 | ) 10 | 11 | type Cfg struct { 12 | PostgresHost string 13 | PostgresUser string 14 | PostgresPass string 15 | PostgresUriArgs string 16 | PostgresDefaultDb string 17 | CloudProvider string 18 | AnnotationFilter string 19 | KeepSecretName bool 20 | } 21 | 22 | var doOnce sync.Once 23 | var config *Cfg 24 | 25 | func Get() *Cfg { 26 | doOnce.Do(func() { 27 | config = &Cfg{} 28 | config.PostgresHost = utils.MustGetEnv("POSTGRES_HOST") 29 | config.PostgresUser = url.PathEscape(utils.MustGetEnv("POSTGRES_USER")) 30 | config.PostgresPass = url.PathEscape(utils.MustGetEnv("POSTGRES_PASS")) 31 | config.PostgresUriArgs = utils.MustGetEnv("POSTGRES_URI_ARGS") 32 | config.PostgresDefaultDb = utils.GetEnv("POSTGRES_DEFAULT_DATABASE") 33 | config.CloudProvider = utils.GetEnv("POSTGRES_CLOUD_PROVIDER") 34 | config.AnnotationFilter = utils.GetEnv("POSTGRES_INSTANCE") 35 | if value, err := strconv.ParseBool(utils.GetEnv("KEEP_SECRET_NAME")); err == nil { 36 | config.KeepSecretName = value 37 | } 38 | }) 39 | return config 40 | } 41 | -------------------------------------------------------------------------------- /pkg/postgres/aws.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | type awspg struct { 11 | pg 12 | } 13 | 14 | func newAWSPG(postgres *pg) PG { 15 | return &awspg{ 16 | *postgres, 17 | } 18 | } 19 | 20 | func (c *awspg) AlterDefaultLoginRole(role, setRole string) error { 21 | // On AWS RDS the postgres user isn't really superuser so he doesn't have permissions 22 | // to ALTER USER unless he belongs to both roles 23 | err := c.GrantRole(role, c.user) 24 | if err != nil { 25 | return err 26 | } 27 | defer c.RevokeRole(role, c.user) 28 | 29 | return c.pg.AlterDefaultLoginRole(role, setRole) 30 | } 31 | 32 | func (c *awspg) CreateDB(dbname, role string) error { 33 | // Have to add the master role to the group role before we can transfer the database owner 34 | err := c.GrantRole(role, c.user) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return c.pg.CreateDB(dbname, role) 40 | } 41 | 42 | func (c *awspg) CreateUserRole(role, password string) (string, error) { 43 | returnedRole, err := c.pg.CreateUserRole(role, password) 44 | if err != nil { 45 | return "", err 46 | } 47 | // On AWS RDS the postgres user isn't really superuser so he doesn't have permissions 48 | // to ALTER DEFAULT PRIVILEGES FOR ROLE unless he belongs to the role 49 | err = c.GrantRole(role, c.user) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | return returnedRole, nil 55 | } 56 | 57 | func (c *awspg) DropRole(role, newOwner, database string, logger logr.Logger) error { 58 | // On AWS RDS the postgres user isn't really superuser so he doesn't have permissions 59 | // to REASSIGN OWNED BY unless he belongs to both roles 60 | err := c.GrantRole(role, c.user) 61 | if err != nil && err.(*pq.Error).Code != "0LP01" { 62 | if err.(*pq.Error).Code == "42704" { 63 | // The group role does not exist, no point in continuing 64 | return nil 65 | } 66 | return err 67 | } 68 | err = c.GrantRole(newOwner, c.user) 69 | if err != nil && err.(*pq.Error).Code != "0LP01" { 70 | if err.(*pq.Error).Code == "42704" { 71 | // The group role does not exist, no point of granting roles 72 | logger.Info(fmt.Sprintf("not granting %s to %s as %s does not exist", role, newOwner, newOwner)) 73 | return nil 74 | } 75 | return err 76 | } 77 | defer c.RevokeRole(newOwner, c.user) 78 | 79 | return c.pg.DropRole(role, newOwner, database, logger) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/postgres/azure.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "github.com/go-logr/logr" 5 | "github.com/lib/pq" 6 | ) 7 | 8 | type AzureType string 9 | 10 | type azurepg struct { 11 | pg 12 | } 13 | 14 | func newAzurePG(postgres *pg) PG { 15 | return &azurepg{ 16 | pg: *postgres, 17 | } 18 | } 19 | 20 | func (azpg *azurepg) CreateUserRole(role, password string) (string, error) { 21 | returnedRole, err := azpg.pg.CreateUserRole(role, password) 22 | if err != nil { 23 | return "", err 24 | } 25 | return returnedRole, nil 26 | } 27 | 28 | func (azpg *azurepg) CreateDB(dbname, role string) error { 29 | // This step is necessary before we can set the specified role as the database owner 30 | err := azpg.GrantRole(role, azpg.user) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return azpg.pg.CreateDB(dbname, role) 36 | } 37 | 38 | func (azpg *azurepg) DropRole(role, newOwner, database string, logger logr.Logger) error { 39 | // Grant the role to the user first 40 | err := azpg.GrantRole(role, azpg.user) 41 | if err != nil && err.(*pq.Error).Code != "0LP01" { 42 | if err.(*pq.Error).Code == "42704" { 43 | return nil 44 | } 45 | return err 46 | } 47 | 48 | // Delegate to parent implementation to perform the actual drop 49 | return azpg.pg.DropRole(role, newOwner, database, logger) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/postgres/database.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | const ( 11 | CREATE_DB = `CREATE DATABASE "%s"` 12 | CREATE_SCHEMA = `CREATE SCHEMA IF NOT EXISTS "%s" AUTHORIZATION "%s"` 13 | CREATE_EXTENSION = `CREATE EXTENSION IF NOT EXISTS "%s"` 14 | ALTER_DB_OWNER = `ALTER DATABASE "%s" OWNER TO "%s"` 15 | DROP_DATABASE = `DROP DATABASE "%s"` 16 | GRANT_USAGE_SCHEMA = `GRANT USAGE ON SCHEMA "%s" TO "%s"` 17 | GRANT_CREATE_TABLE = `GRANT CREATE ON SCHEMA "%s" TO "%s"` 18 | GRANT_ALL_TABLES = `GRANT %s ON ALL TABLES IN SCHEMA "%s" TO "%s"` 19 | DEFAULT_PRIVS_SCHEMA = `ALTER DEFAULT PRIVILEGES FOR ROLE "%s" IN SCHEMA "%s" GRANT %s ON TABLES TO "%s"` 20 | REVOKE_CONNECT = `REVOKE CONNECT ON DATABASE "%s" FROM public` 21 | TERMINATE_BACKEND = `SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '%s' AND pid <> pg_backend_pid()` 22 | GET_DB_OWNER = `SELECT pg_catalog.pg_get_userbyid(d.datdba) FROM pg_catalog.pg_database d WHERE d.datname = '%s'` 23 | GRANT_CREATE_SCHEMA = `GRANT CREATE ON DATABASE "%s" TO "%s"` 24 | ) 25 | 26 | func (c *pg) CreateDB(dbname, role string) error { 27 | _, err := c.db.Exec(fmt.Sprintf(CREATE_DB, dbname)) 28 | if err != nil { 29 | // eat DUPLICATE DATABASE ERROR 30 | if err.(*pq.Error).Code != "42P04" { 31 | return err 32 | } 33 | } 34 | 35 | _, err = c.db.Exec(fmt.Sprintf(ALTER_DB_OWNER, dbname, role)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | _, err = c.db.Exec(fmt.Sprintf(GRANT_CREATE_SCHEMA, dbname, role)) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func (c *pg) CreateSchema(db, role, schema string, logger logr.Logger) error { 48 | tmpDb, err := GetConnection(c.user, c.pass, c.host, db, c.args, logger) 49 | if err != nil { 50 | return err 51 | } 52 | defer tmpDb.Close() 53 | 54 | _, err = tmpDb.Exec(fmt.Sprintf(CREATE_SCHEMA, schema, role)) 55 | if err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (c *pg) DropDatabase(database string, logger logr.Logger) error { 62 | _, err := c.db.Exec(fmt.Sprintf(REVOKE_CONNECT, database)) 63 | // Error code 3D000 is returned if database doesn't exist 64 | if err != nil && err.(*pq.Error).Code != "3D000" { 65 | return err 66 | } 67 | 68 | _, err = c.db.Exec(fmt.Sprintf(TERMINATE_BACKEND, database)) 69 | // Error code 3D000 is returned if database doesn't exist 70 | if err != nil && err.(*pq.Error).Code != "3D000" { 71 | return err 72 | } 73 | _, err = c.db.Exec(fmt.Sprintf(DROP_DATABASE, database)) 74 | // Error code 3D000 is returned if database doesn't exist 75 | if err != nil && err.(*pq.Error).Code != "3D000" { 76 | return err 77 | } 78 | 79 | logger.Info(fmt.Sprintf("Dropped database %s", database)) 80 | 81 | return nil 82 | } 83 | 84 | func (c *pg) CreateExtension(db, extension string, logger logr.Logger) error { 85 | tmpDb, err := GetConnection(c.user, c.pass, c.host, db, c.args, logger) 86 | if err != nil { 87 | return err 88 | } 89 | defer tmpDb.Close() 90 | 91 | _, err = tmpDb.Exec(fmt.Sprintf(CREATE_EXTENSION, extension)) 92 | if err != nil { 93 | return err 94 | } 95 | return nil 96 | } 97 | 98 | func (c *pg) SetSchemaPrivileges(schemaPrivileges PostgresSchemaPrivileges, logger logr.Logger) error { 99 | tmpDb, err := GetConnection(c.user, c.pass, c.host, schemaPrivileges.DB, c.args, logger) 100 | if err != nil { 101 | return err 102 | } 103 | defer tmpDb.Close() 104 | 105 | // Grant role usage on schema 106 | _, err = tmpDb.Exec(fmt.Sprintf(GRANT_USAGE_SCHEMA, schemaPrivileges.Schema, schemaPrivileges.Role)) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | // Grant role privs on existing tables in schema 112 | _, err = tmpDb.Exec(fmt.Sprintf(GRANT_ALL_TABLES, schemaPrivileges.Privs, schemaPrivileges.Schema, schemaPrivileges.Role)) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | // Grant role privs on future tables in schema 118 | _, err = tmpDb.Exec(fmt.Sprintf(DEFAULT_PRIVS_SCHEMA, schemaPrivileges.Creator, schemaPrivileges.Schema, schemaPrivileges.Privs, schemaPrivileges.Role)) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | // Grant role usage on schema if createSchema 124 | if schemaPrivileges.CreateSchema { 125 | _, err = tmpDb.Exec(fmt.Sprintf(GRANT_CREATE_TABLE, schemaPrivileges.Schema, schemaPrivileges.Role)) 126 | if err != nil { 127 | return err 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /pkg/postgres/gcp.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | type gcppg struct { 11 | pg 12 | } 13 | 14 | func newGCPPG(postgres *pg) PG { 15 | return &gcppg{ 16 | *postgres, 17 | } 18 | } 19 | 20 | func (c *gcppg) DropDatabase(database string, logger logr.Logger) error { 21 | 22 | _, err := c.db.Exec(fmt.Sprintf(REVOKE_CONNECT, database)) 23 | // Error code 3D000 is returned if database doesn't exist 24 | if err != nil && err.(*pq.Error).Code != "3D000" { 25 | return err 26 | } 27 | 28 | _, err = c.db.Exec(fmt.Sprintf(TERMINATE_BACKEND, database)) 29 | // Error code 3D000 is returned if database doesn't exist 30 | if err != nil && err.(*pq.Error).Code != "3D000" { 31 | return err 32 | } 33 | _, err = c.db.Exec(fmt.Sprintf(DROP_DATABASE, database)) 34 | // Error code 3D000 is returned if database doesn't exist 35 | if err != nil && err.(*pq.Error).Code != "3D000" { 36 | return err 37 | } 38 | 39 | logger.Info(fmt.Sprintf("Dropped database %s", database)) 40 | 41 | return nil 42 | } 43 | 44 | func (c *gcppg) CreateDB(dbname, role string) error { 45 | 46 | err := c.GrantRole(role, c.user) 47 | if err != nil { 48 | return err 49 | } 50 | err = c.pg.CreateDB(dbname, role) 51 | if err != nil { 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | func (c *gcppg) DropRole(role, newOwner, database string, logger logr.Logger) error { 58 | 59 | tmpDb, err := GetConnection(c.user, c.pass, c.host, database, c.args, logger) 60 | q := fmt.Sprintf(GET_DB_OWNER, database) 61 | logger.Info("Checking master role: " + q) 62 | rows, err := tmpDb.Query(q) 63 | if err != nil { 64 | return err 65 | } 66 | var masterRole string 67 | for rows.Next() { 68 | rows.Scan(&masterRole) 69 | } 70 | 71 | if role != masterRole { 72 | q = fmt.Sprintf(DROP_ROLE, role) 73 | logger.Info("GCP Drop Role: " + q) 74 | _, err = tmpDb.Exec(q) 75 | // Check if error exists and if different from "ROLE NOT FOUND" => 42704 76 | if err != nil && err.(*pq.Error).Code != "42704" { 77 | return err 78 | } 79 | 80 | defer tmpDb.Close() 81 | } else { 82 | logger.Info(fmt.Sprintf("GCP refusing DropRole on master role: %s", masterRole)) 83 | } 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/postgres/mock/postgres.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: pkg/postgres/postgres.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source pkg/postgres/postgres.go 7 | // 8 | 9 | // Package mock_postgres is a generated GoMock package. 10 | package mock_postgres 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | logr "github.com/go-logr/logr" 16 | postgres "github.com/movetokube/postgres-operator/pkg/postgres" 17 | gomock "go.uber.org/mock/gomock" 18 | ) 19 | 20 | // MockPG is a mock of PG interface. 21 | type MockPG struct { 22 | ctrl *gomock.Controller 23 | recorder *MockPGMockRecorder 24 | isgomock struct{} 25 | } 26 | 27 | // MockPGMockRecorder is the mock recorder for MockPG. 28 | type MockPGMockRecorder struct { 29 | mock *MockPG 30 | } 31 | 32 | // NewMockPG creates a new mock instance. 33 | func NewMockPG(ctrl *gomock.Controller) *MockPG { 34 | mock := &MockPG{ctrl: ctrl} 35 | mock.recorder = &MockPGMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockPG) EXPECT() *MockPGMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // AlterDefaultLoginRole mocks base method. 45 | func (m *MockPG) AlterDefaultLoginRole(role, setRole string) error { 46 | m.ctrl.T.Helper() 47 | ret := m.ctrl.Call(m, "AlterDefaultLoginRole", role, setRole) 48 | ret0, _ := ret[0].(error) 49 | return ret0 50 | } 51 | 52 | // AlterDefaultLoginRole indicates an expected call of AlterDefaultLoginRole. 53 | func (mr *MockPGMockRecorder) AlterDefaultLoginRole(role, setRole any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AlterDefaultLoginRole", reflect.TypeOf((*MockPG)(nil).AlterDefaultLoginRole), role, setRole) 56 | } 57 | 58 | // CreateDB mocks base method. 59 | func (m *MockPG) CreateDB(dbname, username string) error { 60 | m.ctrl.T.Helper() 61 | ret := m.ctrl.Call(m, "CreateDB", dbname, username) 62 | ret0, _ := ret[0].(error) 63 | return ret0 64 | } 65 | 66 | // CreateDB indicates an expected call of CreateDB. 67 | func (mr *MockPGMockRecorder) CreateDB(dbname, username any) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDB", reflect.TypeOf((*MockPG)(nil).CreateDB), dbname, username) 70 | } 71 | 72 | // CreateExtension mocks base method. 73 | func (m *MockPG) CreateExtension(db, extension string, logger logr.Logger) error { 74 | m.ctrl.T.Helper() 75 | ret := m.ctrl.Call(m, "CreateExtension", db, extension, logger) 76 | ret0, _ := ret[0].(error) 77 | return ret0 78 | } 79 | 80 | // CreateExtension indicates an expected call of CreateExtension. 81 | func (mr *MockPGMockRecorder) CreateExtension(db, extension, logger any) *gomock.Call { 82 | mr.mock.ctrl.T.Helper() 83 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExtension", reflect.TypeOf((*MockPG)(nil).CreateExtension), db, extension, logger) 84 | } 85 | 86 | // CreateGroupRole mocks base method. 87 | func (m *MockPG) CreateGroupRole(role string) error { 88 | m.ctrl.T.Helper() 89 | ret := m.ctrl.Call(m, "CreateGroupRole", role) 90 | ret0, _ := ret[0].(error) 91 | return ret0 92 | } 93 | 94 | // CreateGroupRole indicates an expected call of CreateGroupRole. 95 | func (mr *MockPGMockRecorder) CreateGroupRole(role any) *gomock.Call { 96 | mr.mock.ctrl.T.Helper() 97 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroupRole", reflect.TypeOf((*MockPG)(nil).CreateGroupRole), role) 98 | } 99 | 100 | // CreateSchema mocks base method. 101 | func (m *MockPG) CreateSchema(db, role, schema string, logger logr.Logger) error { 102 | m.ctrl.T.Helper() 103 | ret := m.ctrl.Call(m, "CreateSchema", db, role, schema, logger) 104 | ret0, _ := ret[0].(error) 105 | return ret0 106 | } 107 | 108 | // CreateSchema indicates an expected call of CreateSchema. 109 | func (mr *MockPGMockRecorder) CreateSchema(db, role, schema, logger any) *gomock.Call { 110 | mr.mock.ctrl.T.Helper() 111 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSchema", reflect.TypeOf((*MockPG)(nil).CreateSchema), db, role, schema, logger) 112 | } 113 | 114 | // CreateUserRole mocks base method. 115 | func (m *MockPG) CreateUserRole(role, password string) (string, error) { 116 | m.ctrl.T.Helper() 117 | ret := m.ctrl.Call(m, "CreateUserRole", role, password) 118 | ret0, _ := ret[0].(string) 119 | ret1, _ := ret[1].(error) 120 | return ret0, ret1 121 | } 122 | 123 | // CreateUserRole indicates an expected call of CreateUserRole. 124 | func (mr *MockPGMockRecorder) CreateUserRole(role, password any) *gomock.Call { 125 | mr.mock.ctrl.T.Helper() 126 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserRole", reflect.TypeOf((*MockPG)(nil).CreateUserRole), role, password) 127 | } 128 | 129 | // DropDatabase mocks base method. 130 | func (m *MockPG) DropDatabase(db string, logger logr.Logger) error { 131 | m.ctrl.T.Helper() 132 | ret := m.ctrl.Call(m, "DropDatabase", db, logger) 133 | ret0, _ := ret[0].(error) 134 | return ret0 135 | } 136 | 137 | // DropDatabase indicates an expected call of DropDatabase. 138 | func (mr *MockPGMockRecorder) DropDatabase(db, logger any) *gomock.Call { 139 | mr.mock.ctrl.T.Helper() 140 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DropDatabase", reflect.TypeOf((*MockPG)(nil).DropDatabase), db, logger) 141 | } 142 | 143 | // DropRole mocks base method. 144 | func (m *MockPG) DropRole(role, newOwner, database string, logger logr.Logger) error { 145 | m.ctrl.T.Helper() 146 | ret := m.ctrl.Call(m, "DropRole", role, newOwner, database, logger) 147 | ret0, _ := ret[0].(error) 148 | return ret0 149 | } 150 | 151 | // DropRole indicates an expected call of DropRole. 152 | func (mr *MockPGMockRecorder) DropRole(role, newOwner, database, logger any) *gomock.Call { 153 | mr.mock.ctrl.T.Helper() 154 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DropRole", reflect.TypeOf((*MockPG)(nil).DropRole), role, newOwner, database, logger) 155 | } 156 | 157 | // GetDefaultDatabase mocks base method. 158 | func (m *MockPG) GetDefaultDatabase() string { 159 | m.ctrl.T.Helper() 160 | ret := m.ctrl.Call(m, "GetDefaultDatabase") 161 | ret0, _ := ret[0].(string) 162 | return ret0 163 | } 164 | 165 | // GetDefaultDatabase indicates an expected call of GetDefaultDatabase. 166 | func (mr *MockPGMockRecorder) GetDefaultDatabase() *gomock.Call { 167 | mr.mock.ctrl.T.Helper() 168 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultDatabase", reflect.TypeOf((*MockPG)(nil).GetDefaultDatabase)) 169 | } 170 | 171 | // GetUser mocks base method. 172 | func (m *MockPG) GetUser() string { 173 | m.ctrl.T.Helper() 174 | ret := m.ctrl.Call(m, "GetUser") 175 | ret0, _ := ret[0].(string) 176 | return ret0 177 | } 178 | 179 | // GetUser indicates an expected call of GetUser. 180 | func (mr *MockPGMockRecorder) GetUser() *gomock.Call { 181 | mr.mock.ctrl.T.Helper() 182 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockPG)(nil).GetUser)) 183 | } 184 | 185 | // GrantRole mocks base method. 186 | func (m *MockPG) GrantRole(role, grantee string) error { 187 | m.ctrl.T.Helper() 188 | ret := m.ctrl.Call(m, "GrantRole", role, grantee) 189 | ret0, _ := ret[0].(error) 190 | return ret0 191 | } 192 | 193 | // GrantRole indicates an expected call of GrantRole. 194 | func (mr *MockPGMockRecorder) GrantRole(role, grantee any) *gomock.Call { 195 | mr.mock.ctrl.T.Helper() 196 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantRole", reflect.TypeOf((*MockPG)(nil).GrantRole), role, grantee) 197 | } 198 | 199 | // RevokeRole mocks base method. 200 | func (m *MockPG) RevokeRole(role, revoked string) error { 201 | m.ctrl.T.Helper() 202 | ret := m.ctrl.Call(m, "RevokeRole", role, revoked) 203 | ret0, _ := ret[0].(error) 204 | return ret0 205 | } 206 | 207 | // RevokeRole indicates an expected call of RevokeRole. 208 | func (mr *MockPGMockRecorder) RevokeRole(role, revoked any) *gomock.Call { 209 | mr.mock.ctrl.T.Helper() 210 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeRole", reflect.TypeOf((*MockPG)(nil).RevokeRole), role, revoked) 211 | } 212 | 213 | // SetSchemaPrivileges mocks base method. 214 | func (m *MockPG) SetSchemaPrivileges(schemaPrivileges postgres.PostgresSchemaPrivileges, logger logr.Logger) error { 215 | m.ctrl.T.Helper() 216 | ret := m.ctrl.Call(m, "SetSchemaPrivileges", schemaPrivileges, logger) 217 | ret0, _ := ret[0].(error) 218 | return ret0 219 | } 220 | 221 | // SetSchemaPrivileges indicates an expected call of SetSchemaPrivileges. 222 | func (mr *MockPGMockRecorder) SetSchemaPrivileges(schemaPrivileges, logger any) *gomock.Call { 223 | mr.mock.ctrl.T.Helper() 224 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSchemaPrivileges", reflect.TypeOf((*MockPG)(nil).SetSchemaPrivileges), schemaPrivileges, logger) 225 | } 226 | 227 | // UpdatePassword mocks base method. 228 | func (m *MockPG) UpdatePassword(role, password string) error { 229 | m.ctrl.T.Helper() 230 | ret := m.ctrl.Call(m, "UpdatePassword", role, password) 231 | ret0, _ := ret[0].(error) 232 | return ret0 233 | } 234 | 235 | // UpdatePassword indicates an expected call of UpdatePassword. 236 | func (mr *MockPGMockRecorder) UpdatePassword(role, password any) *gomock.Call { 237 | mr.mock.ctrl.T.Helper() 238 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockPG)(nil).UpdatePassword), role, password) 239 | } 240 | -------------------------------------------------------------------------------- /pkg/postgres/postgres.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/go-logr/logr" 9 | "github.com/movetokube/postgres-operator/pkg/config" 10 | ) 11 | 12 | type PG interface { 13 | CreateDB(dbname, username string) error 14 | CreateSchema(db, role, schema string, logger logr.Logger) error 15 | CreateExtension(db, extension string, logger logr.Logger) error 16 | CreateGroupRole(role string) error 17 | CreateUserRole(role, password string) (string, error) 18 | UpdatePassword(role, password string) error 19 | GrantRole(role, grantee string) error 20 | SetSchemaPrivileges(schemaPrivileges PostgresSchemaPrivileges, logger logr.Logger) error 21 | RevokeRole(role, revoked string) error 22 | AlterDefaultLoginRole(role, setRole string) error 23 | DropDatabase(db string, logger logr.Logger) error 24 | DropRole(role, newOwner, database string, logger logr.Logger) error 25 | GetUser() string 26 | GetDefaultDatabase() string 27 | } 28 | 29 | type pg struct { 30 | db *sql.DB 31 | log logr.Logger 32 | host string 33 | user string 34 | pass string 35 | args string 36 | default_database string 37 | } 38 | 39 | type PostgresSchemaPrivileges struct { 40 | DB string 41 | Creator string 42 | Role string 43 | Schema string 44 | Privs string 45 | CreateSchema bool 46 | } 47 | 48 | func NewPG(cfg *config.Cfg, logger logr.Logger) (PG, error) { 49 | db, err := GetConnection( 50 | cfg.PostgresUser, 51 | cfg.PostgresPass, 52 | cfg.PostgresHost, 53 | cfg.PostgresDefaultDb, 54 | cfg.PostgresUriArgs, 55 | logger) 56 | if err != nil { 57 | log.Fatalf("failed to connect to PostgreSQL server: %s", err.Error()) 58 | } 59 | logger.Info("connected to postgres server") 60 | postgres := &pg{ 61 | db: db, 62 | log: logger, 63 | host: cfg.PostgresHost, 64 | user: cfg.PostgresUser, 65 | pass: cfg.PostgresPass, 66 | args: cfg.PostgresUriArgs, 67 | default_database: cfg.PostgresDefaultDb, 68 | } 69 | 70 | switch cfg.CloudProvider { 71 | case "AWS": 72 | logger.Info("Using AWS wrapper") 73 | return newAWSPG(postgres), nil 74 | case "Azure": 75 | logger.Info("Using Azure wrapper") 76 | return newAzurePG(postgres), nil 77 | case "GCP": 78 | logger.Info("Using GCP wrapper") 79 | return newGCPPG(postgres), nil 80 | default: 81 | logger.Info("Using default postgres implementation") 82 | return postgres, nil 83 | } 84 | } 85 | 86 | func (c *pg) GetUser() string { 87 | return c.user 88 | } 89 | 90 | func (c *pg) GetDefaultDatabase() string { 91 | return c.default_database 92 | } 93 | 94 | func GetConnection(user, password, host, database, uri_args string, logger logr.Logger) (*sql.DB, error) { 95 | db, err := sql.Open("postgres", fmt.Sprintf("postgresql://%s:%s@%s/%s?%s", user, password, host, database, uri_args)) 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | err = db.Ping() 100 | return db, err 101 | } 102 | -------------------------------------------------------------------------------- /pkg/postgres/role.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-logr/logr" 7 | "github.com/lib/pq" 8 | ) 9 | 10 | const ( 11 | CREATE_GROUP_ROLE = `CREATE ROLE "%s"` 12 | CREATE_USER_ROLE = `CREATE ROLE "%s" WITH LOGIN PASSWORD '%s'` 13 | GRANT_ROLE = `GRANT "%s" TO "%s"` 14 | ALTER_USER_SET_ROLE = `ALTER USER "%s" SET ROLE "%s"` 15 | REVOKE_ROLE = `REVOKE "%s" FROM "%s"` 16 | UPDATE_PASSWORD = `ALTER ROLE "%s" WITH PASSWORD '%s'` 17 | DROP_ROLE = `DROP ROLE "%s"` 18 | DROP_OWNED_BY = `DROP OWNED BY "%s"` 19 | REASIGN_OBJECTS = `REASSIGN OWNED BY "%s" TO "%s"` 20 | ) 21 | 22 | func (c *pg) CreateGroupRole(role string) error { 23 | // Error code 42710 is duplicate_object (role already exists) 24 | _, err := c.db.Exec(fmt.Sprintf(CREATE_GROUP_ROLE, role)) 25 | if err != nil && err.(*pq.Error).Code != "42710" { 26 | return err 27 | } 28 | return nil 29 | } 30 | 31 | func (c *pg) CreateUserRole(role, password string) (string, error) { 32 | _, err := c.db.Exec(fmt.Sprintf(CREATE_USER_ROLE, role, password)) 33 | if err != nil { 34 | return "", err 35 | } 36 | return role, nil 37 | } 38 | 39 | func (c *pg) GrantRole(role, grantee string) error { 40 | _, err := c.db.Exec(fmt.Sprintf(GRANT_ROLE, role, grantee)) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func (c *pg) AlterDefaultLoginRole(role, setRole string) error { 48 | _, err := c.db.Exec(fmt.Sprintf(ALTER_USER_SET_ROLE, role, setRole)) 49 | if err != nil { 50 | return err 51 | } 52 | return nil 53 | } 54 | 55 | func (c *pg) RevokeRole(role, revoked string) error { 56 | _, err := c.db.Exec(fmt.Sprintf(REVOKE_ROLE, role, revoked)) 57 | if err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func (c *pg) DropRole(role, newOwner, database string, logger logr.Logger) error { 64 | // REASSIGN OWNED BY only works if the correct database is selected 65 | tmpDb, err := GetConnection(c.user, c.pass, c.host, database, c.args, logger) 66 | if err != nil { 67 | if err.(*pq.Error).Code == "3D000" { 68 | return nil // Database is does not exist (anymore) 69 | } else { 70 | return err 71 | } 72 | } 73 | _, err = tmpDb.Exec(fmt.Sprintf(REASIGN_OBJECTS, role, newOwner)) 74 | defer tmpDb.Close() 75 | // Check if error exists and if different from "ROLE NOT FOUND" => 42704 76 | if err != nil && err.(*pq.Error).Code != "42704" { 77 | return err 78 | } 79 | 80 | // We previously assigned all objects to the operator's role so DROP OWNED BY will drop privileges of role 81 | _, err = tmpDb.Exec(fmt.Sprintf(DROP_OWNED_BY, role)) 82 | // Check if error exists and if different from "ROLE NOT FOUND" => 42704 83 | if err != nil && err.(*pq.Error).Code != "42704" { 84 | return err 85 | } 86 | 87 | _, err = c.db.Exec(fmt.Sprintf(DROP_ROLE, role)) 88 | // Check if error exists and if different from "ROLE NOT FOUND" => 42704 89 | if err != nil && err.(*pq.Error).Code != "42704" { 90 | return err 91 | } 92 | return nil 93 | } 94 | 95 | func (c *pg) UpdatePassword(role, password string) error { 96 | _, err := c.db.Exec(fmt.Sprintf(UPDATE_PASSWORD, role, password)) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/utils/annotationfilter.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | const INSTANCE_ANNOTATION = "postgres.db.movetokube.com/instance" 6 | 7 | func MatchesInstanceAnnotation(annotationMap map[string]string, configuredInstance string) bool { 8 | if v, found := annotationMap[INSTANCE_ANNOTATION]; found { 9 | // Annotation Found, check if the value matches with what is configured 10 | return strings.EqualFold(v, configuredInstance) 11 | } else { 12 | // The annotation is not found, so we check if we have configured a filter 13 | return configuredInstance == "" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/utils/annotationfilter_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("MatchesInstanceAnnotation", func() { 9 | Context("when filter is defined", func() { 10 | const filter = "value" 11 | 12 | It("should return false when annotations are nil", func() { 13 | Expect(MatchesInstanceAnnotation(nil, filter)).To(BeFalse()) 14 | }) 15 | 16 | It("should return false when correct key is not present", func() { 17 | annotations := map[string]string{ 18 | "invalidkey": "value", 19 | } 20 | Expect(MatchesInstanceAnnotation(annotations, filter)).To(BeFalse()) 21 | }) 22 | 23 | It("should return true when correct key and value match", func() { 24 | annotations := map[string]string{ 25 | INSTANCE_ANNOTATION: "value", 26 | } 27 | Expect(MatchesInstanceAnnotation(annotations, filter)).To(BeTrue()) 28 | }) 29 | }) 30 | 31 | Context("when filter is not defined", func() { 32 | const filter = "" 33 | 34 | It("should return true when annotations are nil", func() { 35 | Expect(MatchesInstanceAnnotation(nil, filter)).To(BeTrue()) 36 | }) 37 | 38 | It("should return true when annotations map is empty", func() { 39 | annotations := map[string]string{} 40 | Expect(MatchesInstanceAnnotation(annotations, filter)).To(BeTrue()) 41 | }) 42 | 43 | It("should return true when correct key is not present", func() { 44 | annotations := map[string]string{ 45 | "invalidkey": "value", 46 | } 47 | Expect(MatchesInstanceAnnotation(annotations, filter)).To(BeTrue()) 48 | }) 49 | 50 | It("should return false when the instance annotation key is present", func() { 51 | annotations := map[string]string{ 52 | INSTANCE_ANNOTATION: "value", 53 | } 54 | Expect(MatchesInstanceAnnotation(annotations, filter)).To(BeFalse()) 55 | }) 56 | }) 57 | Context("Testing case insensitivity", func() { 58 | It("should match values case-insensitively", func() { 59 | annotations := map[string]string{ 60 | INSTANCE_ANNOTATION: "VALUE", 61 | } 62 | Expect(MatchesInstanceAnnotation(annotations, "value")).To(BeTrue()) 63 | 64 | annotations = map[string]string{ 65 | INSTANCE_ANNOTATION: "value", 66 | } 67 | Expect(MatchesInstanceAnnotation(annotations, "VALUE")).To(BeTrue()) 68 | }) 69 | }) 70 | 71 | Context("Testing with mixed cases", func() { 72 | It("should match values with mixed cases", func() { 73 | annotations := map[string]string{ 74 | INSTANCE_ANNOTATION: "MiXeDcAsE", 75 | } 76 | Expect(MatchesInstanceAnnotation(annotations, "mixedcase")).To(BeTrue()) 77 | Expect(MatchesInstanceAnnotation(annotations, "MIXEDCASE")).To(BeTrue()) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /pkg/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | func MustGetEnv(name string) string { 9 | value, found := os.LookupEnv(name) 10 | if !found { 11 | log.Fatalf("environment variable %s is missing", name) 12 | } 13 | return value 14 | } 15 | 16 | func GetEnv(name string) string { 17 | return os.Getenv(name) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/utils/random.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import cryptorand "crypto/rand" 4 | import "math/rand" 5 | import "math/big" 6 | 7 | var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") 8 | 9 | func GetRandomString(length int) string { 10 | b := make([]rune, length) 11 | for i := range b { 12 | b[i] = letterRunes[rand.Intn(len(letterRunes))] 13 | } 14 | return string(b) 15 | } 16 | 17 | // If the secure random number generator malfunctions it will return an error 18 | func GetSecureRandomString(length int) (string, error) { 19 | b := make([]rune, length) 20 | for i := 0; i < length; i++ { 21 | num, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(letterRunes)))) 22 | if err != nil { 23 | return "", err 24 | } 25 | b[i] = letterRunes[num.Int64()] 26 | } 27 | 28 | return string(b), nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/utils/random_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Random String Utils", func() { 9 | Context("Generating random strings", func() { 10 | const testLength = 10 11 | 12 | When("using GetRandomString", func() { 13 | It("should return a string of the specified length", func() { 14 | result := GetRandomString(testLength) 15 | Expect(result).To(HaveLen(testLength)) 16 | }) 17 | 18 | It("should only contain valid characters", func() { 19 | result := GetRandomString(testLength) 20 | validChars := "^[a-zA-Z0-9]+$" 21 | Expect(result).To(MatchRegexp(validChars)) 22 | }) 23 | 24 | It("should generate different strings on multiple calls", func() { 25 | result1 := GetRandomString(testLength) 26 | result2 := GetRandomString(testLength) 27 | Expect(result1).NotTo(Equal(result2)) 28 | }) 29 | }) 30 | 31 | When("using GetSecureRandomString", func() { 32 | It("should return a string of the specified length", func() { 33 | result, err := GetSecureRandomString(testLength) 34 | Expect(err).NotTo(HaveOccurred()) 35 | Expect(result).To(HaveLen(testLength)) 36 | }) 37 | 38 | It("should only contain valid characters", func() { 39 | result, err := GetSecureRandomString(testLength) 40 | Expect(err).NotTo(HaveOccurred()) 41 | validChars := "^[a-zA-Z0-9]+$" 42 | Expect(result).To(MatchRegexp(validChars)) 43 | }) 44 | 45 | It("should generate different strings on multiple calls", func() { 46 | result1, err1 := GetSecureRandomString(testLength) 47 | result2, err2 := GetSecureRandomString(testLength) 48 | Expect(err1).NotTo(HaveOccurred()) 49 | Expect(err2).NotTo(HaveOccurred()) 50 | Expect(result1).NotTo(Equal(result2)) 51 | }) 52 | 53 | It("should handle generating strings of different lengths", func() { 54 | result1, err1 := GetSecureRandomString(5) 55 | result2, err2 := GetSecureRandomString(15) 56 | Expect(err1).NotTo(HaveOccurred()) 57 | Expect(err2).NotTo(HaveOccurred()) 58 | Expect(result1).To(HaveLen(5)) 59 | Expect(result2).To(HaveLen(15)) 60 | }) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /pkg/utils/suite_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestUtils(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Utils Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/utils/template.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | ) 8 | 9 | type TemplateContext struct { 10 | Host string 11 | Role string 12 | Database string 13 | Password string 14 | } 15 | 16 | func RenderTemplate(data map[string]string, tc TemplateContext) (map[string][]byte, error) { 17 | if len(data) == 0 { 18 | return nil, nil 19 | } 20 | var out = make(map[string][]byte, len(data)) 21 | for key, templ := range data { 22 | parsed, err := template.New("").Parse(templ) 23 | if err != nil { 24 | return nil, fmt.Errorf("parse template %q: %w", key, err) 25 | } 26 | var content bytes.Buffer 27 | if err := parsed.Execute(&content, tc); err != nil { 28 | return nil, fmt.Errorf("execute template %q: %w", key, err) 29 | } 30 | out[key] = content.Bytes() 31 | } 32 | return out, nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/utils/template_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/onsi/ginkgo/v2" 5 | "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = ginkgo.Describe("Template Utils", func() { 9 | ginkgo.Context("RenderTemplate function", func() { 10 | var ( 11 | templateContext TemplateContext 12 | templates map[string]string 13 | ) 14 | 15 | ginkgo.BeforeEach(func() { 16 | templateContext = TemplateContext{ 17 | Host: "localhost", 18 | Role: "admin", 19 | Database: "postgres", 20 | Password: "secret", 21 | } 22 | }) 23 | 24 | ginkgo.When("provided with valid templates", func() { 25 | ginkgo.BeforeEach(func() { 26 | templates = map[string]string{ 27 | "simple": "Host: {{.Host}}", 28 | "all-fields": "Host: {{.Host}}, Role: {{.Role}}, DB: {{.Database}}, Password: {{.Password}}", 29 | "multi-line": "Connection Info:\n Host: {{.Host}}\n Database: {{.Database}}", 30 | "empty-templ": "", 31 | } 32 | }) 33 | 34 | ginkgo.It("should render all templates correctly", func() { 35 | result, err := RenderTemplate(templates, templateContext) 36 | 37 | gomega.Expect(err).NotTo(gomega.HaveOccurred()) 38 | gomega.Expect(result).To(gomega.HaveLen(4)) 39 | 40 | gomega.Expect(string(result["simple"])).To(gomega.Equal("Host: localhost")) 41 | gomega.Expect(string(result["all-fields"])).To(gomega.Equal("Host: localhost, Role: admin, DB: postgres, Password: secret")) 42 | gomega.Expect(string(result["multi-line"])).To(gomega.Equal("Connection Info:\n Host: localhost\n Database: postgres")) 43 | gomega.Expect(string(result["empty-templ"])).To(gomega.Equal("")) 44 | }) 45 | }) 46 | 47 | ginkgo.When("provided with an invalid template", func() { 48 | ginkgo.BeforeEach(func() { 49 | templates = map[string]string{ 50 | "invalid": "Host: {{.Host}}, Invalid: {{.NonExistent}}", 51 | } 52 | }) 53 | 54 | ginkgo.It("should return an error", func() { 55 | result, err := RenderTemplate(templates, templateContext) 56 | 57 | gomega.Expect(err).To(gomega.HaveOccurred()) 58 | gomega.Expect(err.Error()).To(gomega.ContainSubstring("execute template")) 59 | gomega.Expect(result).To(gomega.BeNil()) 60 | }) 61 | }) 62 | 63 | ginkgo.When("provided with a template with syntax error", func() { 64 | ginkgo.BeforeEach(func() { 65 | templates = map[string]string{ 66 | "syntax-error": "Host: {{.Host}, Missing closing bracket", 67 | } 68 | }) 69 | 70 | ginkgo.It("should return an error", func() { 71 | result, err := RenderTemplate(templates, templateContext) 72 | 73 | gomega.Expect(err).To(gomega.HaveOccurred()) 74 | gomega.Expect(err.Error()).To(gomega.ContainSubstring("parse template")) 75 | gomega.Expect(result).To(gomega.BeNil()) 76 | }) 77 | }) 78 | 79 | ginkgo.When("provided with an empty template map", func() { 80 | ginkgo.BeforeEach(func() { 81 | templates = map[string]string{} 82 | }) 83 | 84 | ginkgo.It("should return nil", func() { 85 | result, err := RenderTemplate(templates, templateContext) 86 | 87 | gomega.Expect(err).NotTo(gomega.HaveOccurred()) 88 | gomega.Expect(result).To(gomega.BeNil()) 89 | }) 90 | }) 91 | 92 | ginkgo.When("provided with a nil template map", func() { 93 | ginkgo.It("should return nil", func() { 94 | result, err := RenderTemplate(nil, templateContext) 95 | 96 | gomega.Expect(err).NotTo(gomega.HaveOccurred()) 97 | gomega.Expect(result).To(gomega.BeNil()) 98 | }) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/01-assert.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestAssert 3 | collectors: 4 | - type: pod 5 | selector: app.kubernetes.io/name=ext-postgres-operator 6 | tail: 100 7 | --- 8 | apiVersion: db.movetokube.com/v1alpha1 9 | kind: Postgres 10 | metadata: 11 | name: my-db 12 | status: 13 | roles: 14 | owner: test-db-group 15 | reader: test-db-reader 16 | writer: test-db-writer 17 | schemas: 18 | - stores 19 | - customers 20 | succeeded: true 21 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/01-postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: db.movetokube.com/v1alpha1 2 | kind: Postgres 3 | metadata: 4 | name: my-db 5 | spec: 6 | database: test-db 7 | dropOnDelete: true 8 | masterRole: test-db-group 9 | schemas: 10 | - stores 11 | - customers 12 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/02-assert.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestAssert 3 | collectors: 4 | - type: pod 5 | selector: app.kubernetes.io/name=ext-postgres-operator 6 | tail: 100 7 | --- 8 | apiVersion: db.movetokube.com/v1alpha1 9 | kind: PostgresUser 10 | metadata: 11 | name: my-db-user 12 | spec: 13 | labels: 14 | custom-label: custom-value 15 | status: 16 | databaseName: test-db 17 | postgresGroup: test-db-group 18 | succeeded: true 19 | --- 20 | apiVersion: v1 21 | kind: Secret 22 | metadata: 23 | name: my-secret-my-db-user 24 | labels: 25 | custom-label: custom-value 26 | app: my-db-user 27 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/02-postgresuser.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: db.movetokube.com/v1alpha1 2 | kind: PostgresUser 3 | metadata: 4 | name: my-db-user 5 | spec: 6 | role: username 7 | database: my-db 8 | secretName: my-secret 9 | privileges: OWNER 10 | labels: 11 | custom-label: custom-value 12 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/03-assert.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestAssert 3 | collectors: 4 | - type: pod 5 | selector: app.kubernetes.io/name=ext-postgres-operator 6 | tail: 100 7 | commands: 8 | - command: bash -c "! kubectl get postgresuser my-db-user -n $NAMESPACE" 9 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/03-delete-postgresuser.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestStep 3 | delete: 4 | - apiVersion: db.movetokube.com/v1alpha1 5 | kind: PostgresUser 6 | name: my-db-user 7 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/04-assert.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestAssert 3 | collectors: 4 | - type: pod 5 | selector: app.kubernetes.io/name=ext-postgres-operator 6 | tail: 100 7 | commands: 8 | - command: bash -c "! kubectl get postgres my-db -n $NAMESPACE" 9 | -------------------------------------------------------------------------------- /tests/e2e/basic-operations/04-delete-postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestStep 3 | delete: 4 | - apiVersion: db.movetokube.com/v1alpha1 5 | kind: Postgres 6 | name: my-db 7 | -------------------------------------------------------------------------------- /tests/kuttl-test-self-hosted-postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kuttl.dev/v1beta1 2 | kind: TestSuite 3 | testDirs: 4 | - ./tests/e2e/ 5 | # crdDir: ./deploy/crds/ 6 | startKIND: true 7 | kindContext: self-hosted-postgres 8 | kindContainers: 9 | - postgres-operator:build 10 | artifactsDir: ./tests/ 11 | commands: 12 | - command: >- 13 | helm install -n $NAMESPACE postgresql oci://registry-1.docker.io/bitnamicharts/postgresql 14 | --version 16.6.0 15 | --set global.postgresql.auth.password=postgres 16 | --set global.postgresql.auth.username=postgres 17 | --wait 18 | timeout: 120 19 | - command: >- 20 | helm install -n $NAMESPACE ext-postgres-operator ./charts/ext-postgres-operator 21 | --set image.repository=postgres-operator 22 | --set image.tag=build 23 | --set postgres.host=postgresql 24 | --set postgres.user=postgres 25 | --set postgres.password=postgres 26 | --set postgres.uri_args="sslmode=disable" 27 | --wait 28 | --------------------------------------------------------------------------------