├── config ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── service_account.yaml │ ├── auth_proxy_client_clusterrole.yaml │ ├── role_binding.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── leader_election_role_binding.yaml │ ├── auth_proxy_role.yaml │ ├── dopplersecret_viewer_role.yaml │ ├── dopplersecret_editor_role.yaml │ ├── leader_election_role.yaml │ ├── kustomization.yaml │ └── role.yaml ├── scorecard │ ├── bases │ │ └── config.yaml │ ├── patches │ │ ├── basic.config.yaml │ │ └── olm.config.yaml │ └── kustomization.yaml ├── samples │ ├── kustomization.yaml │ ├── secrets_v1alpha1_dopplersecret.yaml │ ├── deployment-envfrom.yaml │ ├── deployment-valuefrom.yaml │ └── deployment-volume.yaml ├── manager │ ├── controller_manager_config.yaml │ ├── kustomization.yaml │ └── manager.yaml ├── crd │ ├── patches │ │ ├── cainjection_in_dopplersecrets.yaml │ │ └── webhook_in_dopplersecrets.yaml │ ├── kustomizeconfig.yaml │ ├── kustomization.yaml │ └── bases │ │ └── secrets.doppler.com_dopplersecrets.yaml ├── default │ ├── manager_auth_proxy_patch.yaml │ ├── manager_config_patch.yaml │ └── kustomization.yaml └── manifests │ └── kustomization.yaml ├── docs ├── diagram.jpg └── custom_types_and_processors.md ├── pkg ├── version │ └── version.go ├── models │ └── secrets.go ├── processors │ └── processors.go ├── cache │ └── cache.go ├── auth │ └── oidc.go └── api │ └── api.go ├── hack ├── helm │ ├── NOTES.txt │ ├── Chart.yaml │ └── deploy-pages.sh ├── inject-nosec.sh └── boilerplate.go.txt ├── .dockerignore ├── tools ├── get-secret.sh └── operator-logs.sh ├── PROJECT ├── .gitignore ├── .github └── workflows │ ├── scan.yaml │ └── release.yml ├── Dockerfile ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── dopplersecret_types.go │ └── zz_generated.deepcopy.go ├── controllers ├── dopplersecret_controller_util.go ├── suite_test.go ├── dopplersecret_controller_conditions.go ├── dopplersecret_controller_deployments.go ├── dopplersecret_controller.go ├── dopplersecret_controller_auth.go └── dopplersecret_controller_secrets.go ├── go.mod ├── main.go ├── Makefile ├── LICENSE ├── go.sum └── README.md /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /docs/diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DopplerHQ/kubernetes-operator/HEAD/docs/diagram.jpg -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | ControllerVersion = "dev" 5 | ) 6 | -------------------------------------------------------------------------------- /hack/helm/NOTES.txt: -------------------------------------------------------------------------------- 1 | To configure the Doppler operator, see https://github.com/DopplerHQ/kubernetes-operator 2 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore all files which are not go type 3 | !**/*.go 4 | !**/*.mod 5 | !**/*.sum 6 | -------------------------------------------------------------------------------- /config/scorecard/bases/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: scorecard.operatorframework.io/v1alpha3 2 | kind: Configuration 3 | metadata: 4 | name: config 5 | stages: 6 | - parallel: true 7 | tests: [] 8 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples you want in your CSV to this file as resources ## 2 | resources: 3 | - secrets_v1alpha1_dopplersecret.yaml 4 | #+kubebuilder:scaffold:manifestskustomizesamples 5 | -------------------------------------------------------------------------------- /pkg/models/secrets.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Secret struct { 4 | Name string 5 | Value string 6 | } 7 | 8 | type SecretsResult struct { 9 | Modified bool 10 | Secrets []Secret 11 | ETag string 12 | } 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - "/metrics" 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /tools/get-secret.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 1 ]; then 4 | echo 1>&2 "usage: $0 my-kube-secret" 5 | exit 2 6 | fi 7 | 8 | echo -e "\n### $1 ###\n" 9 | 10 | kubectl get secret "$1" -o go-template='{{range $k,$v := .data}}{{$k}}={{$v|base64decode}}{{"\n"}}{{end}}' 11 | -------------------------------------------------------------------------------- /config/scorecard/patches/basic.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - basic-check-spec 7 | image: quay.io/operator-framework/scorecard-test:v1.7.2 8 | labels: 9 | suite: basic 10 | test: basic-check-spec-test 11 | -------------------------------------------------------------------------------- /tools/operator-logs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | OPERATOR_NAMESPACE=doppler-operator-system 3 | OPERATOR_MANAGER=doppler-operator-controller-manager 4 | 5 | kubectl rollout status -w -n $OPERATOR_NAMESPACE deployment/$OPERATOR_MANAGER 6 | kubectl logs -f -n $OPERATOR_NAMESPACE deployments/$OPERATOR_MANAGER -c manager 7 | -------------------------------------------------------------------------------- /config/manager/controller_manager_config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 2 | kind: ControllerManagerConfig 3 | health: 4 | healthProbeBindAddress: :8081 5 | metrics: 6 | bindAddress: 127.0.0.1:8080 7 | webhook: 8 | port: 9443 9 | leaderElection: 10 | leaderElect: true 11 | resourceName: f39fa519.doppler.com 12 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_dopplersecrets.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 7 | name: dopplersecrets.secrets.doppler.com 8 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: controller-manager-metrics-service 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | 4 | generatorOptions: 5 | disableNameSuffixHash: true 6 | 7 | configMapGenerator: 8 | - files: 9 | - controller_manager_config.yaml 10 | name: manager-config 11 | apiVersion: kustomize.config.k8s.io/v1beta1 12 | kind: Kustomization 13 | images: 14 | - name: controller 15 | newName: dopplerhq/kubernetes-operator 16 | newTag: latest 17 | -------------------------------------------------------------------------------- /hack/helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: doppler-kubernetes-operator 3 | version: v1.0.0 4 | description: Automatically sync secrets from Doppler to Kubernetes and auto-reload deployments when secrets change. 5 | keywords: 6 | - Doppler 7 | - Secrets Manager 8 | home: https://doppler.com 9 | sources: 10 | - https://github.com/DopplerHQ/kubernetes-operator 11 | icon: https://doppler.com/imgs/logo_color.png 12 | -------------------------------------------------------------------------------- /hack/inject-nosec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | NOSEC_RULES="G601" 5 | 6 | function is_gnu_sed(){ 7 | sed --version >/dev/null 2>&1 8 | } 9 | 10 | while IFS= read -r -d '' file 11 | do 12 | sed_command="1s;^;/* #nosec $NOSEC_RULES */\n;" 13 | if is_gnu_sed; then 14 | sed -i "$sed_command" "$file" 15 | else 16 | sed -i "" "$sed_command" "$file" 17 | fi 18 | done < <(find . -iname '*zz_generated*' -print0) 19 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_dopplersecrets.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables a conversion webhook for the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: dopplersecrets.secrets.doppler.com 6 | spec: 7 | conversion: 8 | strategy: Webhook 9 | webhook: 10 | clientConfig: 11 | service: 12 | namespace: system 13 | name: webhook-service 14 | path: /convert 15 | -------------------------------------------------------------------------------- /config/rbac/dopplersecret_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view dopplersecrets. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: dopplersecret-viewer-role 6 | rules: 7 | - apiGroups: 8 | - secrets.doppler.com 9 | resources: 10 | - dopplersecrets 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - secrets.doppler.com 17 | resources: 18 | - dopplersecrets/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /config/scorecard/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - bases/config.yaml 3 | patchesJson6902: 4 | - path: patches/basic.config.yaml 5 | target: 6 | group: scorecard.operatorframework.io 7 | version: v1alpha3 8 | kind: Configuration 9 | name: config 10 | - path: patches/olm.config.yaml 11 | target: 12 | group: scorecard.operatorframework.io 13 | version: v1alpha3 14 | kind: Configuration 15 | name: config 16 | #+kubebuilder:scaffold:patchesJson6902 17 | -------------------------------------------------------------------------------- /config/rbac/dopplersecret_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit dopplersecrets. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: dopplersecret-editor-role 6 | rules: 7 | - apiGroups: 8 | - secrets.doppler.com 9 | resources: 10 | - dopplersecrets 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - secrets.doppler.com 21 | resources: 22 | - dopplersecrets/status 23 | verbs: 24 | - get 25 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: doppler.com 2 | layout: 3 | - go.kubebuilder.io/v3 4 | plugins: 5 | manifests.sdk.operatorframework.io/v2: {} 6 | scorecard.sdk.operatorframework.io/v2: {} 7 | projectName: doppler-kubernetes-operator 8 | repo: github.com/DopplerHQ/kubernetes-operator 9 | resources: 10 | - api: 11 | crdVersion: v1 12 | namespaced: true 13 | controller: true 14 | domain: doppler.com 15 | group: secrets 16 | kind: DopplerSecret 17 | path: github.com/DopplerHQ/kubernetes-operator/api/v1alpha1 18 | version: v1alpha1 19 | version: "3" 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | testbin/* 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Kubernetes Generated files - skip generated files, except for vendored files 18 | 19 | !vendor/**/zz_generated.* 20 | 21 | # editor and IDE paraphernalia 22 | .idea 23 | *.swp 24 | *.swo 25 | *~ 26 | 27 | # outputs 28 | /dist/ 29 | /charts/ 30 | /*.tgz 31 | 32 | # other 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: manager 13 | args: 14 | - "--health-probe-bind-address=:8081" 15 | - "--metrics-bind-address=127.0.0.1:8080" 16 | - "--leader-elect" 17 | -------------------------------------------------------------------------------- /config/default/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | args: 12 | - "--config=controller_manager_config.yaml" 13 | volumeMounts: 14 | - name: manager-config 15 | mountPath: /controller_manager_config.yaml 16 | subPath: controller_manager_config.yaml 17 | volumes: 18 | - name: manager-config 19 | configMap: 20 | name: manager-config 21 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Prometheus Monitor Service (Metrics) 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | control-plane: controller-manager 8 | name: controller-manager-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | scheme: https 15 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 16 | tlsConfig: 17 | insecureSkipVerify: true 18 | selector: 19 | matchLabels: 20 | control-plane: controller-manager 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ -------------------------------------------------------------------------------- /pkg/processors/processors.go: -------------------------------------------------------------------------------- 1 | package processors 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | ) 7 | 8 | type ProcessorFunc func(value string) ([]byte, error) 9 | 10 | func processPlain(value string) ([]byte, error) { 11 | return []byte(value), nil 12 | } 13 | 14 | func processBase64(value string) ([]byte, error) { 15 | decodedData, err := base64.StdEncoding.DecodeString(value) 16 | if err != nil { 17 | return nil, fmt.Errorf("Failed to decode base64 string: %w", err) 18 | } 19 | return decodedData, nil 20 | } 21 | 22 | var All = map[string]ProcessorFunc{ 23 | "plain": processPlain, 24 | "base64": processBase64, 25 | } 26 | -------------------------------------------------------------------------------- /config/samples/secrets_v1alpha1_dopplersecret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: secrets.doppler.com/v1alpha1 2 | kind: DopplerSecret 3 | metadata: 4 | name: dopplersecret-test # DopplerSecret Name 5 | namespace: doppler-operator-system 6 | spec: 7 | tokenSecret: # Kubernetes service token secret (namespace defaults to doppler-operator-system) 8 | name: doppler-token-secret 9 | project: example-project # Doppler project 10 | config: prd # Doppler config 11 | managedSecret: # Kubernetes managed secret (will be created if does not exist) 12 | name: doppler-test-secret 13 | namespace: default # Should match the namespace of deployments that will use the secret 14 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - coordination.k8s.io 21 | resources: 22 | - leases 23 | verbs: 24 | - get 25 | - list 26 | - watch 27 | - create 28 | - update 29 | - patch 30 | - delete 31 | - apiGroups: 32 | - "" 33 | resources: 34 | - events 35 | verbs: 36 | - create 37 | - patch 38 | -------------------------------------------------------------------------------- /.github/workflows/scan.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | 6 | name: Salus security scan 7 | 8 | jobs: 9 | gosec: 10 | runs-on: ubuntu-latest 11 | env: 12 | GO111MODULE: on 13 | steps: 14 | - name: Checkout Source 15 | uses: actions/checkout@v3 16 | - name: Run Gosec Security Scanner 17 | uses: securego/gosec@master 18 | with: 19 | args: ./... 20 | 21 | govulncheck: 22 | runs-on: ubuntu-latest 23 | name: Run govulncheck 24 | steps: 25 | - id: govulncheck 26 | uses: golang/govulncheck-action@v1 27 | with: 28 | go-version-input: 1.24 29 | go-package: ./... 30 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # Comment the following 4 lines if you want to disable 13 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 14 | # which protects your /metrics endpoint. 15 | # - auth_proxy_service.yaml 16 | # - auth_proxy_role.yaml 17 | # - auth_proxy_role_binding.yaml 18 | # - auth_proxy_client_clusterrole.yaml 19 | -------------------------------------------------------------------------------- /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/secrets.doppler.com_dopplersecrets.yaml 6 | #+kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patchesStrategicMerge: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- patches/webhook_in_dopplersecrets.yaml 12 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- patches/cainjection_in_dopplersecrets.yaml 17 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | configurations: 21 | - kustomizeconfig.yaml 22 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - secrets 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - update 17 | - watch 18 | - apiGroups: 19 | - "" 20 | resources: 21 | - serviceaccounts/token 22 | verbs: 23 | - create 24 | - apiGroups: 25 | - apps 26 | resources: 27 | - deployments 28 | verbs: 29 | - get 30 | - list 31 | - update 32 | - watch 33 | - apiGroups: 34 | - secrets.doppler.com 35 | resources: 36 | - dopplersecrets 37 | verbs: 38 | - create 39 | - delete 40 | - get 41 | - list 42 | - patch 43 | - update 44 | - watch 45 | - apiGroups: 46 | - secrets.doppler.com 47 | resources: 48 | - dopplersecrets/finalizers 49 | verbs: 50 | - update 51 | - apiGroups: 52 | - secrets.doppler.com 53 | resources: 54 | - dopplersecrets/status 55 | verbs: 56 | - get 57 | - patch 58 | - update 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.24 as builder 3 | 4 | ARG CONTROLLER_VERSION 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | 8 | WORKDIR /workspace 9 | # Copy the Go Modules manifests 10 | COPY go.mod go.mod 11 | COPY go.sum go.sum 12 | # cache deps before building and copying source so that we don't need to re-download as much 13 | # and so that source changes don't invalidate our downloaded layer 14 | RUN go mod download 15 | 16 | # Copy the go source 17 | COPY main.go main.go 18 | COPY api/ api/ 19 | COPY controllers/ controllers/ 20 | COPY pkg/ pkg/ 21 | 22 | # Build 23 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -ldflags="-X 'github.com/DopplerHQ/kubernetes-operator/pkg/version.ControllerVersion=${CONTROLLER_VERSION}'" -o manager main.go 24 | 25 | # Use distroless as minimal base image to package the manager binary 26 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 27 | FROM gcr.io/distroless/static:nonroot 28 | WORKDIR / 29 | COPY --from=builder /workspace/manager . 30 | USER 65532:65532 31 | 32 | ENTRYPOINT ["/manager"] 33 | -------------------------------------------------------------------------------- /config/samples/deployment-envfrom.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: doppler-test-deployment-envfrom 5 | annotations: 6 | secrets.doppler.com/reload: 'true' 7 | spec: 8 | replicas: 2 9 | selector: 10 | matchLabels: 11 | app: doppler-test 12 | template: 13 | metadata: 14 | labels: 15 | app: doppler-test 16 | spec: 17 | containers: 18 | - name: doppler-test 19 | image: alpine 20 | command: 21 | - /bin/sh 22 | - -c 23 | # Print all non-Kubernetes environment variables 24 | - apk add --no-cache tini > /dev/null 2>&1 && 25 | echo "### This is a simple deployment running with this env:" && 26 | printenv | grep -v KUBERNETES_ && 27 | tini -s tail -f /dev/null 28 | imagePullPolicy: Always 29 | envFrom: 30 | - secretRef: 31 | name: doppler-test-secret # Kubernetes secret name 32 | resources: 33 | requests: 34 | memory: '250Mi' 35 | cpu: '250m' 36 | limits: 37 | memory: '500Mi' 38 | cpu: '500m' 39 | -------------------------------------------------------------------------------- /config/manifests/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # These resources constitute the fully configured set of manifests 2 | # used to generate the 'manifests/' directory in a bundle. 3 | resources: 4 | - bases/kubernetes-operator.clusterserviceversion.yaml 5 | - ../default 6 | - ../samples 7 | - ../scorecard 8 | 9 | # [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix. 10 | # Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager. 11 | # These patches remove the unnecessary "cert" volume and its manager container volumeMount. 12 | #patchesJson6902: 13 | #- target: 14 | # group: apps 15 | # version: v1 16 | # kind: Deployment 17 | # name: controller-manager 18 | # namespace: system 19 | # patch: |- 20 | # # Remove the manager container's "cert" volumeMount, since OLM will create and mount a set of certs. 21 | # # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment. 22 | # - op: remove 23 | # path: /spec/template/spec/containers/1/volumeMounts/0 24 | # # Remove the "cert" volume, since OLM will create and mount a set of certs. 25 | # # Update the indices in this path if adding or removing volumes in the manager's Deployment. 26 | # - op: remove 27 | # path: /spec/template/spec/volumes/0 28 | -------------------------------------------------------------------------------- /hack/helm/deploy-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | tmpDir=$(mktemp -d) 5 | 6 | function cleanup() { 7 | rm -r "$tmpDir" 8 | echo "Deleted $tmpDir" 9 | } 10 | trap cleanup EXIT 11 | 12 | # TEMP/charts will contain our new chart 13 | mkdir "$tmpDir/charts" 14 | cp "$1" "$tmpDir/charts" 15 | pushd "$tmpDir" 16 | 17 | git clone https://github.com/DopplerHQ/helm-charts.git 18 | cd helm-charts 19 | 20 | git config user.name "Doppler Bot" 21 | git config user.email "support@doppler.com" 22 | 23 | if [[ -f "index.yaml" ]]; then 24 | echo "Found index, merging changes" 25 | # Ugly workaround to preserve old file timestamps: https://github.com/helm/helm/issues/7363#issuecomment-572369872 26 | # Generate the index from TEMP/charts (which only contains our new chart) and merge in the existing index 27 | helm repo index ../charts --url https://helm.doppler.com --merge index.yaml 28 | # Then copy the index.yaml and the new chart to TEMP/helm-charts 29 | mv ../charts/* ./ 30 | else 31 | echo "No index found, generating a new one" 32 | # Copy new chart TEMP/helm-charts 33 | mv ../charts/* ./ 34 | # Generate the new index in this dir with the new chart 35 | helm repo index . --url https://helm.doppler.com 36 | fi 37 | 38 | git add . 39 | git commit -m "Publish Helm charts" 40 | git push 41 | 42 | popd 43 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package v1alpha1 contains API Schema definitions for the secrets v1alpha1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=secrets.doppler.com 20 | package v1alpha1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "secrets.doppler.com", Version: "v1alpha1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /config/samples/deployment-valuefrom.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: doppler-test-deployment-valuefrom 5 | annotations: 6 | secrets.doppler.com/reload: 'true' 7 | spec: 8 | replicas: 2 9 | selector: 10 | matchLabels: 11 | app: doppler-test 12 | template: 13 | metadata: 14 | labels: 15 | app: doppler-test 16 | spec: 17 | containers: 18 | - name: doppler-test 19 | image: alpine 20 | command: 21 | - /bin/sh 22 | - -c 23 | # Print all non-Kubernetes environment variables 24 | - apk add --no-cache tini > /dev/null 2>&1 && 25 | echo "### This is a simple deployment running with this env:" && 26 | printenv | grep -v KUBERNETES_ && 27 | tini -s tail -f /dev/null 28 | imagePullPolicy: Always 29 | env: 30 | - name: DOPPLER_CONFIG # The name of the environment variable exposed in the container 31 | valueFrom: 32 | secretKeyRef: 33 | name: doppler-test-secret # Kubernetes secret name 34 | key: DOPPLER_CONFIG # The name of the key in the Kubernetes secret 35 | resources: 36 | requests: 37 | memory: '250Mi' 38 | cpu: '250m' 39 | limits: 40 | memory: '500Mi' 41 | cpu: '500m' 42 | -------------------------------------------------------------------------------- /config/scorecard/patches/olm.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - olm-bundle-validation 7 | image: quay.io/operator-framework/scorecard-test:v1.7.2 8 | labels: 9 | suite: olm 10 | test: olm-bundle-validation-test 11 | - op: add 12 | path: /stages/0/tests/- 13 | value: 14 | entrypoint: 15 | - scorecard-test 16 | - olm-crds-have-validation 17 | image: quay.io/operator-framework/scorecard-test:v1.7.2 18 | labels: 19 | suite: olm 20 | test: olm-crds-have-validation-test 21 | - op: add 22 | path: /stages/0/tests/- 23 | value: 24 | entrypoint: 25 | - scorecard-test 26 | - olm-crds-have-resources 27 | image: quay.io/operator-framework/scorecard-test:v1.7.2 28 | labels: 29 | suite: olm 30 | test: olm-crds-have-resources-test 31 | - op: add 32 | path: /stages/0/tests/- 33 | value: 34 | entrypoint: 35 | - scorecard-test 36 | - olm-spec-descriptors 37 | image: quay.io/operator-framework/scorecard-test:v1.7.2 38 | labels: 39 | suite: olm 40 | test: olm-spec-descriptors-test 41 | - op: add 42 | path: /stages/0/tests/- 43 | value: 44 | entrypoint: 45 | - scorecard-test 46 | - olm-status-descriptors 47 | image: quay.io/operator-framework/scorecard-test:v1.7.2 48 | labels: 49 | suite: olm 50 | test: olm-status-descriptors-test 51 | -------------------------------------------------------------------------------- /config/samples/deployment-volume.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: doppler-test-deployment-volume 5 | annotations: 6 | secrets.doppler.com/reload: 'true' 7 | spec: 8 | replicas: 2 9 | selector: 10 | matchLabels: 11 | app: doppler-test 12 | template: 13 | metadata: 14 | labels: 15 | app: doppler-test 16 | spec: 17 | volumes: 18 | - name: secret-volume 19 | secret: 20 | secretName: doppler-test-secret # Kubernetes secret name 21 | containers: 22 | - name: doppler-test 23 | image: alpine 24 | command: # ['/bin/sh', '-c', 'apk add --no-cache tini > /dev/null 2>&1 && ls /etc/secrets | grep -v KUBERNETES_&& tini -s tail -f /dev/null'] # List all non-Kubernetes secret files 25 | - /bin/sh 26 | - -c 27 | - apk add --no-cache tini > /dev/null 2>&1 && 28 | echo "### This is a simple deployment running with these mounted secret files:" && 29 | (for f in $(find /etc/secrets -type f); do echo $f && cat $f && echo ""; done) && 30 | tini -s tail -f /dev/null 31 | imagePullPolicy: Always 32 | volumeMounts: 33 | - name: secret-volume 34 | mountPath: /etc/secrets 35 | readOnly: true 36 | resources: 37 | requests: 38 | memory: '250Mi' 39 | cpu: '250m' 40 | limits: 41 | memory: '500Mi' 42 | cpu: '500m' 43 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller_util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "fmt" 21 | "io/ioutil" 22 | "os" 23 | "strings" 24 | ) 25 | 26 | func GetOwnNamespace() (string, error) { 27 | // Adapted from https://github.com/kubernetes/kubernetes/pull/63707 28 | 29 | // This way assumes you've set the POD_NAMESPACE environment variable using the downward API. 30 | // This check has to be done first for backwards compatibility with the way InClusterConfig was originally set up 31 | if ns, ok := os.LookupEnv("POD_NAMESPACE"); ok { 32 | return ns, nil 33 | } 34 | 35 | // Fall back to the namespace associated with the service account token, if available 36 | if data, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { 37 | if ns := strings.TrimSpace(string(data)); len(ns) > 0 { 38 | return ns, nil 39 | } 40 | return "", fmt.Errorf("Failed to find current namespace for the operator: %w", err) 41 | } else { 42 | return "", err 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: system 7 | --- 8 | apiVersion: apps/v1 9 | kind: Deployment 10 | metadata: 11 | name: controller-manager 12 | namespace: system 13 | labels: 14 | control-plane: controller-manager 15 | spec: 16 | selector: 17 | matchLabels: 18 | control-plane: controller-manager 19 | replicas: 1 20 | template: 21 | metadata: 22 | labels: 23 | control-plane: controller-manager 24 | spec: 25 | securityContext: 26 | runAsNonRoot: true 27 | containers: 28 | - command: 29 | - /manager 30 | args: 31 | - --leader-elect 32 | image: controller:latest 33 | name: manager 34 | securityContext: 35 | allowPrivilegeEscalation: false 36 | privileged: false 37 | runAsNonRoot: true 38 | capabilities: 39 | drop: 40 | - NET_RAW 41 | livenessProbe: 42 | httpGet: 43 | path: /healthz 44 | port: 8081 45 | initialDelaySeconds: 15 46 | periodSeconds: 20 47 | readinessProbe: 48 | httpGet: 49 | path: /readyz 50 | port: 8081 51 | initialDelaySeconds: 5 52 | periodSeconds: 10 53 | resources: 54 | limits: 55 | cpu: 100m 56 | memory: 256Mi 57 | requests: 58 | cpu: 100m 59 | memory: 256Mi 60 | serviceAccountName: controller-manager 61 | terminationGracePeriodSeconds: 10 62 | -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "path/filepath" 21 | "testing" 22 | 23 | . "github.com/onsi/ginkgo/v2" 24 | . "github.com/onsi/gomega" 25 | "k8s.io/client-go/kubernetes/scheme" 26 | "k8s.io/client-go/rest" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | "sigs.k8s.io/controller-runtime/pkg/envtest" 29 | 30 | logf "sigs.k8s.io/controller-runtime/pkg/log" 31 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 32 | 33 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 34 | //+kubebuilder:scaffold:imports 35 | ) 36 | 37 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 38 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 39 | 40 | var cfg *rest.Config 41 | var k8sClient client.Client 42 | var testEnv *envtest.Environment 43 | 44 | func TestAPIs(t *testing.T) { 45 | RegisterFailHandler(Fail) 46 | 47 | RunSpecs(t, "Controller Suite") 48 | } 49 | 50 | var _ = BeforeSuite(func() { 51 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 52 | 53 | By("bootstrapping test environment") 54 | testEnv = &envtest.Environment{ 55 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 56 | ErrorIfCRDPathMissing: true, 57 | } 58 | 59 | cfg, err := testEnv.Start() 60 | Expect(err).NotTo(HaveOccurred()) 61 | Expect(cfg).NotTo(BeNil()) 62 | 63 | err = secretsv1alpha1.AddToScheme(scheme.Scheme) 64 | Expect(err).NotTo(HaveOccurred()) 65 | 66 | //+kubebuilder:scaffold:scheme 67 | 68 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 69 | Expect(err).NotTo(HaveOccurred()) 70 | Expect(k8sClient).NotTo(BeNil()) 71 | 72 | }, 60) 73 | 74 | var _ = AfterSuite(func() { 75 | By("tearing down the test environment") 76 | err := testEnv.Stop() 77 | Expect(err).NotTo(HaveOccurred()) 78 | }) 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | environment: "production" 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Extract Version 17 | id: extract 18 | run: echo "version=${GITHUB_REF#refs/*/v}" >> $GITHUB_OUTPUT 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v1 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v1 25 | 26 | - name: Login to DockerHub 27 | uses: docker/login-action@v1 28 | with: 29 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 30 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 31 | 32 | - name: Build and push 33 | id: docker_build 34 | uses: docker/build-push-action@v2 35 | with: 36 | push: true 37 | platforms: linux/amd64,linux/arm64 38 | tags: | 39 | dopplerhq/kubernetes-operator:${{ steps.extract.outputs.version }} 40 | dopplerhq/kubernetes-operator:latest 41 | build-args: | 42 | CONTROLLER_VERSION=${{ steps.extract.outputs.version }} 43 | 44 | - name: Image digest 45 | run: echo ${{ steps.docker_build.outputs.digest }} 46 | 47 | - uses: actions/setup-go@v2 48 | with: 49 | go-version: "1.24" 50 | 51 | - name: Generate Helm Chart and Recommended YAML 52 | run: make dist charts 53 | env: 54 | VERSION: ${{ steps.extract.outputs.version }} 55 | 56 | - name: Upload Recommended YAML 57 | uses: svenstaro/upload-release-action@v2 58 | with: 59 | repo_token: ${{ secrets.GITHUB_TOKEN }} 60 | file: dist/recommended.yaml 61 | tag: ${{ github.ref }} 62 | 63 | - name: Generate a token 64 | id: generate-token 65 | uses: actions/create-github-app-token@v1 66 | with: 67 | app-id: ${{ secrets.GH_APP_ID }} 68 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 69 | owner: ${{ github.repository_owner }} 70 | repositories: helm-charts 71 | 72 | - name: Publish Helm Chart 73 | env: 74 | GH_TOKEN: ${{ steps.generate-token.outputs.token }} 75 | run: | 76 | gh auth setup-git 77 | ./hack/helm/deploy-pages.sh ./doppler-kubernetes-operator*.tgz 78 | -------------------------------------------------------------------------------- /pkg/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "container/list" 5 | "sync" 6 | ) 7 | 8 | type Key struct { 9 | Identity string 10 | // Audiences is part of the cache key because it includes the token secret's 11 | // namespace and name (e.g., "dopplerTokenSecret:namespace:secret-name"). 12 | // This allows the same identity to be used in different token secrets 13 | // (different names or namespaces) while maintaining separate cached providers 14 | // for each, since each will have different audience claims in their JWTs. 15 | Audiences string 16 | } 17 | 18 | type Cache[T any] struct { 19 | mu sync.RWMutex 20 | items map[Key]*list.Element 21 | lruList *list.List 22 | maxSize int 23 | onEvict func(T) 24 | } 25 | 26 | type cacheEntry[T any] struct { 27 | key Key 28 | value T 29 | } 30 | 31 | func New[T any](maxSize int, onEvict func(T)) *Cache[T] { 32 | return &Cache[T]{ 33 | items: make(map[Key]*list.Element), 34 | lruList: list.New(), 35 | maxSize: maxSize, 36 | onEvict: onEvict, 37 | } 38 | } 39 | 40 | func (c *Cache[T]) Get(key Key) (T, bool) { 41 | if c.maxSize == 0 { 42 | var zero T 43 | return zero, false 44 | } 45 | 46 | c.mu.Lock() 47 | defer c.mu.Unlock() 48 | 49 | if elem, exists := c.items[key]; exists { 50 | entry := elem.Value.(*cacheEntry[T]) 51 | c.lruList.MoveToFront(elem) 52 | return entry.value, true 53 | } 54 | 55 | var zero T 56 | return zero, false 57 | } 58 | 59 | func (c *Cache[T]) Add(key Key, value T) { 60 | if c.maxSize == 0 { 61 | return 62 | } 63 | 64 | c.mu.Lock() 65 | defer c.mu.Unlock() 66 | 67 | if elem, exists := c.items[key]; exists { 68 | entry := elem.Value.(*cacheEntry[T]) 69 | entry.value = value 70 | c.lruList.MoveToFront(elem) 71 | return 72 | } 73 | 74 | // Add new entry 75 | entry := &cacheEntry[T]{ 76 | key: key, 77 | value: value, 78 | } 79 | elem := c.lruList.PushFront(entry) 80 | c.items[key] = elem 81 | 82 | // Check if we need to evict 83 | if c.lruList.Len() > c.maxSize { 84 | c.evictOldest() 85 | } 86 | } 87 | 88 | func (c *Cache[T]) Remove(key Key) { 89 | c.mu.Lock() 90 | defer c.mu.Unlock() 91 | 92 | if elem, exists := c.items[key]; exists { 93 | c.removeElement(elem) 94 | } 95 | } 96 | 97 | // Remove element from cache 98 | func (c *Cache[T]) removeElement(elem *list.Element) { 99 | entry := elem.Value.(*cacheEntry[T]) 100 | delete(c.items, entry.key) 101 | c.lruList.Remove(elem) 102 | if c.onEvict != nil { 103 | c.onEvict(entry.value) 104 | } 105 | } 106 | 107 | // Remove the least recently used item 108 | func (c *Cache[T]) evictOldest() { 109 | elem := c.lruList.Back() 110 | if elem != nil { 111 | c.removeElement(elem) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: doppler-operator-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: doppler-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 20 | # crd/kustomization.yaml 21 | #- ../webhook 22 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 23 | #- ../certmanager 24 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 25 | #- ../prometheus 26 | 27 | patchesStrategicMerge: 28 | # Protect the /metrics endpoint by putting it behind auth. 29 | # If you want your controller-manager to expose the /metrics 30 | # endpoint w/o any authn/z, please comment the following line. 31 | - manager_auth_proxy_patch.yaml 32 | 33 | # Mount the controller config file for loading manager configurations 34 | # through a ComponentConfig type 35 | #- manager_config_patch.yaml 36 | 37 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 38 | # crd/kustomization.yaml 39 | #- manager_webhook_patch.yaml 40 | 41 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 42 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 43 | # 'CERTMANAGER' needs to be enabled to use ca injection 44 | #- webhookcainjection_patch.yaml 45 | 46 | # the following config is for teaching kustomize how to do var substitution 47 | vars: 48 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 49 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 50 | # objref: 51 | # kind: Certificate 52 | # group: cert-manager.io 53 | # version: v1 54 | # name: serving-cert # this name should match the one in certificate.yaml 55 | # fieldref: 56 | # fieldpath: metadata.namespace 57 | #- name: CERTIFICATE_NAME 58 | # objref: 59 | # kind: Certificate 60 | # group: cert-manager.io 61 | # version: v1 62 | # name: serving-cert # this name should match the one in certificate.yaml 63 | #- name: SERVICE_NAMESPACE # namespace of the service 64 | # objref: 65 | # kind: Service 66 | # version: v1 67 | # name: webhook-service 68 | # fieldref: 69 | # fieldpath: metadata.namespace 70 | #- name: SERVICE_NAME 71 | # objref: 72 | # kind: Service 73 | # version: v1 74 | # name: webhook-service 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/DopplerHQ/kubernetes-operator 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/go-logr/logr v1.4.2 7 | github.com/onsi/ginkgo/v2 v2.21.0 8 | github.com/onsi/gomega v1.35.1 9 | k8s.io/api v0.31.2 10 | k8s.io/apimachinery v0.31.2 11 | k8s.io/client-go v0.31.2 12 | sigs.k8s.io/controller-runtime v0.19.1 13 | ) 14 | 15 | require ( 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 19 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 20 | github.com/evanphx/json-patch v4.9.0+incompatible // indirect 21 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 22 | github.com/fsnotify/fsnotify v1.7.0 // indirect 23 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 24 | github.com/go-logr/zapr v1.3.0 // indirect 25 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 26 | github.com/go-openapi/jsonreference v0.20.2 // indirect 27 | github.com/go-openapi/swag v0.22.4 // indirect 28 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 29 | github.com/gogo/protobuf v1.3.2 // indirect 30 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 31 | github.com/golang/protobuf v1.5.4 // indirect 32 | github.com/google/gnostic-models v0.6.8 // indirect 33 | github.com/google/go-cmp v0.6.0 // indirect 34 | github.com/google/gofuzz v1.2.0 // indirect 35 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect 36 | github.com/google/uuid v1.6.0 // indirect 37 | github.com/imdario/mergo v0.3.10 // indirect 38 | github.com/josharian/intern v1.0.0 // indirect 39 | github.com/json-iterator/go v1.1.12 // indirect 40 | github.com/mailru/easyjson v0.7.7 // indirect 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 42 | github.com/modern-go/reflect2 v1.0.2 // indirect 43 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 44 | github.com/pkg/errors v0.9.1 // indirect 45 | github.com/prometheus/client_golang v1.19.1 // indirect 46 | github.com/prometheus/client_model v0.6.1 // indirect 47 | github.com/prometheus/common v0.55.0 // indirect 48 | github.com/prometheus/procfs v0.15.1 // indirect 49 | github.com/spf13/pflag v1.0.5 // indirect 50 | github.com/x448/float16 v0.8.4 // indirect 51 | go.uber.org/multierr v1.11.0 // indirect 52 | go.uber.org/zap v1.26.0 // indirect 53 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect 54 | golang.org/x/net v0.34.0 // indirect 55 | golang.org/x/oauth2 v0.21.0 // indirect 56 | golang.org/x/sys v0.29.0 // indirect 57 | golang.org/x/term v0.28.0 // indirect 58 | golang.org/x/text v0.21.0 // indirect 59 | golang.org/x/time v0.3.0 // indirect 60 | golang.org/x/tools v0.26.0 // indirect 61 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 62 | google.golang.org/protobuf v1.35.1 // indirect 63 | gopkg.in/inf.v0 v0.9.1 // indirect 64 | gopkg.in/yaml.v2 v2.4.0 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | k8s.io/apiextensions-apiserver v0.31.0 // indirect 67 | k8s.io/klog/v2 v2.130.1 // indirect 68 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 69 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect 70 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 71 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 72 | sigs.k8s.io/yaml v1.4.0 // indirect 73 | ) 74 | -------------------------------------------------------------------------------- /docs/custom_types_and_processors.md: -------------------------------------------------------------------------------- 1 | # Custom Types and Processors 2 | 3 | By default, the operator syncs secret values as they are in Doppler to an [`Opaque` Kubernetes secret](https://kubernetes.io/docs/concepts/configuration/secret/) as Key / Value pairs. 4 | 5 | In some cases, the secret name or value stored in Doppler is not the format required for your Kubernetes deployment. 6 | For example, you might have Base64-encoded TLS data that you want to copy to a native Kubernetes TLS secret. 7 | 8 | Processors provide a mechanism to achieve this. 9 | 10 | Below is the Doppler Secret used in the Getting Started example with some modifications. 11 | 12 | ```yaml 13 | apiVersion: secrets.doppler.com/v1alpha1 14 | kind: DopplerSecret 15 | metadata: 16 | name: dopplersecret-test 17 | namespace: doppler-operator-system 18 | spec: 19 | tokenSecret: 20 | name: doppler-token-secret 21 | managedSecret: 22 | name: doppler-test-secret 23 | namespace: default 24 | type: kubernetes.io/tls 25 | labels: 26 | doppler-secret-label: test 27 | # TLS secrets are required to have the secret fields `tls.crt` and `tls.key` 28 | processors: 29 | TLS_CRT: 30 | type: base64 31 | asName: tls.crt 32 | TLS_KEY: 33 | type: base64 34 | asName: tls.key 35 | ``` 36 | 37 | First, we've added a `type` field to the managed secret reference to define the `kubernetes.io/tls` managed secret type. When the operator creates the managed secret, it will have this Kubernetes secret type. 38 | 39 | Managed secrets can also specify `labels` and `annotations`. These will be added verbatim to the `metadata` field in the resulting `Secret`. 40 | 41 | We've also added a field called `processors`. Processors can make alterations to a secret's name or value before they are saved to the Kubernetes managed secret. 42 | 43 | Kubernetes TLS manged secrets require the `tls.crt` and `tls.key` fields to be present in the secret data. To accommodate this, we're using two processors to remap our Doppler secrets named `TLS_CRT` and `TLS_KEY` to the correct field names with `asName`. 44 | 45 | We can define the processor's `type` to instruct the operator to transform the secret value before saving it into the managed secret. Processors have a default type of `plain`, which treats the Doppler secret value as a plain string. In our example, we've provided the `base64` type which instructs the operator to process the Doppler secret value as Base64 encoded data. 46 | 47 | **Note:** The processors are only applied if there is a Doppler secret in your config which corresponds with the processor name. 48 | 49 | You can have any number of processors, each with different types and name mappings (or no name mapping at all). 50 | 51 | ```yaml 52 | processors: 53 | MY_SECRET: 54 | type: plain 55 | OTHER_SECRET: 56 | type: plain 57 | asName: otherSecret 58 | ``` 59 | 60 | Below are the types of processors available to the operator: 61 | 62 | ## Plain 63 | 64 | ```yaml 65 | type: plain 66 | ``` 67 | 68 | The default processor. This treats the data in the secret as plain string data. 69 | 70 | ## Base64 71 | 72 | ```yaml 73 | type: base64 74 | ``` 75 | 76 | This processor will attempt to [Base64](https://en.wikipedia.org/wiki/Base64) decode the provided string and output the resulting bytes. 77 | 78 | For example, the Base64 processor could be used to decode a Base64 encoded `.p12` file for mounting in a container in its original binary format. 79 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller_conditions.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | "k8s.io/apimachinery/pkg/api/meta" 24 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 25 | 26 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 27 | ) 28 | 29 | func (r *DopplerSecretReconciler) SetSecretsSyncReadyCondition(ctx context.Context, dopplerSecret *secretsv1alpha1.DopplerSecret, updateSecretsError error) { 30 | log := r.Log.WithValues("dopplersecret", dopplerSecret.GetNamespacedName()) 31 | if dopplerSecret.Status.Conditions == nil { 32 | dopplerSecret.Status.Conditions = []metav1.Condition{} 33 | } 34 | if updateSecretsError == nil { 35 | meta.SetStatusCondition(&dopplerSecret.Status.Conditions, metav1.Condition{ 36 | Type: "secrets.doppler.com/SecretSyncReady", 37 | Status: metav1.ConditionTrue, 38 | Reason: "OK", 39 | Message: "Controller is continuously syncing secrets", 40 | }) 41 | } else { 42 | meta.SetStatusCondition(&dopplerSecret.Status.Conditions, metav1.Condition{ 43 | Type: "secrets.doppler.com/SecretSyncReady", 44 | Status: metav1.ConditionFalse, 45 | Reason: "Error", 46 | Message: fmt.Sprintf("Secret update failed: %v", updateSecretsError), 47 | }) 48 | meta.SetStatusCondition(&dopplerSecret.Status.Conditions, metav1.Condition{ 49 | Type: "secrets.doppler.com/DeploymentReloadReady", 50 | Status: metav1.ConditionFalse, 51 | Reason: "Stopped", 52 | Message: "Deployment reload has been stopped due to secrets sync failure", 53 | }) 54 | } 55 | err := r.Client.Status().Update(ctx, dopplerSecret) 56 | if err != nil { 57 | log.Error(err, "Unable to set update secret condition") 58 | } 59 | } 60 | 61 | func (r *DopplerSecretReconciler) SetDeploymentReloadReadyCondition(ctx context.Context, dopplerSecret *secretsv1alpha1.DopplerSecret, numDeployments int, deploymentError error) { 62 | log := r.Log.WithValues("dopplersecret", dopplerSecret.GetNamespacedName()) 63 | if dopplerSecret.Status.Conditions == nil { 64 | dopplerSecret.Status.Conditions = []metav1.Condition{} 65 | } 66 | if deploymentError == nil { 67 | meta.SetStatusCondition(&dopplerSecret.Status.Conditions, metav1.Condition{ 68 | Type: "secrets.doppler.com/DeploymentReloadReady", 69 | Status: metav1.ConditionTrue, 70 | Reason: "OK", 71 | Message: fmt.Sprintf("Controller is ready to reload deployments. %v found.", numDeployments), 72 | }) 73 | } else { 74 | meta.SetStatusCondition(&dopplerSecret.Status.Conditions, metav1.Condition{ 75 | Type: "secrets.doppler.com/DeploymentReloadReady", 76 | Status: metav1.ConditionFalse, 77 | Reason: "Error", 78 | Message: fmt.Sprintf("Deployment reconcile failed: %v", deploymentError), 79 | }) 80 | } 81 | err := r.Client.Status().Update(ctx, dopplerSecret) 82 | if err != nil { 83 | log.Error(err, "Unable to set reconcile deployments condition") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "flag" 21 | "os" 22 | 23 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 24 | // to ensure that exec-entrypoint and run can make use of them. 25 | _ "k8s.io/client-go/plugin/pkg/client/auth" 26 | 27 | "k8s.io/apimachinery/pkg/runtime" 28 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 29 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/healthz" 32 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 33 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 34 | 35 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 36 | "github.com/DopplerHQ/kubernetes-operator/controllers" 37 | "github.com/DopplerHQ/kubernetes-operator/pkg/version" 38 | //+kubebuilder:scaffold:imports 39 | ) 40 | 41 | var ( 42 | scheme = runtime.NewScheme() 43 | setupLog = ctrl.Log.WithName("setup") 44 | ) 45 | 46 | func init() { 47 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 48 | 49 | utilruntime.Must(secretsv1alpha1.AddToScheme(scheme)) 50 | //+kubebuilder:scaffold:scheme 51 | } 52 | 53 | func main() { 54 | var metricsAddr string 55 | var enableLeaderElection bool 56 | var probeAddr string 57 | var oidcProviderCacheSize int 58 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") 59 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 60 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 61 | "Enable leader election for controller manager. "+ 62 | "Enabling this will ensure there is only one active controller manager.") 63 | flag.IntVar(&oidcProviderCacheSize, "oidc-provider-cache-size", 2<<13, "Size of the OIDC provider cache. Set to 0 to disable caching.") 64 | opts := zap.Options{ 65 | Development: true, 66 | } 67 | opts.BindFlags(flag.CommandLine) 68 | flag.Parse() 69 | 70 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 71 | log := ctrl.Log.WithName("controllers").WithName("DopplerSecret") 72 | 73 | controllers.InitializeOIDCCache(log, oidcProviderCacheSize) 74 | 75 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 76 | Scheme: scheme, 77 | Metrics: metricsserver.Options{ 78 | BindAddress: metricsAddr, 79 | }, 80 | HealthProbeBindAddress: probeAddr, 81 | LeaderElection: enableLeaderElection, 82 | LeaderElectionID: "f39fa519.doppler.com", 83 | }) 84 | if err != nil { 85 | setupLog.Error(err, "unable to start manager") 86 | os.Exit(1) 87 | } 88 | 89 | if err = (&controllers.DopplerSecretReconciler{ 90 | Client: mgr.GetClient(), 91 | Log: log, 92 | Scheme: mgr.GetScheme(), 93 | }).SetupWithManager(mgr); err != nil { 94 | setupLog.Error(err, "unable to create controller", "controller", "DopplerSecret") 95 | os.Exit(1) 96 | } 97 | //+kubebuilder:scaffold:builder 98 | 99 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 100 | setupLog.Error(err, "unable to set up health check") 101 | os.Exit(1) 102 | } 103 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 104 | setupLog.Error(err, "unable to set up ready check") 105 | os.Exit(1) 106 | } 107 | 108 | setupLog.Info("starting manager", "controllerVersion", version.ControllerVersion) 109 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 110 | setupLog.Error(err, "problem running manager") 111 | os.Exit(1) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/auth/oidc.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "sync" 12 | "time" 13 | 14 | authenticationv1 "k8s.io/api/authentication/v1" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/client-go/kubernetes" 17 | ) 18 | 19 | // Handle OIDC-based authentication 20 | type OIDCAuthProvider struct { 21 | // Kubernetes client for TokenRequest API 22 | KubeClient kubernetes.Interface 23 | 24 | Namespace string 25 | Audiences []string 26 | 27 | Host string 28 | Identity string 29 | VerifyTLS bool 30 | ExpirationSeconds int64 31 | 32 | // Token management 33 | rwm sync.RWMutex 34 | cachedToken string 35 | tokenExpiry time.Time 36 | } 37 | 38 | 39 | // Returns a Doppler API token, refreshing if necessary 40 | func (o *OIDCAuthProvider) GetToken(ctx context.Context) (string, error) { 41 | o.rwm.RLock() 42 | if o.isTokenValid() { 43 | token := o.cachedToken 44 | o.rwm.RUnlock() 45 | return token, nil 46 | } 47 | o.rwm.RUnlock() 48 | 49 | // Token needs refresh 50 | return o.refreshToken(ctx) 51 | } 52 | 53 | // Check if the cached token is still valid 54 | func (o *OIDCAuthProvider) isTokenValid() bool { 55 | if o.cachedToken == "" { 56 | return false 57 | } 58 | return time.Until(o.tokenExpiry) > 60*time.Second 59 | } 60 | 61 | // Obtain a new Doppler Service Account API token 62 | func (o *OIDCAuthProvider) refreshToken(ctx context.Context) (string, error) { 63 | o.rwm.Lock() 64 | defer o.rwm.Unlock() 65 | 66 | // Sanity check after acquiring write lock 67 | if o.isTokenValid() { 68 | return o.cachedToken, nil 69 | } 70 | 71 | saToken, err := o.getServiceAccountToken(ctx) 72 | if err != nil { 73 | return "", fmt.Errorf("Failed to get service account token: %w", err) 74 | } 75 | 76 | dopplerToken, expiry, err := o.exchangeTokenWithDoppler(ctx, saToken) 77 | if err != nil { 78 | return "", fmt.Errorf("Failed to exchange token with Doppler: %w", err) 79 | } 80 | 81 | o.cachedToken = dopplerToken 82 | o.tokenExpiry = expiry 83 | 84 | return dopplerToken, nil 85 | } 86 | 87 | // Use the TokenRequest API to get a JWT 88 | func (o *OIDCAuthProvider) getServiceAccountToken(ctx context.Context) (string, error) { 89 | tokenRequest := &authenticationv1.TokenRequest{ 90 | Spec: authenticationv1.TokenRequestSpec{ 91 | Audiences: o.Audiences, 92 | ExpirationSeconds: &o.ExpirationSeconds, 93 | }, 94 | } 95 | 96 | tokenResponse, err := o.KubeClient.CoreV1(). 97 | ServiceAccounts(o.Namespace). 98 | CreateToken(ctx, "doppler-operator-controller-manager", tokenRequest, metav1.CreateOptions{}) 99 | 100 | if err != nil { 101 | return "", fmt.Errorf("Failed to create service account token: %w", err) 102 | } 103 | 104 | return tokenResponse.Status.Token, nil 105 | } 106 | 107 | // Exchange the K8s SA token for a Doppler SA API Token 108 | func (o *OIDCAuthProvider) exchangeTokenWithDoppler(ctx context.Context, saToken string) (string, time.Time, error) { 109 | url := fmt.Sprintf("%s/v3/auth/oidc", o.Host) 110 | 111 | requestBody := map[string]string{ 112 | "identity": o.Identity, 113 | "token": saToken, 114 | } 115 | 116 | jsonBody, err := json.Marshal(requestBody) 117 | if err != nil { 118 | return "", time.Time{}, fmt.Errorf("Failed to marshal request body: %w", err) 119 | } 120 | 121 | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody)) 122 | if err != nil { 123 | return "", time.Time{}, fmt.Errorf("Failed to create request: %w", err) 124 | } 125 | 126 | req.Header.Set("Content-Type", "application/json") 127 | req.Header.Set("Accept", "application/json") 128 | 129 | transport := &http.Transport{ 130 | TLSClientConfig: &tls.Config{ 131 | InsecureSkipVerify: !o.VerifyTLS, 132 | }, 133 | } 134 | 135 | client := &http.Client{ 136 | Timeout: 10 * time.Second, 137 | Transport: transport, 138 | } 139 | 140 | resp, err := client.Do(req) 141 | if err != nil { 142 | return "", time.Time{}, fmt.Errorf("Failed to make request to Doppler: %w", err) 143 | } 144 | defer resp.Body.Close() 145 | 146 | body, err := io.ReadAll(resp.Body) 147 | if err != nil { 148 | return "", time.Time{}, fmt.Errorf("Failed to read response body: %w", err) 149 | } 150 | 151 | if resp.StatusCode != http.StatusOK { 152 | return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed with status %d: %s", resp.StatusCode, string(body)) 153 | } 154 | 155 | var response struct { 156 | Success bool `json:"success"` 157 | Token string `json:"token"` 158 | ExpiresAt string `json:"expires_at"` 159 | } 160 | 161 | if err := json.Unmarshal(body, &response); err != nil { 162 | return "", time.Time{}, fmt.Errorf("Failed to parse response: %w", err) 163 | } 164 | 165 | if !response.Success { 166 | return "", time.Time{}, fmt.Errorf("Doppler OIDC auth failed") 167 | } 168 | 169 | expiresAt, err := time.Parse(time.RFC3339, response.ExpiresAt) 170 | if err != nil { 171 | return "", time.Time{}, fmt.Errorf("Failed to parse expiration time: %w", err) 172 | } 173 | 174 | return response.Token, expiresAt, nil 175 | } 176 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller_deployments.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "sync" 23 | 24 | v1 "k8s.io/api/apps/v1" 25 | corev1 "k8s.io/api/core/v1" 26 | "k8s.io/apimachinery/pkg/types" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | 29 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 30 | ) 31 | 32 | const ( 33 | deploymentSecretUpdateAnnotationPrefix = "secrets.doppler.com/secretsupdate" 34 | deploymentRestartAnnotation = "secrets.doppler.com/reload" 35 | ) 36 | 37 | // Reconciles deployments marked with the restart annotation and that use the specified DopplerSecret. 38 | func (r *DopplerSecretReconciler) ReconcileDeploymentsUsingSecret(ctx context.Context, dopplerSecret secretsv1alpha1.DopplerSecret) (int, error) { 39 | log := r.Log.WithValues("dopplersecret", dopplerSecret.GetNamespacedName()) 40 | namespace := dopplerSecret.Namespace 41 | if dopplerSecret.Spec.ManagedSecretRef.Namespace != "" { 42 | namespace = dopplerSecret.Spec.ManagedSecretRef.Namespace 43 | } 44 | deploymentList := &v1.DeploymentList{} 45 | err := r.Client.List(ctx, deploymentList, &client.ListOptions{Namespace: namespace}) 46 | if err != nil { 47 | return 0, fmt.Errorf("Unable to fetch deployments: %w", err) 48 | } 49 | kubeSecretNamespacedName := types.NamespacedName{ 50 | Namespace: namespace, 51 | Name: dopplerSecret.Spec.ManagedSecretRef.Name, 52 | } 53 | kubeSecret := &corev1.Secret{} 54 | err = r.Client.Get(ctx, kubeSecretNamespacedName, kubeSecret) 55 | if err != nil { 56 | return 0, fmt.Errorf("Unable to fetch Kubernetes secret to update deployment: %w", err) 57 | } 58 | var wg sync.WaitGroup 59 | for _, deployment := range deploymentList.Items { 60 | if deployment.Annotations[deploymentRestartAnnotation] == "true" && r.IsDeploymentUsingSecret(deployment, dopplerSecret) { 61 | wg.Add(1) 62 | go func(deployment v1.Deployment, kubeSecret corev1.Secret, wg *sync.WaitGroup) { 63 | defer wg.Done() 64 | err := r.ReconcileDeployment(ctx, deployment, kubeSecret) 65 | if err != nil { 66 | // Errors reconciling deployments are logged but not propagated up. Failed deployments will be reconciled on the next run. 67 | log.Error(err, "Unable to reconcile deployment") 68 | } 69 | }(deployment, *kubeSecret, &wg) 70 | } 71 | } 72 | wg.Wait() 73 | 74 | log.Info("Finished reconciling deployments", "numDeployments", len(deploymentList.Items)) 75 | 76 | return len(deploymentList.Items), nil 77 | } 78 | 79 | // Evaluates whether or not the deployment is using the specified DopplerSecret. 80 | // Specifically, a deployment is using a DopplerSecret if it references it using `envFrom`, `secretKeyRef` or `volumes`. 81 | func (r *DopplerSecretReconciler) IsDeploymentUsingSecret(deployment v1.Deployment, dopplerSecret secretsv1alpha1.DopplerSecret) bool { 82 | managedSecretName := dopplerSecret.Spec.ManagedSecretRef.Name 83 | for _, container := range deployment.Spec.Template.Spec.Containers { 84 | for _, envFrom := range container.EnvFrom { 85 | if envFrom.SecretRef != nil && envFrom.SecretRef.LocalObjectReference.Name == managedSecretName { 86 | return true 87 | } 88 | } 89 | for _, env := range container.Env { 90 | if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.LocalObjectReference.Name == managedSecretName { 91 | return true 92 | } 93 | } 94 | } 95 | for _, volume := range deployment.Spec.Template.Spec.Volumes { 96 | if volume.Secret != nil && volume.Secret.SecretName == managedSecretName { 97 | return true 98 | } 99 | } 100 | 101 | return false 102 | } 103 | 104 | // Reconciles a deployment with a Kubernetes secret 105 | // Specifically, if the Kubernetes secret version is different from the deployment's secret version annotation, 106 | // the annotation is updated to restart the deployment. 107 | func (r *DopplerSecretReconciler) ReconcileDeployment(ctx context.Context, deployment v1.Deployment, secret corev1.Secret) error { 108 | log := r.Log.WithValues("deployment", fmt.Sprintf("%s/%s", deployment.Namespace, deployment.Name)) 109 | annotationKey := fmt.Sprintf("%s.%s", deploymentSecretUpdateAnnotationPrefix, secret.Name) 110 | annotationValue := secret.Annotations[kubeSecretVersionAnnotation] 111 | if deployment.Annotations[annotationKey] == annotationValue && 112 | deployment.Spec.Template.Annotations[annotationKey] == annotationValue { 113 | log.Info("[-] Deployment is already running latest version, nothing to do") 114 | return nil 115 | } 116 | deployment.Annotations[annotationKey] = annotationValue 117 | if deployment.Spec.Template.Annotations == nil { 118 | deployment.Spec.Template.Annotations = make(map[string]string) 119 | } 120 | deployment.Spec.Template.Annotations[annotationKey] = annotationValue 121 | err := r.Client.Update(ctx, &deployment) 122 | if err != nil { 123 | return fmt.Errorf("Failed to update deployment annotation: %w", err) 124 | } 125 | log.Info("[/] Updated deployment") 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/go-logr/logr" 25 | "k8s.io/apimachinery/pkg/api/errors" 26 | "k8s.io/apimachinery/pkg/runtime" 27 | ctrl "sigs.k8s.io/controller-runtime" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | 30 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 31 | ) 32 | 33 | // DopplerSecretReconciler reconciles a DopplerSecret object 34 | type DopplerSecretReconciler struct { 35 | client.Client 36 | Log logr.Logger 37 | Scheme *runtime.Scheme 38 | } 39 | 40 | const ( 41 | defaultRequeueDuration = time.Minute 42 | ) 43 | 44 | //+kubebuilder:rbac:groups=secrets.doppler.com,resources=dopplersecrets,verbs=get;list;watch;create;update;patch;delete 45 | //+kubebuilder:rbac:groups=secrets.doppler.com,resources=dopplersecrets/status,verbs=get;update;patch 46 | //+kubebuilder:rbac:groups=secrets.doppler.com,resources=dopplersecrets/finalizers,verbs=update 47 | 48 | //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;delete 49 | //+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create 50 | //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;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 | // For more details, check Reconcile and its Result here: 55 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.8.3/pkg/reconcile 56 | func (r *DopplerSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 57 | log := r.Log.WithValues("dopplersecret", req.NamespacedName) 58 | 59 | ownNamespace, namespaceErr := GetOwnNamespace() 60 | if namespaceErr != nil { 61 | log.Error(namespaceErr, "Unable to load current namespace") 62 | return ctrl.Result{ 63 | RequeueAfter: defaultRequeueDuration, 64 | }, nil 65 | } 66 | 67 | dopplerSecret := secretsv1alpha1.DopplerSecret{} 68 | err := r.Client.Get(ctx, req.NamespacedName, &dopplerSecret) 69 | if err != nil { 70 | if errors.IsNotFound(err) { 71 | log.Info("[-] dopplersecret not found, nothing to do") 72 | return ctrl.Result{}, nil 73 | } 74 | log.Error(err, "Unable to fetch dopplersecret") 75 | return ctrl.Result{ 76 | RequeueAfter: defaultRequeueDuration, 77 | }, nil 78 | } 79 | 80 | authNamespace := dopplerSecret.Spec.TokenSecretRef.Namespace 81 | authName := dopplerSecret.Spec.TokenSecretRef.Name 82 | 83 | // If omitted, the default namespace for references is the DopplerSecret's namespace 84 | if authNamespace == "" { 85 | authNamespace = dopplerSecret.Namespace 86 | } 87 | managedSecretNamespace := dopplerSecret.Spec.ManagedSecretRef.Namespace 88 | if managedSecretNamespace == "" { 89 | managedSecretNamespace = dopplerSecret.Namespace 90 | } 91 | 92 | // Check namespace restrictions 93 | if ownNamespace == dopplerSecret.Namespace { 94 | log.Info("Reconciling dopplersecret in operator namespace, references can be in any namespace") 95 | } else if dopplerSecret.Namespace == authNamespace && dopplerSecret.Namespace == managedSecretNamespace { 96 | log.Info("Reconciling dopplersecret, all references are in the same namespace") 97 | } else { 98 | err := fmt.Errorf("cannot reconcile dopplersecret (%s/%s) in namespace different from operator (%s) "+ 99 | "unless all references [token: (%s/%s), managed: (%s/%s)] are in the dopplersecret's namespace", 100 | dopplerSecret.Namespace, dopplerSecret.Name, ownNamespace, 101 | authNamespace, authName, managedSecretNamespace, dopplerSecret.Spec.ManagedSecretRef.Name) 102 | log.Error(err, "") 103 | return ctrl.Result{}, nil 104 | } 105 | 106 | log.Info("Reconciling dopplersecret") 107 | 108 | requeueAfter := defaultRequeueDuration 109 | if dopplerSecret.Spec.ResyncSeconds != 0 { 110 | requeueAfter = time.Second * time.Duration(dopplerSecret.Spec.ResyncSeconds) 111 | } 112 | log.Info("Requeue duration set", "requeueAfter", requeueAfter) 113 | 114 | if dopplerSecret.GetDeletionTimestamp() != nil { 115 | log.Info("dopplersecret has been deleted, nothing to do") 116 | return ctrl.Result{}, nil 117 | } 118 | 119 | err = r.UpdateSecret(ctx, dopplerSecret) 120 | r.SetSecretsSyncReadyCondition(ctx, &dopplerSecret, err) 121 | if err != nil { 122 | log.Error(err, "Unable to update dopplersecret") 123 | return ctrl.Result{ 124 | RequeueAfter: requeueAfter, 125 | }, nil 126 | } 127 | 128 | numDeployments, err := r.ReconcileDeploymentsUsingSecret(ctx, dopplerSecret) 129 | r.SetDeploymentReloadReadyCondition(ctx, &dopplerSecret, numDeployments, err) 130 | if err != nil { 131 | log.Error(err, "Failed to update deployments") 132 | return ctrl.Result{ 133 | RequeueAfter: requeueAfter, 134 | }, nil 135 | } 136 | 137 | log.Info("Finished reconciliation") 138 | return ctrl.Result{ 139 | RequeueAfter: requeueAfter, 140 | }, nil 141 | } 142 | 143 | // SetupWithManager sets up the controller with the Manager. 144 | func (r *DopplerSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { 145 | return ctrl.NewControllerManagedBy(mgr). 146 | For(&secretsv1alpha1.DopplerSecret{}). 147 | Complete(r) 148 | } 149 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/DopplerHQ/kubernetes-operator/pkg/models" 14 | 15 | "github.com/DopplerHQ/kubernetes-operator/pkg/version" 16 | ) 17 | 18 | const secretsDownloadFileKey = "DOPPLER_SECRETS_FILE" 19 | 20 | type APIContext struct { 21 | Host string 22 | APIKey string 23 | VerifyTLS bool 24 | } 25 | 26 | type APIResponse struct { 27 | HTTPResponse *http.Response 28 | Body []byte 29 | } 30 | 31 | type APIError struct { 32 | Err error 33 | Message string 34 | } 35 | 36 | type ErrorResponse struct { 37 | Messages []string 38 | Success bool 39 | } 40 | 41 | type QueryParam struct { 42 | Key string 43 | Value string 44 | } 45 | 46 | func (e *APIError) Error() string { 47 | message := fmt.Sprintf("Doppler Error: %s", e.Message) 48 | if underlyingError := e.Err; underlyingError != nil { 49 | message = fmt.Sprintf("%s\n%s", message, underlyingError.Error()) 50 | } 51 | return message 52 | } 53 | 54 | func isSuccess(statusCode int) bool { 55 | return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399) 56 | } 57 | 58 | func GetRequest(context APIContext, path string, headers map[string]string, params []QueryParam) (*APIResponse, *APIError) { 59 | url := fmt.Sprintf("%s%s", context.Host, path) 60 | req, err := http.NewRequest("GET", url, nil) 61 | for k, v := range headers { 62 | req.Header.Set(k, v) 63 | } 64 | query := req.URL.Query() 65 | for _, param := range params { 66 | query.Add(param.Key, param.Value) 67 | } 68 | req.URL.RawQuery = query.Encode() 69 | if err != nil { 70 | return nil, &APIError{Err: err, Message: "Unable to form request"} 71 | } 72 | 73 | return PerformRequest(context, req) 74 | } 75 | 76 | func PerformRequest(context APIContext, req *http.Request) (*APIResponse, *APIError) { 77 | client := &http.Client{Timeout: 10 * time.Second} 78 | 79 | userAgent := fmt.Sprintf("kubernetes-operator/%s", version.ControllerVersion) 80 | req.Header.Set("user-agent", userAgent) 81 | req.SetBasicAuth(context.APIKey, "") 82 | if req.Header.Get("accept") == "" { 83 | req.Header.Set("accept", "application/json") 84 | } 85 | 86 | tlsConfig := &tls.Config{ 87 | MinVersion: tls.VersionTLS12, 88 | } 89 | 90 | if !context.VerifyTLS { 91 | tlsConfig.InsecureSkipVerify = true 92 | } 93 | 94 | client.Transport = &http.Transport{ 95 | DisableKeepAlives: true, 96 | TLSClientConfig: tlsConfig, 97 | } 98 | 99 | r, err := client.Do(req) 100 | if err != nil { 101 | return nil, &APIError{Err: err, Message: "Unable to load response"} 102 | } 103 | defer r.Body.Close() 104 | 105 | body, err := ioutil.ReadAll(r.Body) 106 | if err != nil { 107 | return &APIResponse{HTTPResponse: r, Body: nil}, &APIError{Err: err, Message: "Unable to load response data"} 108 | } 109 | response := &APIResponse{HTTPResponse: r, Body: body} 110 | 111 | if !isSuccess(r.StatusCode) { 112 | if contentType := r.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") { 113 | var errResponse ErrorResponse 114 | err := json.Unmarshal(body, &errResponse) 115 | if err != nil { 116 | return response, &APIError{Err: err, Message: "Unable to load response"} 117 | } 118 | return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")} 119 | } 120 | return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", r.StatusCode, len(body)), Message: "Unable to load response"} 121 | } 122 | if err != nil { 123 | return nil, &APIError{Err: err, Message: "Unable to parse response data"} 124 | } 125 | return response, nil 126 | } 127 | 128 | func GetSecrets(context APIContext, lastETag string, project string, config string, nameTransformer string, format string, secrets []string) (*models.SecretsResult, *APIError) { 129 | headers := map[string]string{} 130 | if lastETag != "" { 131 | headers["If-None-Match"] = lastETag 132 | } 133 | 134 | params := []QueryParam{} 135 | if project != "" { 136 | params = append(params, QueryParam{Key: "project", Value: project}) 137 | } 138 | if config != "" { 139 | params = append(params, QueryParam{Key: "config", Value: config}) 140 | } 141 | if len(secrets) > 0 { 142 | params = append(params, QueryParam{Key: "secrets", Value: strings.Join(secrets, ",")}) 143 | } 144 | if nameTransformer != "" { 145 | params = append(params, QueryParam{Key: "name_transformer", Value: nameTransformer}) 146 | } 147 | if format != "" { 148 | params = append(params, QueryParam{Key: "format", Value: format}) 149 | } 150 | 151 | response, err := GetRequest(context, "/v3/configs/config/secrets/download", headers, params) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | if response.HTTPResponse.StatusCode == 304 { 157 | return &models.SecretsResult{Modified: false, Secrets: nil, ETag: ""}, nil 158 | } 159 | eTag := response.HTTPResponse.Header.Get("ETag") 160 | 161 | // Format defeats JSON parsing 162 | if format != "" { 163 | secrets := []models.Secret{{ 164 | Name: secretsDownloadFileKey, 165 | Value: string(response.Body), 166 | }} 167 | return &models.SecretsResult{Modified: true, Secrets: secrets, ETag: eTag}, nil 168 | } 169 | 170 | result, modelErr := parseSecrets(response.Body, eTag) 171 | if modelErr != nil { 172 | return nil, &APIError{Err: modelErr, Message: "Unable to parse secrets"} 173 | } 174 | return result, nil 175 | } 176 | 177 | func parseSecrets(response []byte, eTag string) (*models.SecretsResult, error) { 178 | var result map[string]string 179 | err := json.Unmarshal(response, &result) 180 | if err != nil { 181 | return nil, err 182 | } 183 | 184 | secrets := make([]models.Secret, 0) 185 | for key, value := range result { 186 | secret := models.Secret{Name: key, Value: value} 187 | secrets = append(secrets, secret) 188 | } 189 | sort.Slice(secrets, func(i, j int) bool { 190 | return secrets[i].Name < secrets[j].Name 191 | }) 192 | return &models.SecretsResult{Modified: true, Secrets: secrets, ETag: eTag}, nil 193 | } 194 | -------------------------------------------------------------------------------- /api/v1alpha1/dopplersecret_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1alpha1 18 | 19 | import ( 20 | "fmt" 21 | 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | ) 24 | 25 | // This file is meant to be modified as specs change. 26 | // Important: Run "make" to regenerate code after modifying this file 27 | 28 | // A reference to a token Kubernetes secret 29 | type TokenSecretReference struct { 30 | // The name of the Secret resource 31 | Name string `json:"name"` 32 | 33 | // Namespace of the resource being referred to. Ignored if not cluster scoped 34 | // +optional 35 | Namespace string `json:"namespace,omitempty"` 36 | } 37 | 38 | // A reference to a managed Kubernetes secret 39 | type ManagedSecretReference struct { 40 | // The name of the Secret resource 41 | Name string `json:"name"` 42 | 43 | // Namespace of the resource being referred to. Ignored if not cluster scoped 44 | // +optional 45 | Namespace string `json:"namespace,omitempty"` 46 | 47 | // The secret type of the managed secret 48 | // +kubebuilder:validation:Enum=Opaque;kubernetes.io/tls;kubernetes.io/service-account-token;kubernetes.io/dockercfg;kubernetes.io/dockerconfigjson;kubernetes.io/basic-auth;kubernetes.io/ssh-auth;bootstrap.kubernetes.io/token 49 | // +kubebuilder:default=Opaque 50 | // +optional 51 | Type string `json:"type,omitempty"` 52 | 53 | // Labels to add or update on the managed secret 54 | // +optional 55 | Labels map[string]string `json:"labels,omitempty"` 56 | 57 | // Annotations to add or update on the managed secret 58 | // +optional 59 | Annotations map[string]string `json:"annotations,omitempty"` 60 | } 61 | 62 | type SecretProcessor struct { 63 | // The type of process to be performed, either "plain" or "base64" 64 | // +kubebuilder:validation:Enum=plain;base64 65 | // +kubebuilder:default=plain 66 | // +optional 67 | Type string `json:"type"` 68 | 69 | // The mapped name of the field in the managed secret, defaults to the original Doppler secret name for Opaque Kubernetes secrets. If omitted for other types, the value is not copied to the managed secret. 70 | AsName string `json:"asName,omitempty"` 71 | } 72 | 73 | type SecretProcessors map[string]*SecretProcessor 74 | 75 | var DefaultProcessor = SecretProcessor{Type: "plain"} 76 | 77 | // DopplerSecretSpec defines the desired state of DopplerSecret 78 | // +kubebuilder:validation:XValidation:rule="(has(self.tokenSecret) && !has(self.identity)) || (!has(self.tokenSecret) && has(self.identity))",message="Must specify either tokenSecret or identity, but not both" 79 | type DopplerSecretSpec struct { 80 | // The Kubernetes secret containing either a Doppler service token or OIDC configuration. Mutually exclusive with 'identity'. 81 | // +optional 82 | TokenSecretRef TokenSecretReference `json:"tokenSecret,omitempty"` 83 | 84 | // The Kubernetes secret where the operator will store and sync the fetched secrets 85 | ManagedSecretRef ManagedSecretReference `json:"managedSecret,omitempty"` 86 | 87 | // The Doppler Service Account Identity (OIDC). Mutually exclusive with 'tokenSecret'. 88 | // +optional 89 | Identity string `json:"identity,omitempty"` 90 | 91 | // The JWT expiration time in seconds for OIDC authentication. This controls the lifetime of the Kubernetes ServiceAccount token requested via the TokenRequest API. Kubernetes enforces a minimum of 600 seconds (10 minutes). Defaults to 600 if not specified. 92 | // +optional 93 | ExpirationSeconds int64 `json:"expirationSeconds,omitempty"` 94 | 95 | // The Doppler project 96 | // +optional 97 | Project string `json:"project,omitempty"` 98 | 99 | // The Doppler config 100 | // +optional 101 | Config string `json:"config,omitempty"` 102 | 103 | // A list of secrets to sync from the config 104 | // +optional 105 | Secrets []string `json:"secrets,omitempty"` 106 | 107 | // A list of processors to transform the data during ingestion 108 | // +kubebuilder:default={} 109 | Processors SecretProcessors `json:"processors,omitempty"` 110 | 111 | // The Doppler API host 112 | // +kubebuilder:default="https://api.doppler.com" 113 | Host string `json:"host,omitempty"` 114 | 115 | // Whether or not to verify TLS 116 | // +kubebuilder:default=true 117 | VerifyTLS bool `json:"verifyTLS,omitempty"` 118 | 119 | // The environment variable compatible secrets name transformer to apply 120 | // +kubebuilder:validation:Enum=upper-camel;camel;lower-snake;tf-var;dotnet-env;lower-kebab 121 | // +optional 122 | NameTransformer string `json:"nameTransformer,omitempty"` 123 | 124 | // Format enables the downloading of secrets as a file 125 | // +kubebuilder:validation:Enum=json;dotnet-json;env;yaml;docker 126 | // +optional 127 | Format string `json:"format,omitempty"` 128 | 129 | // The number of seconds to wait between resyncs 130 | // +kubebuilder:default=60 131 | ResyncSeconds int64 `json:"resyncSeconds,omitempty"` 132 | } 133 | 134 | // DopplerSecretStatus defines the observed state of DopplerSecret 135 | type DopplerSecretStatus struct { 136 | Conditions []metav1.Condition `json:"conditions"` 137 | } 138 | 139 | //+kubebuilder:object:root=true 140 | //+kubebuilder:subresource:status 141 | 142 | // DopplerSecret is the Schema for the dopplersecrets API 143 | type DopplerSecret struct { 144 | metav1.TypeMeta `json:",inline"` 145 | metav1.ObjectMeta `json:"metadata,omitempty"` 146 | 147 | Spec DopplerSecretSpec `json:"spec,omitempty"` 148 | Status DopplerSecretStatus `json:"status,omitempty"` 149 | } 150 | 151 | //+kubebuilder:object:root=true 152 | 153 | // DopplerSecretList contains a list of DopplerSecret 154 | type DopplerSecretList struct { 155 | metav1.TypeMeta `json:",inline"` 156 | metav1.ListMeta `json:"metadata,omitempty"` 157 | Items []DopplerSecret `json:"items"` 158 | } 159 | 160 | func (d DopplerSecret) GetNamespacedName() string { 161 | return fmt.Sprintf("%s/%s", d.Namespace, d.Name) 162 | } 163 | 164 | func init() { 165 | SchemeBuilder.Register(&DopplerSecret{}, &DopplerSecretList{}) 166 | } 167 | -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2021. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1alpha1 22 | 23 | import ( 24 | "k8s.io/apimachinery/pkg/apis/meta/v1" 25 | runtime "k8s.io/apimachinery/pkg/runtime" 26 | ) 27 | 28 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 29 | func (in *DopplerSecret) DeepCopyInto(out *DopplerSecret) { 30 | *out = *in 31 | out.TypeMeta = in.TypeMeta 32 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 33 | in.Spec.DeepCopyInto(&out.Spec) 34 | in.Status.DeepCopyInto(&out.Status) 35 | } 36 | 37 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DopplerSecret. 38 | func (in *DopplerSecret) DeepCopy() *DopplerSecret { 39 | if in == nil { 40 | return nil 41 | } 42 | out := new(DopplerSecret) 43 | in.DeepCopyInto(out) 44 | return out 45 | } 46 | 47 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 48 | func (in *DopplerSecret) DeepCopyObject() runtime.Object { 49 | if c := in.DeepCopy(); c != nil { 50 | return c 51 | } 52 | return nil 53 | } 54 | 55 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 56 | func (in *DopplerSecretList) DeepCopyInto(out *DopplerSecretList) { 57 | *out = *in 58 | out.TypeMeta = in.TypeMeta 59 | in.ListMeta.DeepCopyInto(&out.ListMeta) 60 | if in.Items != nil { 61 | in, out := &in.Items, &out.Items 62 | *out = make([]DopplerSecret, len(*in)) 63 | for i := range *in { 64 | (*in)[i].DeepCopyInto(&(*out)[i]) 65 | } 66 | } 67 | } 68 | 69 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DopplerSecretList. 70 | func (in *DopplerSecretList) DeepCopy() *DopplerSecretList { 71 | if in == nil { 72 | return nil 73 | } 74 | out := new(DopplerSecretList) 75 | in.DeepCopyInto(out) 76 | return out 77 | } 78 | 79 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 80 | func (in *DopplerSecretList) DeepCopyObject() runtime.Object { 81 | if c := in.DeepCopy(); c != nil { 82 | return c 83 | } 84 | return nil 85 | } 86 | 87 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 88 | func (in *DopplerSecretSpec) DeepCopyInto(out *DopplerSecretSpec) { 89 | *out = *in 90 | out.TokenSecretRef = in.TokenSecretRef 91 | in.ManagedSecretRef.DeepCopyInto(&out.ManagedSecretRef) 92 | if in.Secrets != nil { 93 | in, out := &in.Secrets, &out.Secrets 94 | *out = make([]string, len(*in)) 95 | copy(*out, *in) 96 | } 97 | if in.Processors != nil { 98 | in, out := &in.Processors, &out.Processors 99 | *out = make(SecretProcessors, len(*in)) 100 | for key, val := range *in { 101 | var outVal *SecretProcessor 102 | if val == nil { 103 | (*out)[key] = nil 104 | } else { 105 | inVal := (*in)[key] 106 | in, out := &inVal, &outVal 107 | *out = new(SecretProcessor) 108 | **out = **in 109 | } 110 | (*out)[key] = outVal 111 | } 112 | } 113 | } 114 | 115 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DopplerSecretSpec. 116 | func (in *DopplerSecretSpec) DeepCopy() *DopplerSecretSpec { 117 | if in == nil { 118 | return nil 119 | } 120 | out := new(DopplerSecretSpec) 121 | in.DeepCopyInto(out) 122 | return out 123 | } 124 | 125 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 126 | func (in *DopplerSecretStatus) DeepCopyInto(out *DopplerSecretStatus) { 127 | *out = *in 128 | if in.Conditions != nil { 129 | in, out := &in.Conditions, &out.Conditions 130 | *out = make([]v1.Condition, len(*in)) 131 | for i := range *in { 132 | (*in)[i].DeepCopyInto(&(*out)[i]) 133 | } 134 | } 135 | } 136 | 137 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DopplerSecretStatus. 138 | func (in *DopplerSecretStatus) DeepCopy() *DopplerSecretStatus { 139 | if in == nil { 140 | return nil 141 | } 142 | out := new(DopplerSecretStatus) 143 | in.DeepCopyInto(out) 144 | return out 145 | } 146 | 147 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 148 | func (in *ManagedSecretReference) DeepCopyInto(out *ManagedSecretReference) { 149 | *out = *in 150 | if in.Labels != nil { 151 | in, out := &in.Labels, &out.Labels 152 | *out = make(map[string]string, len(*in)) 153 | for key, val := range *in { 154 | (*out)[key] = val 155 | } 156 | } 157 | if in.Annotations != nil { 158 | in, out := &in.Annotations, &out.Annotations 159 | *out = make(map[string]string, len(*in)) 160 | for key, val := range *in { 161 | (*out)[key] = val 162 | } 163 | } 164 | } 165 | 166 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedSecretReference. 167 | func (in *ManagedSecretReference) DeepCopy() *ManagedSecretReference { 168 | if in == nil { 169 | return nil 170 | } 171 | out := new(ManagedSecretReference) 172 | in.DeepCopyInto(out) 173 | return out 174 | } 175 | 176 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 177 | func (in *SecretProcessor) DeepCopyInto(out *SecretProcessor) { 178 | *out = *in 179 | } 180 | 181 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretProcessor. 182 | func (in *SecretProcessor) DeepCopy() *SecretProcessor { 183 | if in == nil { 184 | return nil 185 | } 186 | out := new(SecretProcessor) 187 | in.DeepCopyInto(out) 188 | return out 189 | } 190 | 191 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 192 | func (in SecretProcessors) DeepCopyInto(out *SecretProcessors) { 193 | { 194 | in := &in 195 | *out = make(SecretProcessors, len(*in)) 196 | for key, val := range *in { 197 | var outVal *SecretProcessor 198 | if val == nil { 199 | (*out)[key] = nil 200 | } else { 201 | inVal := (*in)[key] 202 | in, out := &inVal, &outVal 203 | *out = new(SecretProcessor) 204 | **out = **in 205 | } 206 | (*out)[key] = outVal 207 | } 208 | } 209 | } 210 | 211 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretProcessors. 212 | func (in SecretProcessors) DeepCopy() SecretProcessors { 213 | if in == nil { 214 | return nil 215 | } 216 | out := new(SecretProcessors) 217 | in.DeepCopyInto(out) 218 | return *out 219 | } 220 | 221 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 222 | func (in *TokenSecretReference) DeepCopyInto(out *TokenSecretReference) { 223 | *out = *in 224 | } 225 | 226 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TokenSecretReference. 227 | func (in *TokenSecretReference) DeepCopy() *TokenSecretReference { 228 | if in == nil { 229 | return nil 230 | } 231 | out := new(TokenSecretReference) 232 | in.DeepCopyInto(out) 233 | return out 234 | } 235 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller_auth.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/go-logr/logr" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/types" 12 | "k8s.io/client-go/kubernetes" 13 | ctrl "sigs.k8s.io/controller-runtime" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 17 | "github.com/DopplerHQ/kubernetes-operator/pkg/api" 18 | "github.com/DopplerHQ/kubernetes-operator/pkg/auth" 19 | "github.com/DopplerHQ/kubernetes-operator/pkg/cache" 20 | ) 21 | 22 | var ( 23 | oidcProviderCache *cache.Cache[*auth.OIDCAuthProvider] 24 | ) 25 | 26 | func InitializeOIDCCache(log logr.Logger, cacheSize int) { 27 | oidcProviderCache = cache.New(cacheSize, func(provider *auth.OIDCAuthProvider) { 28 | log.Info("Evicting OIDC provider from cache", 29 | "namespace", provider.Namespace, 30 | "identity", provider.Identity) 31 | }) 32 | } 33 | 34 | // Interface for different authentication methods 35 | type AuthProvider interface { 36 | GetAPIContext(ctx context.Context) (*api.APIContext, error) 37 | } 38 | 39 | // Handle service token authentication 40 | type ServiceTokenAuthProvider struct { 41 | client client.Client 42 | tokenRef secretsv1alpha1.TokenSecretReference 43 | namespace string 44 | host string 45 | verifyTLS bool 46 | } 47 | 48 | func (s *ServiceTokenAuthProvider) GetAPIContext(ctx context.Context) (*api.APIContext, error) { 49 | tokenSecret := corev1.Secret{} 50 | tokenNamespace := s.namespace 51 | if s.tokenRef.Namespace != "" { 52 | tokenNamespace = s.tokenRef.Namespace 53 | } 54 | 55 | err := s.client.Get(ctx, types.NamespacedName{ 56 | Name: s.tokenRef.Name, 57 | Namespace: tokenNamespace, 58 | }, &tokenSecret) 59 | if err != nil { 60 | return nil, fmt.Errorf("Unable to fetch token secret: %w", err) 61 | } 62 | 63 | serviceToken, ok := tokenSecret.Data["serviceToken"] 64 | if !ok { 65 | return nil, fmt.Errorf("Token secret does not contain 'serviceToken' field") 66 | } 67 | 68 | return &api.APIContext{ 69 | Host: s.host, 70 | APIKey: string(serviceToken), 71 | VerifyTLS: s.verifyTLS, 72 | }, nil 73 | } 74 | 75 | // Handle OIDC authentication 76 | type OIDCAuthProvider struct { 77 | oidcProvider *auth.OIDCAuthProvider 78 | cacheKey cache.Key 79 | } 80 | 81 | func (o *OIDCAuthProvider) GetAPIContext(ctx context.Context) (*api.APIContext, error) { 82 | token, err := o.oidcProvider.GetToken(ctx) 83 | if err != nil { 84 | // On error, remove from cache to force retry 85 | oidcProviderCache.Remove(o.cacheKey) 86 | return nil, fmt.Errorf("Unable to get OIDC token: %w", err) 87 | } 88 | 89 | return &api.APIContext{ 90 | Host: o.oidcProvider.Host, 91 | APIKey: token, 92 | VerifyTLS: o.oidcProvider.VerifyTLS, 93 | }, nil 94 | } 95 | 96 | // Determine which authentication provider to use 97 | func (r *DopplerSecretReconciler) getAuthProvider(ctx context.Context, dopplerSecret *secretsv1alpha1.DopplerSecret) (AuthProvider, error) { 98 | // Use OIDC authentication with identity from spec 99 | if dopplerSecret.Spec.Identity != "" { 100 | return r.createOIDCAuthProvider(dopplerSecret, dopplerSecret.Spec.Identity, nil) 101 | } 102 | 103 | // Check what the token secret contains to determine auth type 104 | tokenSecret := corev1.Secret{} 105 | tokenNamespace := dopplerSecret.Namespace 106 | if dopplerSecret.Spec.TokenSecretRef.Namespace != "" { 107 | tokenNamespace = dopplerSecret.Spec.TokenSecretRef.Namespace 108 | } 109 | 110 | err := r.Client.Get(ctx, types.NamespacedName{ 111 | Name: dopplerSecret.Spec.TokenSecretRef.Name, 112 | Namespace: tokenNamespace, 113 | }, &tokenSecret) 114 | if err != nil { 115 | return nil, fmt.Errorf("Unable to fetch token secret: %w", err) 116 | } 117 | 118 | // Check what authentication fields exist 119 | _, hasServiceToken := tokenSecret.Data["serviceToken"] 120 | tokenSecretIdentity, hasTokenSecretIdentity := tokenSecret.Data["identity"] 121 | 122 | // Ensure mutual exclusivity between auth methods 123 | if hasServiceToken && hasTokenSecretIdentity { 124 | return nil, fmt.Errorf("Token secret cannot contain both 'serviceToken' and 'identity' fields - use one or the other") 125 | } 126 | 127 | // Use OIDC authentication 128 | if hasTokenSecretIdentity { 129 | return r.createOIDCAuthProvider(dopplerSecret, string(tokenSecretIdentity), &tokenSecret) 130 | } 131 | 132 | // Use service token authentication 133 | if hasServiceToken { 134 | return &ServiceTokenAuthProvider{ 135 | client: r.Client, 136 | tokenRef: dopplerSecret.Spec.TokenSecretRef, 137 | namespace: dopplerSecret.Namespace, 138 | host: dopplerSecret.Spec.Host, 139 | verifyTLS: dopplerSecret.Spec.VerifyTLS, 140 | }, nil 141 | } 142 | 143 | return nil, fmt.Errorf("Token secret must contain either 'serviceToken' or 'identity' field") 144 | } 145 | 146 | // Create an OIDC authentication provider 147 | func (r *DopplerSecretReconciler) createOIDCAuthProvider(dopplerSecret *secretsv1alpha1.DopplerSecret, identity string, tokenSecret *corev1.Secret) (AuthProvider, error) { 148 | operatorNamespace, err := GetOwnNamespace() 149 | if err != nil { 150 | return nil, fmt.Errorf("Unable to get operator namespace: %w", err) 151 | } 152 | 153 | audiences := []string{ 154 | dopplerSecret.Spec.Host, 155 | } 156 | 157 | // Add audience for validation that the token was issued for this specific resource 158 | if tokenSecret != nil { 159 | // The dopplerTokenSecret audience allows us to validate that the token was issued 160 | // for this specific token secret 161 | audiences = append(audiences, fmt.Sprintf("dopplerTokenSecret:%s:%s", 162 | tokenSecret.Namespace, 163 | tokenSecret.Name)) 164 | } else { 165 | // Identity is provided in the DopplerSecret, so we include a dopplerSecret audience 166 | audiences = append(audiences, fmt.Sprintf("dopplerSecret:%s:%s", 167 | dopplerSecret.Namespace, 168 | dopplerSecret.Name)) 169 | } 170 | 171 | // If the identity is a UUID, we add it as an additional audience to allow for a 172 | // cryptographic binding of the JWT to its intended identity 173 | uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) 174 | if uuidRegex.MatchString(strings.ToLower(identity)) { 175 | audiences = append(audiences, identity) 176 | } 177 | 178 | // Get expiration seconds from spec or secret, default to 600 179 | expirationSeconds := int64(600) 180 | 181 | // Ensure mutual exclusivity for expirationSeconds 182 | if dopplerSecret.Spec.ExpirationSeconds > 0 && tokenSecret != nil { 183 | if _, ok := tokenSecret.Data["expirationSeconds"]; ok { 184 | return nil, fmt.Errorf("expirationSeconds specified in both DopplerSecret spec and tokenSecret - use one or the other") 185 | } 186 | } 187 | 188 | if dopplerSecret.Spec.ExpirationSeconds > 0 { 189 | expirationSeconds = dopplerSecret.Spec.ExpirationSeconds 190 | } else if tokenSecret != nil { 191 | if expSecondsData, ok := tokenSecret.Data["expirationSeconds"]; ok { 192 | if _, err := fmt.Sscanf(string(expSecondsData), "%d", &expirationSeconds); err != nil { 193 | r.Log.Info("Invalid expirationSeconds in token secret, using default", 194 | "value", string(expSecondsData), 195 | "default", expirationSeconds) 196 | } 197 | } 198 | } 199 | 200 | cacheKey := cache.Key{ 201 | Identity: identity, 202 | Audiences: strings.Join(audiences, ","), 203 | } 204 | 205 | var oidcProvider *auth.OIDCAuthProvider 206 | 207 | if cachedProvider, found := oidcProviderCache.Get(cacheKey); found { 208 | oidcProvider = cachedProvider 209 | r.Log.Info("Using cached OIDC provider", 210 | "namespace", dopplerSecret.Namespace, 211 | "name", dopplerSecret.Name, 212 | "cacheKey", cacheKey) 213 | } 214 | 215 | if oidcProvider == nil { 216 | r.Log.Info("Creating new OIDC provider", 217 | "namespace", dopplerSecret.Namespace, 218 | "name", dopplerSecret.Name, 219 | "identity", identity) 220 | 221 | config, err := ctrl.GetConfig() 222 | if err != nil { 223 | return nil, fmt.Errorf("Unable to get kubernetes config: %w", err) 224 | } 225 | 226 | clientset, err := kubernetes.NewForConfig(config) 227 | if err != nil { 228 | return nil, fmt.Errorf("Unable to create kubernetes clientset: %w", err) 229 | } 230 | 231 | oidcProvider = &auth.OIDCAuthProvider{ 232 | KubeClient: clientset, 233 | Namespace: operatorNamespace, 234 | Audiences: audiences, 235 | Host: dopplerSecret.Spec.Host, 236 | Identity: identity, 237 | VerifyTLS: dopplerSecret.Spec.VerifyTLS, // Defaults to true via kubebuilder annotation in CRD 238 | ExpirationSeconds: expirationSeconds, 239 | } 240 | 241 | // Add to cache 242 | oidcProviderCache.Add(cacheKey, oidcProvider) 243 | r.Log.Info("Added OIDC provider to cache", 244 | "namespace", dopplerSecret.Namespace, 245 | "name", dopplerSecret.Name, 246 | "cacheKey", cacheKey) 247 | } 248 | 249 | return &OIDCAuthProvider{ 250 | oidcProvider: oidcProvider, 251 | cacheKey: cacheKey, 252 | }, nil 253 | } 254 | -------------------------------------------------------------------------------- /config/crd/bases/secrets.doppler.com_dopplersecrets.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: dopplersecrets.secrets.doppler.com 8 | spec: 9 | group: secrets.doppler.com 10 | names: 11 | kind: DopplerSecret 12 | listKind: DopplerSecretList 13 | plural: dopplersecrets 14 | singular: dopplersecret 15 | scope: Namespaced 16 | versions: 17 | - name: v1alpha1 18 | schema: 19 | openAPIV3Schema: 20 | description: DopplerSecret is the Schema for the dopplersecrets 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: DopplerSecretSpec defines the desired state of DopplerSecret 41 | properties: 42 | config: 43 | description: The Doppler config 44 | type: string 45 | expirationSeconds: 46 | description: The JWT expiration time in seconds for OIDC authentication. 47 | This controls the lifetime of the Kubernetes ServiceAccount token 48 | requested via the TokenRequest API. Kubernetes enforces a minimum 49 | of 600 seconds (10 minutes). Defaults to 600 if not specified. 50 | format: int64 51 | type: integer 52 | format: 53 | description: Format enables the downloading of secrets as a file 54 | enum: 55 | - json 56 | - dotnet-json 57 | - env 58 | - yaml 59 | - docker 60 | type: string 61 | host: 62 | default: https://api.doppler.com 63 | description: The Doppler API host 64 | type: string 65 | identity: 66 | description: The Doppler Service Account Identity (OIDC). Mutually 67 | exclusive with 'tokenSecret'. 68 | type: string 69 | managedSecret: 70 | description: The Kubernetes secret where the operator will store and 71 | sync the fetched secrets 72 | properties: 73 | annotations: 74 | additionalProperties: 75 | type: string 76 | description: Annotations to add or update on the managed secret 77 | type: object 78 | labels: 79 | additionalProperties: 80 | type: string 81 | description: Labels to add or update on the managed secret 82 | type: object 83 | name: 84 | description: The name of the Secret resource 85 | type: string 86 | namespace: 87 | description: Namespace of the resource being referred to. Ignored 88 | if not cluster scoped 89 | type: string 90 | type: 91 | default: Opaque 92 | description: The secret type of the managed secret 93 | enum: 94 | - Opaque 95 | - kubernetes.io/tls 96 | - kubernetes.io/service-account-token 97 | - kubernetes.io/dockercfg 98 | - kubernetes.io/dockerconfigjson 99 | - kubernetes.io/basic-auth 100 | - kubernetes.io/ssh-auth 101 | - bootstrap.kubernetes.io/token 102 | type: string 103 | required: 104 | - name 105 | type: object 106 | nameTransformer: 107 | description: The environment variable compatible secrets name transformer 108 | to apply 109 | enum: 110 | - upper-camel 111 | - camel 112 | - lower-snake 113 | - tf-var 114 | - dotnet-env 115 | - lower-kebab 116 | type: string 117 | processors: 118 | additionalProperties: 119 | properties: 120 | asName: 121 | description: The mapped name of the field in the managed secret, 122 | defaults to the original Doppler secret name for Opaque Kubernetes 123 | secrets. If omitted for other types, the value is not copied 124 | to the managed secret. 125 | type: string 126 | type: 127 | default: plain 128 | description: The type of process to be performed, either "plain" 129 | or "base64" 130 | enum: 131 | - plain 132 | - base64 133 | type: string 134 | type: object 135 | default: {} 136 | description: A list of processors to transform the data during ingestion 137 | type: object 138 | project: 139 | description: The Doppler project 140 | type: string 141 | resyncSeconds: 142 | default: 60 143 | description: The number of seconds to wait between resyncs 144 | format: int64 145 | type: integer 146 | secrets: 147 | description: A list of secrets to sync from the config 148 | items: 149 | type: string 150 | type: array 151 | tokenSecret: 152 | description: The Kubernetes secret containing either a Doppler service 153 | token or OIDC configuration. Mutually exclusive with 'identity'. 154 | properties: 155 | name: 156 | description: The name of the Secret resource 157 | type: string 158 | namespace: 159 | description: Namespace of the resource being referred to. Ignored 160 | if not cluster scoped 161 | type: string 162 | required: 163 | - name 164 | type: object 165 | verifyTLS: 166 | default: true 167 | description: Whether or not to verify TLS 168 | type: boolean 169 | type: object 170 | x-kubernetes-validations: 171 | - message: Must specify either tokenSecret or identity, but not both 172 | rule: (has(self.tokenSecret) && !has(self.identity)) || (!has(self.tokenSecret) 173 | && has(self.identity)) 174 | status: 175 | description: DopplerSecretStatus defines the observed state of DopplerSecret 176 | properties: 177 | conditions: 178 | items: 179 | description: Condition contains details for one aspect of the current 180 | state of this API Resource. 181 | properties: 182 | lastTransitionTime: 183 | description: |- 184 | lastTransitionTime is the last time the condition transitioned from one status to another. 185 | This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 186 | format: date-time 187 | type: string 188 | message: 189 | description: |- 190 | message is a human readable message indicating details about the transition. 191 | This may be an empty string. 192 | maxLength: 32768 193 | type: string 194 | observedGeneration: 195 | description: |- 196 | observedGeneration represents the .metadata.generation that the condition was set based upon. 197 | For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date 198 | with respect to the current state of the instance. 199 | format: int64 200 | minimum: 0 201 | type: integer 202 | reason: 203 | description: |- 204 | reason contains a programmatic identifier indicating the reason for the condition's last transition. 205 | Producers of specific condition types may define expected values and meanings for this field, 206 | and whether the values are considered a guaranteed API. 207 | The value should be a CamelCase string. 208 | This field may not be empty. 209 | maxLength: 1024 210 | minLength: 1 211 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 212 | type: string 213 | status: 214 | description: status of the condition, one of True, False, Unknown. 215 | enum: 216 | - "True" 217 | - "False" 218 | - Unknown 219 | type: string 220 | type: 221 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 222 | maxLength: 316 223 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 224 | type: string 225 | required: 226 | - lastTransitionTime 227 | - message 228 | - reason 229 | - status 230 | - type 231 | type: object 232 | type: array 233 | required: 234 | - conditions 235 | type: object 236 | type: object 237 | served: true 238 | storage: true 239 | subresources: 240 | status: {} 241 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # VERSION defines the project version for the bundle. 2 | # To re-generate a bundle for another specific version without changing the standard setup, you can: 3 | # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) 4 | # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) 5 | # NOTE: This value is not used by Doppler for releases. Release versions are pulled from tag names. 6 | VERSION ?= dev 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 = "preview,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=preview,fast,stable) 12 | # - use environment variables to overwrite this value (e.g export CHANNELS="preview,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 | # doppler.com/kubernetes-operator-bundle:$VERSION and doppler.com/kubernetes-operator-catalog:$VERSION. 32 | IMAGE_TAG_BASE ?= doppler.com/kubernetes-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 | # Image URL to use all building/pushing image targets 39 | IMG ?= dopplerhq/kubernetes-operator:$(VERSION) 40 | # Add CRD options, if any 41 | CRD_OPTIONS ?= "crd" 42 | 43 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 44 | ifeq (,$(shell go env GOBIN)) 45 | GOBIN=$(shell go env GOPATH)/bin 46 | else 47 | GOBIN=$(shell go env GOBIN) 48 | endif 49 | 50 | # Setting SHELL to bash allows bash commands to be executed by recipes. 51 | # This is a requirement for 'setup-envtest.sh' in the test target. 52 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 53 | SHELL = /usr/bin/env bash -o pipefail 54 | .SHELLFLAGS = -ec 55 | 56 | # Inject the VERSION into the version package 57 | GO_BUILD_VERSION_FLAGS = -ldflags="-X 'github.com/DopplerHQ/kubernetes-operator/pkg/version.ControllerVersion=${VERSION}'" 58 | 59 | all: build 60 | 61 | ##@ General 62 | 63 | # The help target prints out all targets with their descriptions organized 64 | # beneath their categories. The categories are represented by '##@' and the 65 | # target descriptions by '##'. The awk commands is responsible for reading the 66 | # entire set of makefiles included in this invocation, looking for lines of the 67 | # file as xyz: ## something, and then pretty-format the target and help. Then, 68 | # if there's a line with ##@ something, that gets pretty-printed as a category. 69 | # More info on the usage of ANSI control characters for terminal formatting: 70 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 71 | # More info on the awk command: 72 | # http://linuxcommand.org/lc3_adv_awk.php 73 | 74 | help: ## Display this help. 75 | @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) 76 | 77 | ##@ Development 78 | 79 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 80 | $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases 81 | 82 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 83 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 84 | 85 | fmt: ## Run go fmt against code. 86 | go fmt ./... 87 | 88 | vet: ## Run go vet against code. 89 | go vet ./... 90 | 91 | ENVTEST_ASSETS_DIR=$(shell pwd)/testbin 92 | test: manifests generate fmt vet ## Run tests. 93 | mkdir -p ${ENVTEST_ASSETS_DIR} 94 | test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/v0.8.3/hack/setup-envtest.sh 95 | source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... -coverprofile cover.out 96 | 97 | ##@ Build 98 | 99 | build: generate fmt vet ## Build manager binary. 100 | go build ${GO_BUILD_VERSION_FLAGS} -o bin/manager main.go 101 | 102 | run: manifests generate fmt vet ## Run a controller from your host. Does not use VERSION flags. 103 | POD_NAMESPACE=doppler-operator-system go run ./main.go 104 | 105 | docker-build: test ## Build docker image with the manager. 106 | docker build --build-arg CONTROLLER_VERSION=${VERSION} -t ${IMG} . 107 | 108 | docker-push: ## Push docker image with the manager. 109 | docker push ${IMG} 110 | 111 | ##@ Deployment 112 | 113 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 114 | $(KUSTOMIZE) build config/crd | kubectl apply -f - 115 | 116 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. 117 | $(KUSTOMIZE) build config/crd | kubectl delete -f - 118 | 119 | dist: manifests kustomize 120 | mkdir -p dist 121 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 122 | $(KUSTOMIZE) build config/default > dist/recommended.yaml 123 | 124 | CHART_DIR = charts/doppler-kubernetes-operator 125 | charts: manifests kustomize helm-tool yq dist 126 | mkdir -p $(CHART_DIR)/crds 127 | mkdir -p $(CHART_DIR)/templates 128 | $(YQ) e 'select(.kind == "CustomResourceDefinition")' dist/recommended.yaml > $(CHART_DIR)/crds/all.yaml 129 | $(YQ) e 'select(.kind != "CustomResourceDefinition")' dist/recommended.yaml > $(CHART_DIR)/templates/all.yaml 130 | cp hack/helm/Chart.yaml $(CHART_DIR)/ 131 | cp hack/helm/NOTES.txt $(CHART_DIR)/templates/ 132 | touch $(CHART_DIR)/values.yaml 133 | helm package $(CHART_DIR) --version $(VERSION) 134 | 135 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 136 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 137 | $(KUSTOMIZE) build config/default | kubectl apply -f - 138 | 139 | undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. 140 | $(KUSTOMIZE) build config/default | kubectl delete -f - 141 | 142 | 143 | CONTROLLER_GEN = $(shell pwd)/bin/controller-gen 144 | controller-gen: ## Download controller-gen locally if necessary. 145 | $(call go-get-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.1) 146 | 147 | KUSTOMIZE = $(shell pwd)/bin/kustomize 148 | kustomize: ## Download kustomize locally if necessary. 149 | $(call go-get-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5@v5.6.0) 150 | 151 | YQ = $(shell pwd)/bin/yq 152 | yq: ## Download yq locally if necessary. 153 | ifeq (,$(wildcard $(YQ))) 154 | ifeq (,$(shell which yq 2>/dev/null)) 155 | @{ \ 156 | set -e ;\ 157 | echo 'Downloading yq binary...' ;\ 158 | OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ 159 | curl --tlsv1.2 --proto "=https" -sSLo "$(YQ)" "https://github.com/mikefarah/yq/releases/download/v4.30.8/yq_$${OS}_$${ARCH}" ;\ 160 | chmod +x $(YQ) ;\ 161 | } 162 | else 163 | YQ = $(shell which yq) 164 | endif 165 | endif 166 | 167 | # go-get-tool will 'go get' any package $2 and install it to $1. 168 | PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) 169 | define go-get-tool 170 | @[ -f $(1) ] || { \ 171 | set -e ;\ 172 | TMP_DIR=$$(mktemp -d) ;\ 173 | cd $$TMP_DIR ;\ 174 | go mod init tmp ;\ 175 | echo "Downloading $(2)" ;\ 176 | GOBIN=$(PROJECT_DIR)/bin go install $(2) ;\ 177 | rm -rf $$TMP_DIR ;\ 178 | } 179 | endef 180 | 181 | .PHONY: bundle 182 | bundle: manifests kustomize ## Generate bundle manifests and metadata, then validate generated files. 183 | operator-sdk generate kustomize manifests -q 184 | cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) 185 | $(KUSTOMIZE) build config/manifests | operator-sdk generate bundle -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) 186 | operator-sdk bundle validate ./bundle 187 | 188 | .PHONY: bundle-build 189 | bundle-build: ## Build the bundle image. 190 | docker build -f bundle.Dockerfile --build-arg CONTROLLER_VERSION=${VERSION} -t $(BUNDLE_IMG) . 191 | 192 | .PHONY: bundle-push 193 | bundle-push: ## Push the bundle image. 194 | $(MAKE) docker-push IMG=$(BUNDLE_IMG) 195 | 196 | .PHONY: opm 197 | OPM = ./bin/opm 198 | opm: ## Download opm locally if necessary. 199 | ifeq (,$(wildcard $(OPM))) 200 | ifeq (,$(shell which opm 2>/dev/null)) 201 | @{ \ 202 | set -e ;\ 203 | mkdir -p $(dir $(OPM)) ;\ 204 | OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ 205 | curl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.15.1/$${OS}-$${ARCH}-opm ;\ 206 | chmod +x $(OPM) ;\ 207 | } 208 | else 209 | OPM = $(shell which opm) 210 | endif 211 | endif 212 | 213 | .PHONY: helm-tool 214 | helm-tool: 215 | ifeq (,$(shell which helm 2>/dev/null)) 216 | @{ \ 217 | set -e ;\ 218 | curl --tlsv1.2 --proto "=https" https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | sh 219 | } 220 | endif 221 | 222 | # 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). 223 | # These images MUST exist in a registry and be pull-able. 224 | BUNDLE_IMGS ?= $(BUNDLE_IMG) 225 | 226 | # The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0). 227 | CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION) 228 | 229 | # Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image. 230 | ifneq ($(origin CATALOG_BASE_IMG), undefined) 231 | FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG) 232 | endif 233 | 234 | # Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'. 235 | # This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see: 236 | # https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator 237 | .PHONY: catalog-build 238 | catalog-build: opm ## Build a catalog image. 239 | $(OPM) index add --container-tool docker --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT) 240 | 241 | # Push the catalog image. 242 | .PHONY: catalog-push 243 | catalog-push: ## Push a catalog image. 244 | $(MAKE) docker-push IMG=$(CATALOG_IMG) 245 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /controllers/dopplersecret_controller_secrets.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package controllers 18 | 19 | import ( 20 | "context" 21 | "crypto/sha256" 22 | "encoding/json" 23 | "fmt" 24 | "reflect" 25 | "slices" 26 | "time" 27 | 28 | "github.com/DopplerHQ/kubernetes-operator/pkg/models" 29 | 30 | corev1 "k8s.io/api/core/v1" 31 | "k8s.io/apimachinery/pkg/api/errors" 32 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 33 | "k8s.io/apimachinery/pkg/types" 34 | 35 | secretsv1alpha1 "github.com/DopplerHQ/kubernetes-operator/api/v1alpha1" 36 | "github.com/DopplerHQ/kubernetes-operator/pkg/api" 37 | procs "github.com/DopplerHQ/kubernetes-operator/pkg/processors" 38 | ) 39 | 40 | const ( 41 | kubeSecretVersionAnnotation = "secrets.doppler.com/version" 42 | kubeSecretProcessorsVersionAnnotation = "secrets.doppler.com/processor-version" 43 | kubeSecretFormatVersionAnnotation = "secrets.doppler.com/format" 44 | kubeSecretDashboardLinkAnnotaion = "secrets.doppler.com/dashboard-link" 45 | kubeSecretManagedByAnnotation = "secrets.doppler.com/managed-by" 46 | kubeSecretLastUpdatedAnnotation = "secrets.doppler.com/last-updated" 47 | kubeSecretServiceTokenKey = "serviceToken" 48 | ) 49 | 50 | var kubeSecretBuiltInAnnotationKeys = []string{kubeSecretVersionAnnotation, kubeSecretProcessorsVersionAnnotation, kubeSecretFormatVersionAnnotation, kubeSecretDashboardLinkAnnotaion, kubeSecretManagedByAnnotation, kubeSecretLastUpdatedAnnotation} 51 | 52 | // GetDashboardLink gets a link to the Doppler dashboard from a list of Doppler secrets 53 | func GetDashboardLink(secrets []models.Secret) string { 54 | var projectSlug string 55 | var configSlug string 56 | for _, secret := range secrets { 57 | if secret.Name == "DOPPLER_PROJECT" { 58 | projectSlug = secret.Value 59 | } else if secret.Name == "DOPPLER_CONFIG" { 60 | configSlug = secret.Value 61 | } 62 | } 63 | if projectSlug == "" || configSlug == "" { 64 | return "https://dashboard.doppler.com/workplace" 65 | } 66 | return fmt.Sprintf("https://dashboard.doppler.com/workplace/projects/%v/configs/%v", projectSlug, configSlug) 67 | } 68 | 69 | // GetReferencedSecret gets a Kubernetes secret from a SecretReference 70 | func (r *DopplerSecretReconciler) GetReferencedSecret(ctx context.Context, namespacedName types.NamespacedName) (*corev1.Secret, error) { 71 | existingKubeSecret := &corev1.Secret{} 72 | err := r.Client.Get(ctx, namespacedName, existingKubeSecret) 73 | if err != nil { 74 | existingKubeSecret = nil 75 | } 76 | return existingKubeSecret, err 77 | } 78 | 79 | // GetDopplerToken gets the Doppler Service Token referenced by the DopplerSecret 80 | func (r *DopplerSecretReconciler) GetDopplerToken(ctx context.Context, dopplerSecret secretsv1alpha1.DopplerSecret) (string, error) { 81 | tokenSecretNamespacedName := types.NamespacedName{ 82 | Name: dopplerSecret.Spec.TokenSecretRef.Name, 83 | Namespace: dopplerSecret.Spec.TokenSecretRef.Namespace, 84 | } 85 | tokenSecret, err := r.GetReferencedSecret(ctx, tokenSecretNamespacedName) 86 | if err != nil { 87 | return "", fmt.Errorf("Failed to fetch token secret reference: %w", err) 88 | } 89 | dopplerToken := tokenSecret.Data[kubeSecretServiceTokenKey] 90 | if dopplerToken == nil { 91 | return "", fmt.Errorf("Could not find secret key %s.%s", dopplerSecret.Spec.TokenSecretRef.Name, kubeSecretServiceTokenKey) 92 | } 93 | return string(dopplerToken), nil 94 | } 95 | 96 | // GetKubeSecretData generates Kube secret data from a Doppler API secrets result 97 | func GetKubeSecretData(secretsResult models.SecretsResult, processors secretsv1alpha1.SecretProcessors, includeSecretsByDefault bool) (map[string][]byte, error) { 98 | kubeSecretData := map[string][]byte{} 99 | for _, secret := range secretsResult.Secrets { 100 | // Processors 101 | processor := processors[secret.Name] 102 | if processor == nil { 103 | processor = &secretsv1alpha1.DefaultProcessor 104 | } 105 | 106 | var secretName string 107 | 108 | if processor.AsName != "" { 109 | secretName = processor.AsName 110 | } else if includeSecretsByDefault { 111 | secretName = secret.Name 112 | } else { 113 | // Omit this secret entirely 114 | continue 115 | } 116 | 117 | processorFunc := procs.All[processor.Type] 118 | if processorFunc == nil { 119 | return nil, fmt.Errorf("Failed to process data with unknown processor: %v", processor.Type) 120 | } 121 | data, err := processorFunc(secret.Value) 122 | if err != nil { 123 | return nil, fmt.Errorf("Failed to process data: %w", err) 124 | } 125 | 126 | kubeSecretData[secretName] = data 127 | } 128 | return kubeSecretData, nil 129 | } 130 | 131 | // GetKubeSecretAnnotations generates Kube annotations from a Doppler API secrets result 132 | func GetKubeSecretAnnotations(secretsResult models.SecretsResult, processorsVersion string, format string, additionalLabels map[string]string, managedBy string) map[string]string { 133 | annotations := map[string]string{} 134 | 135 | for k, v := range additionalLabels { 136 | annotations[k] = v 137 | } 138 | 139 | annotations[kubeSecretVersionAnnotation] = secretsResult.ETag 140 | annotations[kubeSecretDashboardLinkAnnotaion] = GetDashboardLink(secretsResult.Secrets) 141 | annotations[kubeSecretManagedByAnnotation] = managedBy 142 | annotations[kubeSecretLastUpdatedAnnotation] = time.Now().UTC().Format(time.RFC3339) 143 | 144 | if len(processorsVersion) > 0 { 145 | annotations[kubeSecretProcessorsVersionAnnotation] = processorsVersion 146 | } 147 | 148 | if len(format) > 0 { 149 | annotations[kubeSecretFormatVersionAnnotation] = format 150 | } 151 | 152 | return annotations 153 | } 154 | 155 | // GetKubeSecretLabels generates Kube labels from the provided managed secret spec values 156 | func GetKubeSecretLabels(additionalLabels map[string]string) map[string]string { 157 | labels := map[string]string{} 158 | 159 | for k, v := range additionalLabels { 160 | labels[k] = v 161 | } 162 | 163 | labels["secrets.doppler.com/subtype"] = "dopplerSecret" 164 | 165 | return labels 166 | } 167 | 168 | // GetProcessorsVersion generates the version of given processors using a SHA256 hash 169 | func GetProcessorsVersion(processors secretsv1alpha1.SecretProcessors) (string, error) { 170 | if len(processors) == 0 { 171 | return "", nil 172 | } 173 | processorsJson, err := json.Marshal(processors) 174 | if err != nil { 175 | return "", fmt.Errorf("Failed to marshal processors: %w", err) 176 | } 177 | return fmt.Sprintf("%x", sha256.Sum256(processorsJson)), nil 178 | } 179 | 180 | // CreateManagedSecret creates a managed Kubernetes secret 181 | func (r *DopplerSecretReconciler) CreateManagedSecret(ctx context.Context, dopplerSecret secretsv1alpha1.DopplerSecret, secretsResult models.SecretsResult) error { 182 | var includeSecretsByDefault bool 183 | if dopplerSecret.Spec.ManagedSecretRef.Type == string(corev1.SecretTypeOpaque) { 184 | includeSecretsByDefault = true 185 | } 186 | secretData, dataErr := GetKubeSecretData(secretsResult, dopplerSecret.Spec.Processors, includeSecretsByDefault) 187 | if dataErr != nil { 188 | return fmt.Errorf("Failed to build Kubernetes secret data: %w", dataErr) 189 | } 190 | processorsVersion, versErr := GetProcessorsVersion(dopplerSecret.Spec.Processors) 191 | if versErr != nil { 192 | return fmt.Errorf("Failed to compute processors version: %w", versErr) 193 | } 194 | newKubeSecret := &corev1.Secret{ 195 | ObjectMeta: metav1.ObjectMeta{ 196 | Name: dopplerSecret.Spec.ManagedSecretRef.Name, 197 | Namespace: dopplerSecret.Spec.ManagedSecretRef.Namespace, 198 | Annotations: GetKubeSecretAnnotations(secretsResult, processorsVersion, dopplerSecret.Spec.Format, dopplerSecret.Spec.ManagedSecretRef.Annotations, dopplerSecret.GetNamespacedName()), 199 | Labels: GetKubeSecretLabels(dopplerSecret.Spec.ManagedSecretRef.Labels), 200 | }, 201 | Type: corev1.SecretType(dopplerSecret.Spec.ManagedSecretRef.Type), 202 | Data: secretData, 203 | } 204 | err := r.Client.Create(ctx, newKubeSecret) 205 | if err != nil { 206 | return fmt.Errorf("Failed to create Kubernetes secret: %w", err) 207 | } 208 | r.Log.Info("[/] Successfully created new Kubernetes secret") 209 | return nil 210 | } 211 | 212 | // UpdateManagedSecret updates a managed Kubernetes secret 213 | func (r *DopplerSecretReconciler) UpdateManagedSecret(ctx context.Context, secret corev1.Secret, dopplerSecret secretsv1alpha1.DopplerSecret, secretsResult models.SecretsResult) error { 214 | var includeSecretsByDefault bool 215 | if dopplerSecret.Spec.ManagedSecretRef.Type == string(corev1.SecretTypeOpaque) { 216 | includeSecretsByDefault = true 217 | } 218 | secretData, dataErr := GetKubeSecretData(secretsResult, dopplerSecret.Spec.Processors, includeSecretsByDefault) 219 | if dataErr != nil { 220 | return fmt.Errorf("Failed to build Kubernetes secret data: %w", dataErr) 221 | } 222 | processorsVersion, procsVersErr := GetProcessorsVersion(dopplerSecret.Spec.Processors) 223 | if procsVersErr != nil { 224 | return fmt.Errorf("Failed to compute processors version: %w", procsVersErr) 225 | } 226 | secret.Data = secretData 227 | secret.ObjectMeta.Annotations = GetKubeSecretAnnotations(secretsResult, processorsVersion, dopplerSecret.Spec.Format, dopplerSecret.Spec.ManagedSecretRef.Annotations, dopplerSecret.GetNamespacedName()) 228 | secret.ObjectMeta.Labels = GetKubeSecretLabels((dopplerSecret.Spec.ManagedSecretRef.Labels)) 229 | err := r.Client.Update(ctx, &secret) 230 | if err != nil { 231 | return fmt.Errorf("Failed to update Kubernetes secret: %w", err) 232 | } 233 | r.Log.Info("[/] Successfully updated existing Kubernetes secret") 234 | return nil 235 | } 236 | 237 | // UpdateSecret updates a Kubernetes secret using the configuration specified in a DopplerSecret 238 | func (r *DopplerSecretReconciler) UpdateSecret(ctx context.Context, dopplerSecret secretsv1alpha1.DopplerSecret) error { 239 | log := r.Log.WithValues("dopplersecret", dopplerSecret.GetNamespacedName(), "verifyTLS", dopplerSecret.Spec.VerifyTLS, "host", dopplerSecret.Spec.Host) 240 | if dopplerSecret.Spec.ManagedSecretRef.Namespace == "" { 241 | dopplerSecret.Spec.ManagedSecretRef.Namespace = dopplerSecret.Namespace 242 | } 243 | 244 | // Handle namespace defaults 245 | if dopplerSecret.Spec.TokenSecretRef.Namespace == "" { 246 | dopplerSecret.Spec.TokenSecretRef.Namespace = dopplerSecret.Namespace 247 | } 248 | 249 | authProvider, err := r.getAuthProvider(ctx, &dopplerSecret) 250 | if err != nil { 251 | return fmt.Errorf("Failed to get auth provider: %w", err) 252 | } 253 | 254 | apiContext, err := authProvider.GetAPIContext(ctx) 255 | if err != nil { 256 | return fmt.Errorf("Failed to get API context: %w", err) 257 | } 258 | 259 | managedSecretNamespacedName := types.NamespacedName{ 260 | Name: dopplerSecret.Spec.ManagedSecretRef.Name, 261 | Namespace: dopplerSecret.Spec.ManagedSecretRef.Namespace, 262 | } 263 | existingKubeSecret, err := r.GetReferencedSecret(ctx, managedSecretNamespacedName) 264 | if err != nil && !errors.IsNotFound(err) { 265 | return fmt.Errorf("Failed to fetch managed secret reference: %w", err) 266 | } 267 | if existingKubeSecret != nil && existingKubeSecret.Type != corev1.SecretType(dopplerSecret.Spec.ManagedSecretRef.Type) { 268 | return fmt.Errorf("Cannot change existing managed secret type from %v to %v. Delete the managed secret and re-apply the DopplerSecret.", existingKubeSecret.Type, dopplerSecret.Spec.ManagedSecretRef.Type) 269 | } 270 | 271 | currentProcessorsVersion, err := GetProcessorsVersion(dopplerSecret.Spec.Processors) 272 | if err != nil { 273 | return fmt.Errorf("Failed to compute processors version: %w", err) 274 | } 275 | 276 | log.Info("Fetching Doppler secrets") 277 | secretVersion := "" 278 | 279 | // Secret processors 280 | processorsVersion := "" 281 | formatVersion := "" 282 | existingLabels := map[string]string{} 283 | existingCustomAnnotations := map[string]string{} 284 | if existingKubeSecret != nil { 285 | secretVersion = existingKubeSecret.Annotations[kubeSecretVersionAnnotation] 286 | processorsVersion = existingKubeSecret.Annotations[kubeSecretProcessorsVersionAnnotation] 287 | formatVersion = existingKubeSecret.Annotations[kubeSecretFormatVersionAnnotation] 288 | existingLabels = existingKubeSecret.Labels 289 | // We can't predict the new annotations because it includes the latest secret version. 290 | // Instead, we'll just compare the custom (non-builtin) annotations on the secret against the spec. 291 | for k, v := range existingKubeSecret.Annotations { 292 | if !slices.Contains(kubeSecretBuiltInAnnotationKeys, k) { 293 | existingCustomAnnotations[k] = v 294 | } 295 | } 296 | } 297 | 298 | changes := []string{} 299 | 300 | // Processors transform secret values so if they've changed, we need to re-fetch the secrets so they can be re-processed. 301 | if currentProcessorsVersion != processorsVersion { 302 | changes = append(changes, "processors") 303 | } 304 | 305 | // The format is computed by the API and it defaults to "json". However, the operator uses the presence of the `format` field 306 | // to determine whether or not to process the JSON as separate k/v pairs or save the whole payload into a single DOPPLER_SECRETS_FILE secret. 307 | // If the format changed, we need to re-fetch secrets so we can redetermine this. 308 | if dopplerSecret.Spec.Format != formatVersion { 309 | changes = append(changes, "format") 310 | } 311 | 312 | // If the labels have been changed, we don't technically need to reload the secrets but it's simpler to do. 313 | if !reflect.DeepEqual(existingLabels, GetKubeSecretLabels(dopplerSecret.Spec.ManagedSecretRef.Labels)) { 314 | changes = append(changes, "labels") 315 | } 316 | 317 | customAnnotations := dopplerSecret.Spec.ManagedSecretRef.Annotations 318 | if customAnnotations == nil { 319 | // Default to empty for comparison 320 | customAnnotations = map[string]string{} 321 | } 322 | 323 | // If the annotations have been changed, we don't technically need to reload the secrets but it's simpler to do. 324 | if !reflect.DeepEqual(existingCustomAnnotations, customAnnotations) { 325 | changes = append(changes, "annotations") 326 | } 327 | 328 | // If any relevant attributes have been changed, set requestedSecretVersion to an empty secret version to reload the secrets. 329 | requestedSecretVersion := secretVersion 330 | if len(changes) > 0 { 331 | log.Info("[/] Attributes have changed, reloading secrets.", "changes", changes) 332 | requestedSecretVersion = "" 333 | } 334 | 335 | secretsResult, apiErr := api.GetSecrets(*apiContext, requestedSecretVersion, dopplerSecret.Spec.Project, dopplerSecret.Spec.Config, dopplerSecret.Spec.NameTransformer, dopplerSecret.Spec.Format, dopplerSecret.Spec.Secrets) 336 | if apiErr != nil { 337 | return apiErr 338 | } 339 | if !secretsResult.Modified { 340 | log.Info("[-] Doppler secrets not modified.") 341 | return nil 342 | } 343 | 344 | log.Info("[/] Secrets have been modified", "oldVersion", secretVersion, "newVersion", secretsResult.ETag, "changes", changes) 345 | 346 | if existingKubeSecret == nil { 347 | return r.CreateManagedSecret(ctx, dopplerSecret, *secretsResult) 348 | } else { 349 | return r.UpdateManagedSecret(ctx, *existingKubeSecret, dopplerSecret, *secretsResult) 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 11 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 12 | github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQobrkAqrL+WFZwQses= 13 | github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 14 | github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= 15 | github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= 16 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 17 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 18 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 19 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 20 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 21 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 22 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 23 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 24 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 25 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 26 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 27 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 28 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 29 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 30 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 31 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 32 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 33 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 34 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 35 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 36 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 37 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 38 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 39 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 40 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 41 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 42 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 43 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 44 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 45 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 46 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 47 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 48 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 50 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= 52 | github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 53 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 54 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 55 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 56 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 57 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 58 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 59 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 60 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 61 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 62 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 63 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 64 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 65 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 66 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 67 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 68 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 69 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 71 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 72 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 73 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 74 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 75 | github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= 76 | github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 77 | github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= 78 | github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 79 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 80 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 81 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 82 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 83 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 84 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 85 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 86 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 87 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 88 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 89 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 90 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 91 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 92 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 93 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 94 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 95 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 96 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 97 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 98 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 99 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 100 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 101 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 102 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 103 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 104 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 105 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 106 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 107 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 108 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 109 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 110 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 111 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 112 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 113 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 114 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 115 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 116 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 117 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 118 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= 119 | golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 120 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 121 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 122 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 123 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 124 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 125 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 126 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 127 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 128 | golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= 129 | golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 130 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 131 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 132 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 133 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 134 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 135 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 136 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 137 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 138 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 139 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 140 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 141 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 142 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 143 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 144 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 145 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 146 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 147 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 148 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 149 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 150 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 151 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 152 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 153 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 154 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 155 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 156 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 157 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 158 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 159 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 160 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 161 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 162 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 163 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 164 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 165 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 166 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 167 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 168 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 169 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 170 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 171 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 172 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 173 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 174 | k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0= 175 | k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk= 176 | k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= 177 | k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= 178 | k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw= 179 | k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= 180 | k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc= 181 | k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs= 182 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 183 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 184 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 185 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 186 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= 187 | k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 188 | sigs.k8s.io/controller-runtime v0.19.1 h1:Son+Q40+Be3QWb+niBXAg2vFiYWolDjjRfO8hn/cxOk= 189 | sigs.k8s.io/controller-runtime v0.19.1/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= 190 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 191 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 192 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 193 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 194 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 195 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doppler Kubernetes Operator 2 | 3 | Automatically sync secrets from Doppler to Kubernetes and auto-reload deployments when secrets change. 4 | 5 | ![Doppler Kubernetes Operator Diagram](docs/diagram.jpg) 6 | 7 | ## Overview 8 | 9 | - The Doppler Kubernetes Operator is a controller which runs inside a deployment on your Kubernetes cluster 10 | - It manages custom resources called `DopplerSecret`s, each of which contains a reference to a Kubernetes secret containing your Doppler Service Token and a reference to the Kubernetes secret where Doppler secrets should be synced 11 | - The operator continuously monitors the Doppler API for changes to your Doppler config and updates the managed Kubernetes secret automatically 12 | - If the secrets have changed, the operator can also reload deployments using the Kubernetes secret. See below for details on configuring auto-reload. 13 | 14 | ## Step 0: Enable Kubernetes Secret Encryption at Rest 15 | 16 | The Doppler Kubernetes Operator uses [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/) to store sensitive data. 17 | 18 | Kubernetes Secrets are, by default, stored as unencrypted base64-encoded strings. By default they can be retrieved - as plain text - by anyone with API access, or anyone with access to Kubernetes' underlying data store, etcd. Therefore, Kubernetes recommends enabling [encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) to secure this data. 19 | 20 | ## Step 1: Deploy the Operator 21 | 22 | ### Using Helm 23 | 24 | You can install the latest Helm chart with: 25 | 26 | ```bash 27 | helm repo add doppler https://helm.doppler.com 28 | helm install --generate-name doppler/doppler-kubernetes-operator 29 | ``` 30 | 31 | Updates can be performed with `helm upgrade`. 32 | 33 | One caveat is that [Helm cannot update custom resource definitions (CRDs)](https://helm.sh/docs/chart_best_practices/custom_resource_definitions/#some-caveats-and-explanations). 34 | To simplify this, Doppler guarantees that CRDs will remain backwards compatible. CRDs can be updated directly from the Helm chart manifest with: 35 | 36 | ```bash 37 | helm repo update 38 | helm pull doppler/doppler-kubernetes-operator --untar 39 | kubectl apply -f doppler-kubernetes-operator/crds/all.yaml 40 | ``` 41 | 42 | ### Using `kubectl` 43 | 44 | You can also deploy the operator by applying the latest installation YAML directly: 45 | 46 | ```bash 47 | kubectl apply -f https://github.com/DopplerHQ/kubernetes-operator/releases/latest/download/recommended.yaml 48 | ``` 49 | 50 | Regardless of the installation method, this will use your locally-configured `kubectl` to: 51 | 52 | - Create a `doppler-operator-system` namespace 53 | - Create the resource definition for a `DopplerSecret` 54 | - Setup a service account and RBAC role for the operator 55 | - Create a deployment for the operator inside of the cluster 56 | 57 | You can verify that the operator is running successfully in your cluster with `./tools/operator-logs.sh`. This waits for the deployment to roll out and then tails the log. You can leave this command running to keep monitoring the logs or quit safely with Ctrl-C. 58 | 59 | ## Step 2: Create a `DopplerSecret` 60 | 61 | A `DopplerSecret` is a custom Kubernetes resource with references to two secrets: 62 | 63 | - A Kubernetes secret containing either your Doppler Service Token (in the `serviceToken` field) OR OIDC configuration (in the `identity` field) for authentication (AKA "Doppler Token Secret") 64 | - A Kubernetes secret where your synced Doppler secrets will be stored (AKA "Managed Secret"). This secret will be created by the operator if it does not already exist. 65 | 66 | > Note: While these resources can be created in any namespace, it is recommended that you create your Doppler Token Secret and DopplerSecret inside the `doppler-operator-system` namespace to prevent unauthorized access. The managed secret should be namespaced with the deployments which will use the secret. 67 | 68 | ### Authentication Setup 69 | 70 | The operator supports two authentication methods: Service Token and OIDC. 71 | 72 | Configure either a Workplace Role or a set of Project Access permissions on your Doppler Service Account to allow the appropriate access. 73 | 74 | #### Option 1: Service Token Authentication 75 | 76 | Generate a Doppler Service Token and create a secret: 77 | 78 | ```bash 79 | kubectl create secret generic doppler-token-secret -n doppler-operator-system --from-literal=serviceToken=dp.st.dev.XXXX 80 | ``` 81 | 82 | If you have the Doppler CLI installed, you can generate a Doppler Service Token from the CLI and create the Doppler token secret in one step: 83 | 84 | ```bash 85 | kubectl create secret generic doppler-token-secret -n doppler-operator-system --from-literal=serviceToken=$(doppler configs tokens create doppler-kubernetes-operator --project example-project --config prd --plain) 86 | ``` 87 | 88 | #### Option 2: OIDC Authentication 89 | 90 | First, ensure your cluster's OIDC discovery URLs are publicly accessible. Then create a Doppler [Service Account Identity](https://docs.doppler.com/docs/service-account-identities) with: 91 | 92 | - **Audience**: 93 | - If using identity in tokenSecret: `dopplerTokenSecret:doppler-operator-system:doppler-token-secret`, where `doppler-token-secret` is the name of the tokenSecret and `doppler-operator-system` is the namespace. 94 | - If using identity in DopplerSecret spec: `dopplerSecret:doppler-operator-system:dopplersecret-test`, where `dopplersecret-test` is the name of the DopplerSecret and `doppler-operator-system` is the namespace. 95 | - **Subject**: `system:serviceaccount:doppler-operator-system:doppler-operator-controller-manager`, the operator's shared ServiceAccount. 96 | 97 | Either create a secret containing your identity: 98 | 99 | ```bash 100 | kubectl create secret generic doppler-token-secret -n doppler-operator-system --from-literal=identity=YOUR_IDENTITY_ID 101 | ``` 102 | 103 | Or include the identity directly in the DopplerSecret spec (see Service Account Identity example below). 104 | 105 | ### Creating the DopplerSecret 106 | 107 | Next, create a `DopplerSecret` that references your authentication secret and defines the location of the managed secret: 108 | 109 | **Example with Doppler token secret:** 110 | 111 | ```yaml 112 | apiVersion: secrets.doppler.com/v1alpha1 113 | kind: DopplerSecret 114 | metadata: 115 | name: dopplersecret-test # DopplerSecret Name 116 | namespace: doppler-operator-system 117 | spec: 118 | tokenSecret: # References the auth secret created above (containing Service Token or Service Account Identity) 119 | name: doppler-token-secret 120 | project: example-project # Doppler project 121 | config: prd # Doppler config 122 | managedSecret: # Kubernetes managed secret (will be created if does not exist) 123 | name: doppler-test-secret 124 | namespace: default # Should match the namespace of deployments that will use the secret 125 | ``` 126 | 127 | **Example with Service Account Identity provided in DopplerSecret spec:** 128 | 129 | ```yaml 130 | apiVersion: secrets.doppler.com/v1alpha1 131 | kind: DopplerSecret 132 | metadata: 133 | name: dopplersecret-test 134 | namespace: doppler-operator-system 135 | spec: 136 | identity: 00000000-0000-0000-0000-000000000000 # Doppler Service Account Identity 137 | project: example-project 138 | config: prd 139 | managedSecret: 140 | name: doppler-test-secret 141 | namespace: default 142 | ``` 143 | 144 | If you're following along with these example names, you can apply this sample directly: 145 | 146 | ```bash 147 | kubectl apply -f config/samples/secrets_v1alpha1_dopplersecret.yaml 148 | ``` 149 | 150 | ### Verify Secret Creation 151 | 152 | Check that the associated Kubernetes secret has been created: 153 | 154 | ```bash 155 | # List all Kubernetes secrets created by the Doppler operator 156 | kubectl describe secrets --selector=secrets.doppler.com/subtype=dopplerSecret 157 | ``` 158 | 159 | The operator continuously watches for secret updates from Doppler and when detected, automatically and instantly updates the associated secret. 160 | 161 | Next, we'll cover how to configure a deployment to use the Kubernetes secret and enable auto-reloading for Deployments. 162 | 163 | ## Step 3: Configuring a Deployment 164 | 165 | ### Using the Secret in a Deployment 166 | 167 | To use the secret created by the operator, we can use the managed secret in one of three ways. These methods are also covered in greater detail in the [Kubernetes Secrets documentation](https://kubernetes.io/docs/concepts/configuration/secret/). 168 | 169 | #### `envFrom` 170 | 171 | The `envFrom` field will populate a container's environment variables using the secret's Key-Value pairs: 172 | 173 | ```yaml 174 | envFrom: 175 | - secretRef: 176 | name: doppler-test-secret # Kubernetes secret name 177 | ``` 178 | 179 | #### `valueFrom` 180 | 181 | The `valueFrom` field will inject a specific environment variable from the Kubernetes secret: 182 | 183 | ```yaml 184 | env: 185 | - name: MY_APP_SECRET # The name of the environment variable exposed in the container 186 | valueFrom: 187 | secretKeyRef: 188 | name: doppler-test-secret # Kubernetes secret name 189 | key: MY_APP_SECRET # The name of the key in the Kubernetes secret 190 | ``` 191 | 192 | #### `volume` 193 | 194 | The `volume` field will create a volume that is populated with files containing the Kubernetes secret: 195 | 196 | ```yaml 197 | volumes: 198 | - name: secret-volume 199 | secret: 200 | secretName: doppler-test-secret # Kubernetes secret name 201 | ``` 202 | 203 | Your deployment can use this volume by mounting it to the container's filesystem: 204 | 205 | ```yaml 206 | volumeMounts: 207 | - name: secret-volume 208 | mountPath: /etc/secrets 209 | readOnly: true 210 | ``` 211 | 212 | ### Automatic Redeployments 213 | 214 | In order for the operator to reload a deployment, three things must be true: 215 | 216 | - The deployment is in the same namespace as the managed secret 217 | - The deployment has the `secrets.doppler.com/reload` annotation set to `'true'` (string) 218 | - The deployment uses the managed secret 219 | 220 | Here's an example of the reload annotation: 221 | 222 | ```yaml 223 | annotations: 224 | secrets.doppler.com/reload: 'true' 225 | ``` 226 | 227 | The Doppler Kubernetes operator reloads deployments by updating an annotation with the name `secrets.doppler.com/secretsupdate.`. When this update is made, Kubernetes will automatically redeploy your pods according to the [deployment's configured strategy](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy). 228 | 229 | ### Full Examples 230 | 231 | Complete examples of these different deployment configurations can be found below: 232 | 233 | - [`deployment-envfrom.yaml`](config/samples/deployment-envfrom.yaml) 234 | - [`deployment-valuefrom.yaml`](config/samples/deployment-valuefrom.yaml) 235 | - [`deployment-volume.yaml`](config/samples/deployment-volume.yaml) 236 | 237 | If you've named your managed Kubernetes secret `doppler-test-secret` in the previous step, you can apply any of these examples directly: 238 | 239 | ```bash 240 | kubectl apply -f config/samples/deployment-envfrom.yaml 241 | kubectl rollout status -w deployment/doppler-test-deployment-envfrom 242 | ``` 243 | 244 | Once the Deployment has completed, you can view the logs of the test container: 245 | 246 | ```bash 247 | kubectl logs -lapp=doppler-test --tail=-1 248 | ``` 249 | 250 | Setup is complete! To test the sync behavior, modify a secret in the Doppler dashboard and wait 60 seconds. Run the logs command again (or use the `watch` command) to see the pods automatically restart with the new secret data. 251 | 252 | ## Name Transformers 253 | 254 | Name Transformers enable secret names to transformed from Doppler's `UPPER_SNAKE_CASE` format into any of the following environment variable compatible formats: 255 | 256 | | Type | Default | Transform | 257 | | ----------- | ----------------- | ---------------- | 258 | | camel | API_KEY | apiKey | 259 | | upper-camel | API_KEY | ApiKey | 260 | | lower-snake | API_KEY | api_key | 261 | | tf-var | API_KEY | TF_VAR_api_key | 262 | | dotnet-env | SMTP\_\_USER_NAME | Smtp\_\_UserName | 263 | | lower-kebab | API_KEY | api-key | 264 | 265 | Simply add the `nameTransformer` field with any of the above types: 266 | 267 | ```yaml 268 | apiVersion: secrets.doppler.com/v1alpha1 269 | kind: DopplerSecret 270 | metadata: 271 | name: dopplersecret-test 272 | namespace: doppler-operator-system 273 | spec: 274 | tokenSecret: 275 | name: doppler-token-secret 276 | managedSecret: 277 | name: doppler-test-secret 278 | namespace: default 279 | nameTransformer: dotnet-env 280 | ``` 281 | 282 | The `nameTransformer` values are also validated prior to admission to prevent transformation failures. 283 | 284 | ## Download Formats 285 | 286 | Instead of the standard Key / Value pairs, you can download secrets as a single file in the following formats: 287 | 288 | - json 289 | - dotnet-json 290 | - env 291 | - env-no-quotes 292 | - yaml 293 | 294 | When `format` is specified, a single `DOPPLER_SECRETS_FILE` key is set in the created secret with the string contents of the downloaded file. 295 | 296 | Simply add the `format` field: 297 | 298 | ```yaml 299 | apiVersion: secrets.doppler.com/v1alpha1 300 | kind: DopplerSecret 301 | metadata: 302 | name: dotnet-webapp-appsettings 303 | namespace: doppler-operator-system 304 | spec: 305 | tokenSecret: 306 | name: doppler-token-dotnet-webapp 307 | namespace: doppler-operator-system 308 | managedSecret: 309 | name: dotnet-webapp-appsettings 310 | namespace: default 311 | format: dotnet-json 312 | ``` 313 | 314 | You can then configure your deployment spec to mount the file at the desired path: 315 | 316 | ```yaml 317 | ... 318 | spec: 319 | containers: 320 | - name: dotnet-webapp 321 | volumeMounts: 322 | - name: doppler 323 | mountPath: /usr/src/app/secrets 324 | readOnly: true 325 | volumes: 326 | - name: doppler 327 | secret: 328 | secretName: dotnet-webapp-appsettings # Managed secret name 329 | optional: false 330 | items: 331 | - key: DOPPLER_SECRETS_FILE # Hard-coded by Operator when format specified 332 | path: appsettings.json # Name or path to file name appended to container mountPath 333 | ``` 334 | 335 | ## Specifying Secret Subsets to Sync 336 | 337 | You can have the operator only sync a subset of secrets in a Doppler config. To do this, specify them in the `secrets` spec property: 338 | 339 | ```yaml 340 | apiVersion: secrets.doppler.com/v1alpha1 341 | kind: DopplerSecret 342 | metadata: 343 | name: dopplersecret-test 344 | namespace: doppler-operator-system 345 | spec: 346 | tokenSecret: 347 | name: doppler-token-secret 348 | secrets: 349 | - HOSTNAME 350 | - PORT 351 | managedSecret: 352 | name: doppler-test-secret 353 | namespace: default 354 | ``` 355 | 356 | If this property is omitted all secrets are synced. 357 | 358 | ## Specifying Labels and Annotations on a Managed Secret 359 | 360 | You can specify labels and annotations that the operator should add to the managed Kubernetes `Secret` resource. To do this, specify them in the `managedSecret.labels` and `managedSecret.annotations` spec properties. 361 | 362 | ```yaml 363 | apiVersion: secrets.doppler.com/v1alpha1 364 | kind: DopplerSecret 365 | metadata: 366 | name: dopplersecret-test 367 | namespace: doppler-operator-system 368 | spec: 369 | tokenSecret: 370 | name: doppler-token-secret 371 | managedSecret: 372 | name: doppler-test-secret 373 | namespace: default 374 | labels: 375 | doppler-secret-label: test 376 | annotations: 377 | doppler-secret-annotation: test 378 | ``` 379 | 380 | ## Kubernetes Secret Types and Value Encoding 381 | 382 | By default, the operator syncs secret values as they are in Doppler to an [`Opaque` Kubernetes secret](https://kubernetes.io/docs/concepts/configuration/secret/) as Key / Value pairs. 383 | 384 | In some cases, the secret name or value stored in Doppler is not the format required for your Kubernetes deployment. 385 | For example, you might have Base64-encoded TLS data that you want to copy to a native Kubernetes TLS secret (`kubernetes.io/tls`). 386 | 387 | You can use [custom types and processors](docs/custom_types_and_processors.md) to achieve this. 388 | 389 | ## Failure Strategy and Troubleshooting 390 | 391 | ### Inspecting Status 392 | 393 | If the operator fails to fetch secrets from the Doppler API (e.g. a connection problem or invalid service token), no changes are made to the managed Kubernetes secret or your deployments. The operator will continue to attempt to reconnect to the Doppler API indefinitely. 394 | 395 | The `DopplerSecret` uses `status.conditions` to report its current state and any errors that may have occurred. 396 | 397 | In this example, our Doppler service token has been revoked and the operator is reporting an error condition: 398 | 399 | ``` 400 | $ kubectl describe dopplersecrets -n doppler-operator-system 401 | Name: dopplersecret-test 402 | Namespace: doppler-operator-system 403 | Labels: 404 | Annotations: 405 | API Version: secrets.doppler.com/v1alpha1 406 | Kind: DopplerSecret 407 | Metadata: 408 | ... 409 | Spec: 410 | ... 411 | Status: 412 | Conditions: 413 | Last Transition Time: 2021-06-02T15:46:57Z 414 | Message: Secret update failed: Doppler Error: Invalid Service token 415 | Reason: Error 416 | Status: False 417 | Type: secrets.doppler.com/SecretSyncReady 418 | Last Transition Time: 2021-06-02T15:46:57Z 419 | Message: Deployment reload has been stopped due to secrets sync failure 420 | Reason: Stopped 421 | Status: False 422 | Type: secrets.doppler.com/DeploymentReloadReady 423 | Events: 424 | ``` 425 | 426 | You can safely modify your token Kubernetes secret or `DopplerSecret` at any time. To update our Doppler service token, we can modify our token Kubernetes secret directly and the changes will take effect immediately. 427 | 428 | The `DopplerSecret` resource manages the managed Kubernetes secret but does not officially own it. Therefore, deleting a `DopplerSecret` will not automatically delete the managed secret. 429 | 430 | ### Included Tools 431 | 432 | - [`tools/get-secret.sh`](tools/get-secret.sh) - fetch and decode a Kubernetes secret 433 | - [`tools/operator-logs.sh`](tools/operator-logs.sh) - wait for the operator deployment to roll out, then tail the logs 434 | 435 | ## Uninstalling 436 | 437 | To uninstall the operator, first delete any `DopplerSecret` resources and any referenced Kubernetes secrets that are no longer needed. 438 | 439 | ```bash 440 | kubectl delete dopplersecrets --all --all-namespaces 441 | kubectl delete secret doppler-token-secret -n doppler-operator-system 442 | ``` 443 | 444 | If you installed the operator with Helm, you can use `helm uninstall` to remove the installation resources. Otherwise, run the following command: 445 | 446 | ```bash 447 | kubectl delete -f https://github.com/DopplerHQ/kubernetes-operator/releases/latest/download/recommended.yaml 448 | ``` 449 | 450 | ## Development 451 | 452 | This project uses the [Operator SDK](https://sdk.operatorframework.io). 453 | 454 | When developing locally, you can run the operator using: 455 | 456 | ```bash 457 | make install run 458 | ``` 459 | 460 | See the [Operator SDK Go Tutorial](https://sdk.operatorframework.io/docs/building-operators/golang/tutorial/#run-the-operator) for more information. 461 | 462 | ## Release 463 | 464 | This project is released with Github Actions. Adding a Github Release will start an action which builds the operator image and publishes it to DockerHub. Tag names should match the pattern `vX.X.X`. 465 | --------------------------------------------------------------------------------