├── .go-version ├── .gitignore ├── .yamlignore ├── e2e ├── kind.yaml ├── deploy │ ├── vault │ │ ├── rbac.yaml │ │ └── vault.yaml │ └── workloads │ │ ├── statefulset.yaml │ │ ├── daemonset.yaml │ │ └── deployments.yaml ├── main_test.go └── reloader_test.go ├── .dockerignore ├── .hadolint.yaml ├── examples ├── README.md ├── assets │ ├── flowchart.png │ ├── README.md │ └── flowchart.d2 ├── reloader-in-bank-vaults-ecosystem.md └── try-locally.md ├── .yamllint.yaml ├── .github ├── workflows │ ├── project.yml │ ├── pr.yml │ ├── release.yaml │ ├── ci.yaml │ └── artifacts.yaml ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── config.yaml │ ├── feature_request.yaml │ └── bug_report.yaml └── dependabot.yaml ├── deploy └── charts │ └── vault-secrets-reloader │ ├── .helmignore │ ├── templates │ ├── tests │ │ └── test-connection.yaml │ ├── service.yaml │ ├── hpa.yaml │ ├── rbac.yaml │ ├── _helpers.tpl │ ├── ingress.yaml │ └── deployment.yaml │ ├── Chart.yaml │ ├── README.md.gotmpl │ ├── values.yaml │ └── README.md ├── .golangci.yaml ├── Dockerfile ├── .licensei.toml ├── pkg └── reloader │ ├── client_logger.go │ ├── reloader_test.go │ ├── collector_test.go │ ├── vault_test.go │ ├── vault.go │ ├── collector.go │ ├── reloader.go │ └── controller.go ├── main.go ├── README.md ├── Makefile ├── go.mod └── LICENSE /.go-version: -------------------------------------------------------------------------------- 1 | 1.25.4 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /build/ 3 | /tmp/ 4 | -------------------------------------------------------------------------------- /.yamlignore: -------------------------------------------------------------------------------- 1 | /deploy/ 2 | /e2e/deploy/ 3 | /e2e/test/ 4 | /.github/ 5 | -------------------------------------------------------------------------------- /e2e/kind.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.github/ 2 | /bin/ 3 | /build/ 4 | /deploy/ 5 | /Dockerfile 6 | /e2e/ 7 | -------------------------------------------------------------------------------- /.hadolint.yaml: -------------------------------------------------------------------------------- 1 | ignored: 2 | - DL3018 3 | - DL3059 4 | 5 | failure-threshold: error 6 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Here you can find assets and examples on how to use the Vault Secrets Reloader. 4 | -------------------------------------------------------------------------------- /examples/assets/flowchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bank-vaults/vault-secrets-reloader/HEAD/examples/assets/flowchart.png -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | ignore-from-file: [.gitignore, .yamlignore] 2 | 3 | extends: default 4 | 5 | rules: 6 | line-length: disable 7 | document-start: disable 8 | -------------------------------------------------------------------------------- /examples/assets/README.md: -------------------------------------------------------------------------------- 1 | # Preview and export flowchart 2 | 3 | ```bash 4 | # Preview 5 | d2 --watch examples/assets/flowchart.d2 out.svg --layout=elk 6 | 7 | # Export 8 | d2 examples/assets/flowchart.d2 examples/assets/flowchart.png --layout=elk 9 | ``` 10 | -------------------------------------------------------------------------------- /.github/workflows/project.yml: -------------------------------------------------------------------------------- 1 | name: Project 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 0" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | common: 10 | uses: bank-vaults/.github/.github/workflows/_project-common.yml@main 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | - synchronize 10 | 11 | jobs: 12 | common: 13 | uses: bank-vaults/.github/.github/workflows/_pr-common.yml@main 14 | permissions: 15 | pull-requests: write 16 | issues: write 17 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | - "v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+" 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | artifacts: 14 | name: Artifacts 15 | uses: ./.github/workflows/artifacts.yaml 16 | with: 17 | publish: true 18 | release: true 19 | permissions: 20 | contents: write 21 | packages: write 22 | id-token: write 23 | security-events: write 24 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "vault-secrets-reloader.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "vault-secrets-reloader.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file provides an overview of code owners in this repository. 2 | 3 | # Each line is a file pattern followed by one or more owners. 4 | # The last matching pattern has the most precedence. 5 | # For more details read the following article on GitHub: https://help.github.com/articles/about-codeowners/. 6 | 7 | # These are the default owners for the whole content of repository. 8 | # The default owners are automatically added as reviewers when you open a pull request unless different owners are specified in the file. 9 | * @bank-vaults/maintainers 10 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "vault-secrets-reloader.fullname" . }} 5 | labels: 6 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.externalPort }} 11 | targetPort: {{ .Values.service.internalPort }} 12 | protocol: TCP 13 | name: {{ .Values.service.name }} 14 | selector: 15 | {{- include "vault-secrets-reloader.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | type: application 3 | name: vault-secrets-reloader 4 | version: 0.0.0 5 | appVersion: latest 6 | description: A Kubernetes Controller that reloads workloads on a referenced secret change in HashiCorp Vault. 7 | keywords: 8 | - vault 9 | - hashicorp 10 | - secret 11 | - webhook 12 | - reloader 13 | home: https://bank-vaults.dev 14 | sources: 15 | - https://github.com/hashicorp/vault 16 | - https://github.com/bank-vaults/vault-secrets-reloader 17 | maintainers: 18 | - name: Bank Vaults Maintainers 19 | email: team@bank-vaults.dev 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Overview 10 | 11 | 16 | 17 | Fixes #(issue) 18 | 19 | ## Notes for reviewer 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 📖 Documentation enhancement 4 | url: https://github.com/bank-vaults/bank-vaults.dev/issues 5 | about: Suggest an improvement to the documentation 6 | 7 | - name: ❓ Ask a question 8 | url: https://github.com/bank-vaults/vault-secrets-reloader/discussions/new?category=q-a 9 | about: Ask and discuss questions with other Bank-Vaults community members 10 | 11 | - name: 📚 Documentation 12 | url: https://bank-vaults.dev/docs 13 | about: Check the documentation for help 14 | 15 | - name: 💬 Slack channel 16 | url: https://cloud-native.slack.com/archives/C078PHYK38W/ 17 | about: Please ask and answer questions here 18 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | labels: 7 | - "area/dependencies" 8 | schedule: 9 | interval: "weekly" 10 | day: "sunday" 11 | time: "16:00" 12 | open-pull-requests-limit: 10 13 | groups: 14 | k8s.io: 15 | patterns: 16 | - "k8s.io/*" 17 | sigs.k8s.io: 18 | patterns: 19 | - "sigs.k8s.io/*" 20 | 21 | - package-ecosystem: "docker" 22 | directory: "/" 23 | labels: 24 | - "area/dependencies" 25 | schedule: 26 | interval: "weekly" 27 | day: "sunday" 28 | time: "16:00" 29 | open-pull-requests-limit: 10 30 | 31 | - package-ecosystem: "github-actions" 32 | directory: "/" 33 | labels: 34 | - "area/dependencies" 35 | schedule: 36 | interval: "weekly" 37 | day: "sunday" 38 | time: "16:00" 39 | open-pull-requests-limit: 10 40 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | timeout: 10m 4 | 5 | formatters: 6 | settings: 7 | gci: 8 | sections: 9 | - standard 10 | - default 11 | - prefix(github.com/bank-vaults/vault-secrets-reloader) 12 | goimports: 13 | local-prefixes: 14 | - github.com/bank-vaults/vault-secrets-reloader 15 | gofmt: 16 | simplify: true 17 | gofumpt: 18 | extra-rules: false 19 | 20 | linters: 21 | settings: 22 | misspell: 23 | locale: US 24 | nolintlint: 25 | allow-unused: false # report any unused nolint directives 26 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped 27 | revive: 28 | confidence: 0.1 29 | enable: 30 | - bodyclose 31 | - errcheck 32 | - ineffassign 33 | - misspell 34 | - nolintlint 35 | - revive 36 | - unconvert 37 | - unparam 38 | - unused 39 | - whitespace 40 | -------------------------------------------------------------------------------- /e2e/deploy/vault/rbac.yaml: -------------------------------------------------------------------------------- 1 | kind: ServiceAccount 2 | apiVersion: v1 3 | metadata: 4 | name: vault 5 | 6 | --- 7 | kind: Role 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | metadata: 10 | name: vault 11 | rules: 12 | - apiGroups: [""] 13 | resources: ["secrets"] 14 | verbs: ["*"] 15 | - apiGroups: [""] 16 | resources: ["pods"] 17 | verbs: ["get", "update", "patch"] 18 | 19 | --- 20 | kind: RoleBinding 21 | apiVersion: rbac.authorization.k8s.io/v1 22 | metadata: 23 | name: vault 24 | roleRef: 25 | kind: Role 26 | name: vault 27 | apiGroup: rbac.authorization.k8s.io 28 | subjects: 29 | - kind: ServiceAccount 30 | name: vault 31 | 32 | --- 33 | # This binding allows the deployed Vault instance to authenticate clients 34 | # through Kubernetes ServiceAccounts (if configured so). 35 | apiVersion: rbac.authorization.k8s.io/v1 36 | kind: ClusterRoleBinding 37 | metadata: 38 | name: vault-auth-delegator 39 | roleRef: 40 | apiGroup: rbac.authorization.k8s.io 41 | kind: ClusterRole 42 | name: system:auth-delegator 43 | subjects: 44 | - kind: ServiceAccount 45 | name: vault 46 | namespace: default 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx 2 | 3 | FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine3.22@sha256:3587db7cc96576822c606d119729370dbf581931c5f43ac6d3fa03ab4ed85a10 AS builder 4 | 5 | COPY --from=xx / / 6 | 7 | RUN apk add --update --no-cache ca-certificates make git curl clang lld 8 | 9 | ARG TARGETPLATFORM 10 | 11 | RUN xx-apk --update --no-cache add musl-dev gcc 12 | 13 | RUN xx-go --wrap 14 | 15 | WORKDIR /usr/local/src/vault-secrets-reloader 16 | 17 | ARG GOPROXY 18 | 19 | ENV CGO_ENABLED=0 20 | 21 | COPY go.* ./ 22 | RUN go mod download 23 | 24 | COPY . . 25 | 26 | RUN go build -o /usr/local/bin/vault-secrets-reloader . 27 | RUN xx-verify /usr/local/bin/vault-secrets-reloader 28 | 29 | 30 | FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62 31 | 32 | RUN apk add --update --no-cache ca-certificates tzdata libcap 33 | 34 | COPY --from=builder /usr/local/bin/vault-secrets-reloader /usr/local/bin/vault-secrets-reloader 35 | 36 | USER 65534 37 | 38 | ENTRYPOINT ["vault-secrets-reloader"] 39 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "vault-secrets-reloader.fullname" . }} 6 | labels: 7 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "vault-secrets-reloader.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "vault-secrets-reloader.serviceAccountName" . }} 6 | labels: 7 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | 14 | --- 15 | 16 | apiVersion: rbac.authorization.k8s.io/v1 17 | kind: ClusterRole 18 | metadata: 19 | name: {{ template "vault-secrets-reloader.fullname" . }} 20 | rules: 21 | - apiGroups: 22 | - "apps" 23 | resources: 24 | - deployments 25 | - statefulsets 26 | - daemonsets 27 | verbs: 28 | - "get" 29 | - "list" 30 | - "update" 31 | - "watch" 32 | - apiGroups: 33 | - "" 34 | resources: 35 | - secrets 36 | verbs: 37 | - "get" 38 | 39 | --- 40 | 41 | apiVersion: rbac.authorization.k8s.io/v1 42 | kind: ClusterRoleBinding 43 | metadata: 44 | name: {{ template "vault-secrets-reloader.fullname" . }} 45 | roleRef: 46 | kind: ClusterRole 47 | apiGroup: rbac.authorization.k8s.io 48 | name: {{ template "vault-secrets-reloader.fullname" . }} 49 | subjects: 50 | - kind: ServiceAccount 51 | namespace: {{ .Release.Namespace }} 52 | name: {{ template "vault-secrets-reloader.serviceAccountName" . }} 53 | -------------------------------------------------------------------------------- /examples/reloader-in-bank-vaults-ecosystem.md: -------------------------------------------------------------------------------- 1 | # Vault Secrets Reloader and the Bank-Vaults ecosystem 2 | 3 | If you are familiar with the Bank-Vaults [Secrets Webhook](https://github.com/bank-vaults/secrets-webhook), you are probably aware of it only injecting secrets when the pods are created/recreated, and until the release of this tool, there were no solution within the Bank-Vaults ecosystem to inject secrets into these workloads in a continuous manner. Vault Secrets Reloader offers Secrets Webhook users an automated solution for this problem. 4 | 5 | This is a high level overview of how the Reloader plays along with other components of the Bank-Vaults ecosystem: 6 | 7 | ![flowchart](./assets/flowchart.png) 8 | 9 | 1. The `collector` worker periodically collects unversioned secrets from workloads with the `secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true"` annotation that are in the format for injection by the Webhook. 10 | 11 | 2. At its scheduled time, the `reloader` worker checks in Vault if there is a new version of any of the collected secrets since the last sync. If it is the case, it continues to step 3, otherwise the workflow stops. 12 | 13 | 3. The `reloader` changes another Reloader-specific annotation in the affected workload's podspec, triggering a new rollout. 14 | 15 | 4. The pods are recreated and the Webhook can do its job again by injecting the latest version of the secrets from Vault into the pod's containers. 16 | -------------------------------------------------------------------------------- /.licensei.toml: -------------------------------------------------------------------------------- 1 | approved = [ 2 | "mit", 3 | "apache-2.0", 4 | "bsd-3-clause", 5 | "bsd-2-clause", 6 | "mpl-2.0", 7 | "isc" 8 | ] 9 | 10 | ignored = [ 11 | "github.com/ghodss/yaml", # MIT 12 | "sigs.k8s.io/yaml", # Forked from above 13 | "github.com/gogo/protobuf", # 3-Clause BSD 14 | "logur.dev/adapter/logrus", # MIT 15 | "logur.dev/logur", # MIT 16 | "github.com/hashicorp/vault/api", # Mozilla Public License 2.0 17 | "github.com/hashicorp/vault/api/auth/aws", # Mozilla Public License 2.0 18 | "github.com/hashicorp/vault/api/auth/azure", # Mozilla Public License 2.0 19 | "github.com/hashicorp/vault/api/auth/gcp", # Mozilla Public License 2.0 20 | "github.com/hashicorp/vault/api/auth/kubernetes", # Mozilla Public License 2.0 21 | 22 | # Unsupported VCS 23 | "google.golang.org/protobuf", 24 | ] 25 | 26 | [header] 27 | authors = ["Cisco", "Bank-Vaults Maintainers"] 28 | ignorePaths = ["vendor"] 29 | ignoreFiles = ["zz_generated.*.go"] 30 | template = """// Copyright © :YEAR: :AUTHOR: 31 | // 32 | // Licensed under the Apache License, Version 2.0 (the "License"); 33 | // you may not use this file except in compliance with the License. 34 | // You may obtain a copy of the License at 35 | // 36 | // http://www.apache.org/licenses/LICENSE-2.0 37 | // 38 | // Unless required by applicable law or agreed to in writing, software 39 | // distributed under the License is distributed on an "AS IS" BASIS, 40 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 41 | // See the License for the specific language governing permissions and 42 | // limitations under the License.""" 43 | -------------------------------------------------------------------------------- /pkg/reloader/client_logger.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Bank-Vaults Maintainers 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "log/slog" 19 | 20 | "github.com/bank-vaults/vault-sdk/vault" 21 | ) 22 | 23 | var _ vault.Logger = &clientLogger{} 24 | 25 | type clientLogger struct { 26 | logger *slog.Logger 27 | } 28 | 29 | func (l clientLogger) Trace(msg string, args ...map[string]interface{}) { 30 | l.Debug(msg, args...) 31 | } 32 | 33 | func (l clientLogger) Debug(msg string, args ...map[string]interface{}) { 34 | l.logger.Debug(msg, l.argsToAttrs(args...)...) 35 | } 36 | 37 | func (l clientLogger) Info(msg string, args ...map[string]interface{}) { 38 | l.logger.Info(msg, l.argsToAttrs(args...)...) 39 | } 40 | 41 | func (l clientLogger) Warn(msg string, args ...map[string]interface{}) { 42 | l.logger.Warn(msg, l.argsToAttrs(args...)...) 43 | } 44 | 45 | func (l clientLogger) Error(msg string, args ...map[string]interface{}) { 46 | l.logger.Error(msg, l.argsToAttrs(args...)...) 47 | } 48 | 49 | func (clientLogger) argsToAttrs(args ...map[string]interface{}) []any { 50 | var attrs []any 51 | 52 | for _, arg := range args { 53 | for key, value := range arg { 54 | attrs = append(attrs, slog.Any(key, value)) 55 | } 56 | } 57 | 58 | return attrs 59 | } 60 | -------------------------------------------------------------------------------- /e2e/deploy/workloads/statefulset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | name: reloader-test-statefulset 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: reloader-test-statefulset 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: reloader-test-statefulset 14 | annotations: 15 | secrets-webhook.security.bank-vaults.io/provider: "vault" 16 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 17 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 18 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 19 | spec: 20 | initContainers: 21 | - name: init-ubuntu 22 | image: ubuntu 23 | command: ["sh", "-c", "echo $MYSQL_PASSWORD && echo $DOCKER_REPO_PASSWORD && echo initContainers ready"] 24 | env: 25 | - name: MYSQL_PASSWORD 26 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 27 | - name: DOCKER_REPO_PASSWORD 28 | value: vault:secret/data/dockerrepo#${.DOCKER_REPO_PASSWORD} 29 | resources: 30 | limits: 31 | memory: "128Mi" 32 | cpu: "100m" 33 | containers: 34 | - name: alpine 35 | image: alpine 36 | command: 37 | - "sh" 38 | - "-c" 39 | - "echo $MYSQL_PASSWORD && echo $DOCKER_REPO_PASSWORD && echo going to sleep... && sleep 10000" 40 | env: 41 | - name: MYSQL_PASSWORD 42 | value: vault:secret/data/mysql#MYSQL_PASSWORD 43 | - name: DOCKER_REPO_PASSWORD 44 | value: vault:secret/data/dockerrepo#DOCKER_REPO_PASSWORD 45 | resources: 46 | limits: 47 | memory: "128Mi" 48 | cpu: "100m" 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🎉 Feature request 2 | description: Suggest an idea for the Vault Secrets Reloader 3 | labels: [kind/enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a feature request! 9 | 10 | Please describe what you would like to change/add and why in detail by filling out the template below. 11 | 12 | If you are not sure if your request fits into the Vault Secrets Reloader, you can contact us via the available [support channels](https://bank-vaults.dev/docs/support). 13 | - type: checkboxes 14 | attributes: 15 | label: Preflight Checklist 16 | description: Please ensure you've completed all of the following. 17 | options: 18 | - label: I have searched the [issue tracker](https://www.github.com/bank-vaults/vault-secrets-reloader/issues) for an issue that matches the one I want to file, without success. 19 | required: true 20 | - label: I agree to follow the [Code of Conduct](https://bank-vaults.dev/docs/code-of-conduct). 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Problem Description 25 | description: A clear and concise description of the problem you are seeking to solve with this feature request. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Proposed Solution 31 | description: A clear and concise description of what would you like to happen. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Alternatives Considered 37 | description: A clear and concise description of any alternative solutions or features you've considered. 38 | - type: textarea 39 | attributes: 40 | label: Additional Information 41 | description: Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /pkg/reloader/reloader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/assert" 21 | corev1 "k8s.io/api/core/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | ) 24 | 25 | func TestIncrementReloadCountAnnotation(t *testing.T) { 26 | tests := []struct { 27 | name string 28 | annotations map[string]string 29 | expectedAnnotations map[string]string 30 | }{ 31 | { 32 | name: "no annotation should add annotation", 33 | annotations: map[string]string{}, 34 | expectedAnnotations: map[string]string{ 35 | ReloadCountAnnotationName: "1", 36 | }, 37 | }, 38 | { 39 | name: "existing annotation should increment annotation", 40 | annotations: map[string]string{ 41 | ReloadCountAnnotationName: "1", 42 | }, 43 | expectedAnnotations: map[string]string{ 44 | ReloadCountAnnotationName: "2", 45 | }, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | ttp := tt 51 | t.Run(ttp.name, func(t *testing.T) { 52 | podTemplateSpec := &corev1.PodTemplateSpec{ 53 | ObjectMeta: metav1.ObjectMeta{ 54 | Annotations: ttp.annotations, 55 | }, 56 | } 57 | 58 | incrementReloadCountAnnotation(podTemplateSpec) 59 | 60 | assert.Equal(t, ttp.expectedAnnotations, podTemplateSpec.Annotations) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /e2e/deploy/workloads/daemonset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: DaemonSet 3 | metadata: 4 | name: reloader-test-daemonset 5 | spec: 6 | # replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: reloader-test-daemonset 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: reloader-test-daemonset 14 | annotations: 15 | secrets-webhook.security.bank-vaults.io/provider: "vault" 16 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 17 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 18 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 19 | spec: 20 | initContainers: 21 | - name: init-ubuntu 22 | image: ubuntu 23 | command: 24 | - "sh" 25 | - "-c" 26 | - "echo $AWS_SECRET_ACCESS_KEY && echo $DOCKER_REPO_PASSWORD && echo initContainers ready" 27 | env: 28 | - name: AWS_SECRET_ACCESS_KEY 29 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 30 | - name: DOCKER_REPO_PASSWORD 31 | value: vault:secret/data/dockerrepo#${.DOCKER_REPO_PASSWORD} 32 | resources: 33 | limits: 34 | memory: "128Mi" 35 | cpu: "100m" 36 | containers: 37 | - name: alpine 38 | image: alpine 39 | command: 40 | - "sh" 41 | - "-c" 42 | - "echo $AWS_SECRET_ACCESS_KEY && echo $DOCKER_REPO_PASSWORD && echo going to sleep... && sleep 10000" 43 | env: 44 | - name: AWS_SECRET_ACCESS_KEY 45 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 46 | - name: DOCKER_REPO_PASSWORD 47 | value: vault:secret/data/dockerrepo#${.DOCKER_REPO_PASSWORD} 48 | resources: 49 | limits: 50 | memory: "128Mi" 51 | cpu: "100m" 52 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "vault-secrets-reloader.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "vault-secrets-reloader.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "vault-secrets-reloader.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "vault-secrets-reloader.labels" -}} 37 | helm.sh/chart: {{ include "vault-secrets-reloader.chart" . }} 38 | {{ include "vault-secrets-reloader.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "vault-secrets-reloader.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "vault-secrets-reloader.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "vault-secrets-reloader.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "vault-secrets-reloader.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "vault-secrets-reloader.fullname" . -}} 3 | {{- $svcPort := .Values.service.externalPort -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /examples/assets/flowchart.d2: -------------------------------------------------------------------------------- 1 | direction: right 2 | 3 | Cluster: "" { 4 | icon: https://icons.terrastruct.com/azure%2F_Companies%2FKubernetes.svg 5 | 6 | style: { 7 | border-radius: 15 8 | } 9 | 10 | NamespaceA: namespace { 11 | direction: right 12 | 13 | style: { 14 | opacity: 0.7 15 | border-radius: 15 16 | fill: white 17 | stroke: white 18 | } 19 | 20 | Vault: Vault { 21 | shape: image 22 | icon: https://www.svgrepo.com/download/448679/vault.svg 23 | } 24 | 25 | BVOperator: Vault Operator { 26 | shape: hexagon 27 | icon: https://raw.githubusercontent.com/bank-vaults/bank-vaults.dev/main/assets/icons/logo.svg 28 | } 29 | 30 | BVOperator -> Vault 31 | } 32 | 33 | NamespaceB: namespace { 34 | style: { 35 | opacity: 0.7 36 | border-radius: 15 37 | fill: white 38 | stroke: white 39 | } 40 | 41 | BVWebhook: Secrets Webhook { 42 | shape: parallelogram 43 | icon: https://raw.githubusercontent.com/bank-vaults/bank-vaults.dev/main/assets/icons/logo.svg 44 | } 45 | 46 | BVReloader: Vault Secrets Reloader { 47 | shape: hexagon 48 | icon: https://raw.githubusercontent.com/bank-vaults/bank-vaults.dev/main/assets/icons/logo.svg 49 | } 50 | } 51 | 52 | NamespaceC: namespace { 53 | direction: right 54 | 55 | style: { 56 | opacity: 0.7 57 | border-radius: 15 58 | fill: white 59 | stroke: white 60 | } 61 | 62 | Workload: Workload\n(with Reloader-specific annotation) { 63 | style: { 64 | multiple: true 65 | stroke: green 66 | } 67 | } 68 | 69 | Pod: Pod { 70 | style: { 71 | multiple: true 72 | stroke: green 73 | } 74 | } 75 | 76 | Workload -> Pod: 3 { 77 | style: { 78 | stroke: green 79 | stroke-dash: 5 80 | font-size: 24 81 | font-color: black 82 | } 83 | } 84 | } 85 | 86 | NamespaceB.BVReloader <-> NamespaceC.Workload: 1 { 87 | style: { 88 | font-size: 24 89 | font-color: black 90 | } 91 | } 92 | 93 | NamespaceB.BVReloader <-> NamespaceA.Vault: 2 { 94 | style: { 95 | font-size: 24 96 | font-color: black 97 | } 98 | } 99 | 100 | NamespaceB.BVReloader -> NamespaceC.Workload: 3 { 101 | style: { 102 | font-size: 24 103 | font-color: black 104 | } 105 | } 106 | 107 | NamespaceB.BVWebhook <-> NamespaceA.Vault: 4 { 108 | style: { 109 | font-size: 24 110 | font-color: black 111 | } 112 | } 113 | 114 | NamespaceB.BVWebhook -> NamespaceC.Pod: 4 { 115 | style: { 116 | font-size: 24 117 | font-color: black 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "vault-secrets-reloader.fullname" . }} 5 | labels: 6 | {{- include "vault-secrets-reloader.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: 1 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "vault-secrets-reloader.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "vault-secrets-reloader.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.image.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "vault-secrets-reloader.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | {{- if .Values.volumes }} 31 | {{ toYaml .Values.volumes | indent 8 }} 32 | {{- end }} 33 | containers: 34 | - name: {{ .Chart.Name }} 35 | securityContext: 36 | {{- toYaml .Values.securityContext | nindent 12 }} 37 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 38 | imagePullPolicy: {{ .Values.image.pullPolicy }} 39 | command: 40 | - vault-secrets-reloader 41 | - -log-level 42 | - {{ .Values.logLevel }} 43 | {{- if .Values.enableJSONLog }} 44 | - -enable-json-log 45 | {{- end }} 46 | - -collector-sync-period 47 | - {{ .Values.collectorSyncPeriod }} 48 | - -reloader-run-period 49 | - {{ .Values.reloaderRunPeriod }} 50 | env: 51 | - name: LISTEN_ADDRESS 52 | value: ":{{ .Values.service.internalPort }}" 53 | {{- range $key, $value := .Values.env }} 54 | - name: {{ $key }} 55 | value: {{ $value | quote }} 56 | {{- end }} 57 | ports: 58 | - name: http 59 | containerPort: {{ .Values.service.internalPort }} 60 | protocol: TCP 61 | livenessProbe: 62 | httpGet: 63 | path: / 64 | port: {{ .Values.service.internalPort }} 65 | readinessProbe: 66 | httpGet: 67 | path: / 68 | port: {{ .Values.service.internalPort }} 69 | {{- if .Values.volumeMounts }} 70 | {{ toYaml .Values.volumeMounts | indent 12 }} 71 | {{- end }} 72 | resources: 73 | {{- toYaml .Values.resources | nindent 12 }} 74 | {{- with .Values.nodeSelector }} 75 | nodeSelector: 76 | {{- toYaml . | nindent 8 }} 77 | {{- end }} 78 | {{- with .Values.affinity }} 79 | affinity: 80 | {{- toYaml . | nindent 8 }} 81 | {{- end }} 82 | {{- with .Values.tolerations }} 83 | tolerations: 84 | {{- toYaml . | nindent 8 }} 85 | {{- end }} 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Report a bug to help us improve the Vault Secrets Reloader 3 | labels: [kind/bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a bug report! 9 | 10 | Please fill out the template below to make it easier to debug your problem. 11 | 12 | If you are not sure if it is a bug or not, you can contact us via the available [support channels](https://bank-vaults.dev/docs/support). 13 | - type: checkboxes 14 | attributes: 15 | label: Preflight Checklist 16 | description: Please ensure you've completed all of the following. 17 | options: 18 | - label: I have searched the [issue tracker](https://www.github.com/bank-vaults/vault-secrets-reloader/issues) for an issue that matches the one I want to file, without success. 19 | required: true 20 | - label: I am not looking for support or already pursued the available [support channels](https://bank-vaults.dev/docs/support) without success. 21 | required: true 22 | - label: I agree to follow the [Code of Conduct](https://bank-vaults.dev/docs/code-of-conduct). 23 | required: true 24 | - type: input 25 | attributes: 26 | label: Version 27 | description: What version of the Vault Secrets Reloader are you using? 28 | placeholder: 0.2.0 29 | validations: 30 | required: true 31 | - type: input 32 | attributes: 33 | label: Secrets Webhook Version 34 | description: What version of the Secrets Webhook are you using? 35 | placeholder: 0.1.0 36 | validations: 37 | required: true 38 | - type: input 39 | attributes: 40 | label: Kubernetes Version 41 | description: What version of Kubernetes are you using? 42 | placeholder: 1.27.0 43 | validations: 44 | required: true 45 | - type: input 46 | attributes: 47 | label: Kubernetes Distribution/Provisioner 48 | description: Which Kubernetes distribution/privisioner are you using? 49 | placeholder: e.g. GKE, EKS, AKS etc 50 | validations: 51 | required: true 52 | - type: textarea 53 | attributes: 54 | label: Expected Behavior 55 | description: A clear and concise description of what you expected to happen. 56 | validations: 57 | required: true 58 | - type: textarea 59 | attributes: 60 | label: Actual Behavior 61 | description: A clear description of what actually happens. 62 | validations: 63 | required: true 64 | - type: textarea 65 | attributes: 66 | label: Steps To Reproduce 67 | description: Steps to reproduce the behavior if it is not self-explanatory. 68 | placeholder: | 69 | 1. In this environment... 70 | 2. With this config... 71 | 3. Run '...' 72 | 4. See error... 73 | - type: textarea 74 | attributes: 75 | label: Logs 76 | description: Vault Secrets Reloader logs (if relevant). 77 | render: shell 78 | - type: textarea 79 | attributes: 80 | label: Additional Information 81 | description: Links? References? Anything that will give us more context about the issue that you are encountering! 82 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | {{ template "chart.header" . }} 2 | 3 | This chart will install Vault Secrets Reloader Controller, that reloads workloads on a referenced secret change in HashiCorp Vault. 4 | 5 | Reloader will collect (unversioned) secrets injected by the Webhook from watched workloads, periodically checks if their version has been updated in Vault and if so, "reloads" the workload with an annotation update, triggering a new rollout so the Webhook can inject the new version of the secret into the pod. 6 | 7 | ## Before you start 8 | 9 | Reloader works in conjunction with the [Secrets Webhook](https://github.com/bank-vaults/secrets-webhook), therefore the prerequisites to start using it would be a Hashicorp Vault instance, and a working Webhook. 10 | 11 | You will need to add the following annotations to the pod template spec of the workloads (i.e. Deployments, DaemonSets and StatefulSets) that you wish to reload: 12 | 13 | ```yaml 14 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 15 | ``` 16 | 17 | ## Installing the Chart 18 | 19 | **Prepare Kubernetes namespace** 20 | 21 | You can prepare a separate namespace for Vault Secrets Reloader beforehand, create it automatically if not yet exist with appending `--create-namespace` to the installation Helm command, or just use the one already created for Secrets Webhook. 22 | 23 | **Install the chart** 24 | 25 | 1. Save Reloader default chart values: 26 | 27 | ```shell 28 | helm show values oci://ghcr.io/bank-vaults/helm-charts/vault-secrets-reloader > values.yaml 29 | ``` 30 | 31 | 2. Check the configuration in `values.yaml` and update the required values if needed. Configure the time for periodic runs of the `collector` and `reloader` workers with a value in Go Duration format: 32 | 33 | ```yaml 34 | collectorSyncPeriod: 30m 35 | reloaderRunPeriod: 1h 36 | ``` 37 | 38 | Additionally, Reloader needs to be supplied with Vault credentials to be able to connect to Vault in order to get the secrets. You can check the list of environmental variables accepted for creating a Vault client [here](https://developer.hashicorp.com/vault/docs/commands#environment-variables). For example: 39 | 40 | ```yaml 41 | env: 42 | # define env vars for Vault used for authentication 43 | VAULT_ROLE: "reloader" 44 | VAULT_ADDR: "https://vault.default.svc.cluster.local:8200" 45 | VAULT_NAMESPACE: "default" 46 | VAULT_TLS_SECRET: "vault-tls" 47 | VAULT_TLS_SECRET_NS: "bank-vaults-infra" 48 | ``` 49 | 50 | 3. Install the chart: 51 | 52 | ```shell 53 | helm upgrade --install --values values.yaml vault-secrets-reloader oci://ghcr.io/bank-vaults/helm-charts/vault-secrets-reloader --namespace bank-vaults-infra --create-namespace 54 | ``` 55 | 56 | {{ define "chart.valuesTableHtml" }} 57 | 58 | The following table lists the configurable parameters of the Helm chart. 59 | 60 | | Parameter | Type | Default | Description | 61 | | --- | ---- | ------- | ----------- | 62 | {{- range .Values }} 63 | | `{{ .Key }}` | {{ .Type }} | {{ .Default }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | 64 | {{- end }} 65 | 66 | Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. 67 | 68 | {{ end }} 69 | 70 | {{ template "chart.valuesSectionHtml" . }} 71 | 72 | ### Vault settings 73 | 74 | Make sure to add the `read` and `list` capabilities for secrets to the Vault auth role the Reloader will use. An example can be found in the [example Bank-Vaults Operator CR file](https://github.com/bank-vaults/vault-secrets-reloader/blob/main/e2e/deploy/vault/vault.yaml#L102). 75 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for vault-secrets-reloader. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | # -- Log level 6 | logLevel: info 7 | # -- Use JSON log format instead of text 8 | enableJSONLog: false 9 | 10 | image: 11 | # -- Container image repo that contains the Reloader Controller 12 | repository: ghcr.io/bank-vaults/vault-secrets-reloader 13 | # Overrides the image tag whose default is the chart appVersion. 14 | # -- Container image tag 15 | tag: "" 16 | # -- Container image pull policy 17 | pullPolicy: IfNotPresent 18 | # -- Container image pull secrets for private repositories 19 | imagePullSecrets: [] 20 | 21 | # -- Override app name 22 | nameOverride: "" 23 | # -- Override app full name 24 | fullnameOverride: "" 25 | 26 | # -- Time interval for the collector worker to run in Go Duration format 27 | collectorSyncPeriod: 30m 28 | # -- Time interval for the reloader worker to run in Go Duration format 29 | reloaderRunPeriod: 1h 30 | 31 | serviceAccount: 32 | # -- Specifies whether a service account should be created 33 | create: true 34 | # -- Annotations to add to the service account 35 | annotations: {} 36 | # -- The name of the service account to use. 37 | # If not set and create is true, a name is generated using the fullname template 38 | name: "" 39 | 40 | # -- Extra annotations to add to pod metadata 41 | podAnnotations: {} 42 | 43 | # -- Pod security context for Reloader deployment 44 | podSecurityContext: {} 45 | # fsGroup: 2000 46 | 47 | # -- Pod security context for Reloader containers 48 | securityContext: {} 49 | # capabilities: 50 | # drop: 51 | # - ALL 52 | # readOnlyRootFilesystem: true 53 | # runAsNonRoot: true 54 | # runAsUser: 1000 55 | 56 | service: 57 | # -- Reloader service name 58 | name: vault-secrets-reloader 59 | # -- Reloader service type 60 | type: ClusterIP 61 | # -- Reloader service external port 62 | externalPort: 443 63 | # -- Reloader service internal port 64 | internalPort: 8443 65 | # -- Reloader service annotations, e.g. if type is AWS LoadBalancer and you want to add security groups 66 | annotations: {} 67 | 68 | ingress: 69 | # -- Enable Reloader ingress 70 | enabled: false 71 | # -- Reloader IngressClass name 72 | className: "" 73 | # -- Reloader ingress annotations 74 | annotations: {} 75 | # kubernetes.io/ingress.class: nginx 76 | # kubernetes.io/tls-acme: "true" 77 | # -- Reloader ingress hosts 78 | hosts: [] 79 | # - host: chart-example.local 80 | # paths: 81 | # - path: / 82 | # pathType: ImplementationSpecific 83 | # -- Reloader ingress tls 84 | tls: [] 85 | # - secretName: chart-example-tls 86 | # hosts: 87 | # - chart-example.local 88 | 89 | # -- Environment variables e.g. for Vault authentication 90 | env: {} 91 | # VAULT_ROLE: "reloader" 92 | # VAULT_ADDR: "https://vault.default.svc.cluster.local:8200" 93 | # VAULT_NAMESPACE: "default" 94 | # VAULT_TLS_SECRET: "vault-tls" 95 | # VAULT_TLS_SECRET_NS: "bank-vaults-infra" 96 | # VAULT_SKIP_VERIFY: "false" 97 | # VAULT_AUTH_METHOD: "kubernetes" 98 | # VAULT_PATH: "kubernetes" 99 | # VAULT_CLIENT_TIMEOUT: "10s" 100 | # VAULT_IGNORE_MISSING_SECRETS: "false" 101 | 102 | # -- Extra volume definitions for Reloader deployment 103 | volumes: [] 104 | # - name: vault-tls 105 | # secret: 106 | # secretName: vault-tls 107 | 108 | # -- Extra volume mounts for Reloader deployment 109 | volumeMounts: [] 110 | # - name: vault-tls 111 | # mountPath: /vault/tls 112 | 113 | # -- Resources to request for the deployment and pods 114 | resources: {} 115 | # cpu: 100m 116 | # memory: 128Mi 117 | # requests: 118 | # cpu: 100m 119 | # memory: 128Mi 120 | 121 | autoscaling: 122 | # -- Enable Reloader horizontal pod autoscaling 123 | enabled: false 124 | # -- Minimum number of replicas 125 | minReplicas: 1 126 | # -- Maximum number of replicas 127 | maxReplicas: 100 128 | # targetCPUUtilizationPercentage: 80 129 | # targetMemoryUtilizationPercentage: 80 130 | 131 | # -- Node labels for pod assignment. 132 | # Check: 133 | nodeSelector: {} 134 | 135 | # -- List of node tolerations for the pods. 136 | # Check: 137 | tolerations: [] 138 | 139 | # -- Node affinity settings for the pods. 140 | # Check: 141 | affinity: {} 142 | -------------------------------------------------------------------------------- /pkg/reloader/collector_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/assert" 21 | corev1 "k8s.io/api/core/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | ) 24 | 25 | func TestWorkloadSecretsStore(t *testing.T) { 26 | store := newWorkloadSecrets() 27 | workload1 := workload{ 28 | name: "test", 29 | namespace: "default", 30 | kind: "Deployment", 31 | } 32 | workload2 := workload{ 33 | name: "test2", 34 | namespace: "default", 35 | kind: "DaemonSet", 36 | } 37 | 38 | // add workload secrets 39 | store.Store(workload1, []string{"secret/data/accounts/aws", "secret/data/mysql"}) 40 | store.Store(workload2, []string{"secret/data/accounts/aws", "secret/data/docker"}) 41 | 42 | // check if workload secrets are stored 43 | t.Run("GetWorkloadSecretsMap", func(t *testing.T) { 44 | assert.Equal(t, 45 | map[workload][]string{ 46 | workload1: {"secret/data/accounts/aws", "secret/data/mysql"}, 47 | workload2: {"secret/data/accounts/aws", "secret/data/docker"}, 48 | }, 49 | store.GetWorkloadSecretsMap(), 50 | ) 51 | }) 52 | 53 | t.Run("GetSecretWorkloadsMap", func(t *testing.T) { 54 | // check secret to workloads map creation 55 | secretWorkloadsMap := store.GetSecretWorkloadsMap() 56 | // comparing slices as order is not guaranteed 57 | assert.ElementsMatch(t, secretWorkloadsMap["secret/data/accounts/aws"], []workload{workload1, workload2}) 58 | assert.ElementsMatch(t, secretWorkloadsMap["secret/data/mysql"], []workload{workload1}) 59 | assert.ElementsMatch(t, secretWorkloadsMap["secret/data/docker"], []workload{workload2}) 60 | }) 61 | 62 | t.Run("delete from workloadSecrets map", func(t *testing.T) { 63 | // check workload secret deleting 64 | store.Delete(workload1) 65 | assert.Equal(t, map[workload][]string{ 66 | workload2: {"secret/data/accounts/aws", "secret/data/docker"}, 67 | }, store.GetWorkloadSecretsMap()) 68 | }) 69 | } 70 | 71 | func TestCollectSecrets(t *testing.T) { 72 | template := corev1.PodTemplateSpec{ 73 | ObjectMeta: metav1.ObjectMeta{ 74 | Annotations: map[string]string{ 75 | "secrets-webhook.security.bank-vaults.io/vault-from-path": "secret/data/foo,secret/data/bar#1", 76 | }, 77 | }, 78 | Spec: corev1.PodSpec{ 79 | InitContainers: []corev1.Container{ 80 | { 81 | Name: "container1", 82 | Env: []corev1.EnvVar{ 83 | // this should be ignored 84 | { 85 | Name: "ENV1", 86 | Value: "value1", 87 | }, 88 | // this should be present in the result only once 89 | { 90 | Name: "AWS_SECRET_ACCESS_KEY", 91 | Value: "vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY", 92 | }, 93 | // this should be present in the result 94 | { 95 | Name: "MYSQL_PASSWORD", 96 | Value: "vault:secret/data/mysql#${.MYSQL_PASSWORD}", 97 | }, 98 | }, 99 | }, 100 | }, 101 | Containers: []corev1.Container{ 102 | { 103 | Name: "container2", 104 | Env: []corev1.EnvVar{ 105 | // this should be ignored (no prefix) 106 | { 107 | Name: "GCP_SECRET", 108 | Value: "secret/data/accounts/gcp#GCP_SECRET", 109 | }, 110 | // this should be ignored (no secret value) 111 | { 112 | Name: "AZURE_SECRET", 113 | Value: "vault:secret/data/accounts/azure", 114 | }, 115 | // this should be present in the result only once 116 | { 117 | Name: "AWS_SECRET_ACCESS_KEY", 118 | Value: "vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY", 119 | }, 120 | // this should be ignored, as it is versioned 121 | { 122 | Name: "DOCKER_REPO_PASSWORD", 123 | Value: "vault:secret/data/dockerrepo#${.DOCKER_REPO_PASSWORD}#1", 124 | }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | } 130 | 131 | assert.Equal(t, []string{"secret/data/accounts/aws", "secret/data/foo", "secret/data/mysql"}, collectSecrets(template)) 132 | } 133 | -------------------------------------------------------------------------------- /pkg/reloader/vault_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "encoding/json" 19 | "os" 20 | "testing" 21 | "time" 22 | 23 | vaultapi "github.com/hashicorp/vault/api" 24 | "github.com/stretchr/testify/assert" 25 | ) 26 | 27 | func TestGetVaultConfigFromEnv(t *testing.T) { 28 | t.Run("default config", func(t *testing.T) { 29 | if err := os.Unsetenv("VAULT_ADDR"); err != nil { 30 | t.Fatalf("failed to unset VAULT_ADDR: %v", err) 31 | } 32 | defaults := VaultConfig{ 33 | Addr: "https://vault:8200", 34 | AuthMethod: "jwt", 35 | Role: "", 36 | Path: "kubernetes", 37 | Namespace: "default", 38 | SkipVerify: false, 39 | TLSSecret: "", 40 | TLSSecretNS: "default", 41 | ClientTimeout: 10 * time.Second, 42 | IgnoreMissingSecrets: false, 43 | } 44 | 45 | vaultConfig := getVaultConfigFromEnv() 46 | assert.Equal(t, defaults, *vaultConfig) 47 | }) 48 | 49 | t.Run("custom config", func(t *testing.T) { 50 | if err := os.Setenv("VAULT_ADDR", "http://127.0.0.1:8200"); err != nil { 51 | t.Fatalf("failed to set VAULT_ADDR: %v", err) 52 | } 53 | if err := os.Setenv("VAULT_AUTH_METHOD", "kubernetes"); err != nil { 54 | t.Fatalf("failed to set VAULT_AUTH_METHOD: %v", err) 55 | } 56 | if err := os.Setenv("VAULT_ROLE", "test"); err != nil { 57 | t.Fatalf("failed to set VAULT_ROLE: %v", err) 58 | } 59 | if err := os.Setenv("VAULT_PATH", "test"); err != nil { 60 | t.Fatalf("failed to set VAULT_PATH: %v", err) 61 | } 62 | if err := os.Setenv("VAULT_NAMESPACE", "test"); err != nil { 63 | t.Fatalf("failed to set VAULT_NAMESPACE: %v", err) 64 | } 65 | if err := os.Setenv("VAULT_SKIP_VERIFY", "true"); err != nil { 66 | t.Fatalf("failed to set VAULT_SKIP_VERIFY: %v", err) 67 | } 68 | if err := os.Setenv("VAULT_TLS_SECRET", "test"); err != nil { 69 | t.Fatalf("failed to set VAULT_TLS_SECRET: %v", err) 70 | } 71 | if err := os.Setenv("VAULT_TLS_SECRET_NS", "test"); err != nil { 72 | t.Fatalf("failed to set VAULT_TLS_SECRET_NS: %v", err) 73 | } 74 | if err := os.Setenv("VAULT_CLIENT_TIMEOUT", "1m"); err != nil { 75 | t.Fatalf("failed to set VAULT_CLIENT_TIMEOUT: %v", err) 76 | } 77 | if err := os.Setenv("VAULT_IGNORE_MISSING_SECRETS", "true"); err != nil { 78 | t.Fatalf("failed to set VAULT_IGNORE_MISSING_SECRETS: %v", err) 79 | } 80 | defaults := VaultConfig{ 81 | Addr: "http://127.0.0.1:8200", 82 | AuthMethod: "kubernetes", 83 | Role: "test", 84 | Path: "test", 85 | Namespace: "test", 86 | SkipVerify: true, 87 | TLSSecret: "test", 88 | TLSSecretNS: "test", 89 | ClientTimeout: 1 * time.Minute, 90 | IgnoreMissingSecrets: true, 91 | } 92 | 93 | vaultConfig := getVaultConfigFromEnv() 94 | assert.Equal(t, defaults, *vaultConfig) 95 | }) 96 | } 97 | 98 | type vaultClientMock struct { 99 | err error 100 | vaultSecret *vaultapi.Secret 101 | } 102 | 103 | func (c *vaultClientMock) Read(path string) (*vaultapi.Secret, error) { 104 | _ = path 105 | return c.vaultSecret, c.err 106 | } 107 | 108 | func TestGetSecretVersionFromVault(t *testing.T) { 109 | t.Run("secret not found", func(t *testing.T) { 110 | vaultClient := &vaultClientMock{ 111 | err: ErrSecretNotFound{}, 112 | } 113 | 114 | _, err := getSecretVersionFromVault(vaultClient, "test") 115 | assert.Equal(t, ErrSecretNotFound{}, err) 116 | }) 117 | 118 | t.Run("other error", func(t *testing.T) { 119 | vaultClient := &vaultClientMock{ 120 | err: assert.AnError, 121 | } 122 | 123 | _, err := getSecretVersionFromVault(vaultClient, "test") 124 | assert.Equal(t, assert.AnError, err) 125 | }) 126 | 127 | t.Run("success", func(t *testing.T) { 128 | vaultClient := &vaultClientMock{ 129 | vaultSecret: &vaultapi.Secret{ 130 | Data: map[string]interface{}{ 131 | "metadata": map[string]interface{}{ 132 | "version": json.Number("3"), 133 | }, 134 | }, 135 | }, 136 | } 137 | 138 | version, err := getSecretVersionFromVault(vaultClient, "test") 139 | assert.NoError(t, err) 140 | assert.Equal(t, 3, version) 141 | }) 142 | } 143 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "flag" 20 | "fmt" 21 | "log/slog" 22 | "net/http" 23 | "os" 24 | "slices" 25 | "time" 26 | 27 | slogmulti "github.com/samber/slog-multi" 28 | kubeinformers "k8s.io/client-go/informers" 29 | "k8s.io/client-go/kubernetes" 30 | "sigs.k8s.io/controller-runtime/pkg/client/config" 31 | "sigs.k8s.io/controller-runtime/pkg/manager/signals" 32 | 33 | "github.com/bank-vaults/vault-secrets-reloader/pkg/reloader" 34 | ) 35 | 36 | const ( 37 | defaultSyncPeriod = 30 * time.Second 38 | defaultReloaderRunPeriod = 60 * time.Second 39 | ) 40 | 41 | func main() { 42 | // Register CLI flags 43 | collectorSyncPeriod := flag.Duration("collector-sync-period", defaultSyncPeriod, 44 | "Determines the minimum frequency at which watched resources are reconciled") 45 | reloaderRunPeriod := flag.Duration("reloader-run-period", defaultReloaderRunPeriod, 46 | "Determines the minimum frequency at which watched resources are reloaded") 47 | logLevel := flag.String("log-level", "info", "Log level (debug, info, warn, error).") 48 | enableJSONLog := flag.Bool("enable-json-log", false, "Enable JSON logging") 49 | flag.Parse() 50 | 51 | // Set up signals so we handle the shutdown signal gracefully 52 | ctx := signals.SetupSignalHandler() 53 | 54 | // Setup logger 55 | var logger *slog.Logger 56 | { 57 | var level slog.Level 58 | 59 | err := level.UnmarshalText([]byte(*logLevel)) 60 | if err != nil { // Silently fall back to info level 61 | level = slog.LevelInfo 62 | } 63 | 64 | levelFilter := func(levels ...slog.Level) func(ctx context.Context, r slog.Record) bool { 65 | return func(_ context.Context, r slog.Record) bool { 66 | return slices.Contains(levels, r.Level) 67 | } 68 | } 69 | 70 | router := slogmulti.Router() 71 | 72 | if *enableJSONLog { 73 | // Send logs with level higher than warning to stderr 74 | router = router.Add( 75 | slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}), 76 | levelFilter(slog.LevelWarn, slog.LevelError), 77 | ) 78 | 79 | // Send info and debug logs to stdout 80 | router = router.Add( 81 | slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}), 82 | levelFilter(slog.LevelDebug, slog.LevelInfo), 83 | ) 84 | } else { 85 | // Send logs with level higher than warning to stderr 86 | router = router.Add( 87 | slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}), 88 | levelFilter(slog.LevelWarn, slog.LevelError), 89 | ) 90 | 91 | // Send info and debug logs to stdout 92 | router = router.Add( 93 | slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}), 94 | levelFilter(slog.LevelDebug, slog.LevelInfo), 95 | ) 96 | } 97 | 98 | // TODO: add level filter handler 99 | logger = slog.New(router.Handler()) 100 | logger = logger.With(slog.String("app", "vault-secrets-reloader")) 101 | 102 | slog.SetDefault(logger) 103 | } 104 | 105 | // Handler for health checks 106 | port := os.Getenv("LISTEN_ADDRESS") 107 | if port == "" { 108 | port = ":8080" 109 | } 110 | 111 | go func() { 112 | _ = http.ListenAndServe(port, http.HandlerFunc( 113 | func(w http.ResponseWriter, _ *http.Request) { 114 | _, _ = w.Write([]byte("ok")) 115 | }, 116 | )) 117 | }() 118 | 119 | // Create kubernetes client 120 | kubeConfig, err := config.GetConfig() 121 | if err != nil { 122 | logger.Error(fmt.Errorf("error building kubeconfig: %s", err).Error()) 123 | os.Exit(1) 124 | } 125 | 126 | kubeClient, err := kubernetes.NewForConfig(kubeConfig) 127 | if err != nil { 128 | logger.Error(fmt.Errorf("error building kubernetes clientset: %s", err).Error()) 129 | os.Exit(1) 130 | } 131 | 132 | kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, *collectorSyncPeriod) 133 | 134 | controller := reloader.NewController( 135 | logger, 136 | kubeClient, 137 | kubeInformerFactory.Apps().V1().Deployments(), 138 | kubeInformerFactory.Apps().V1().DaemonSets(), 139 | kubeInformerFactory.Apps().V1().StatefulSets(), 140 | ) 141 | 142 | kubeInformerFactory.Start(ctx.Done()) 143 | 144 | if err = controller.Run(ctx, *reloaderRunPeriod); err != nil { 145 | logger.Error(fmt.Errorf("error running controller: %s", err).Error()) 146 | os.Exit(1) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /pkg/reloader/vault.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "context" 19 | "crypto/x509" 20 | "encoding/json" 21 | "fmt" 22 | "net/http" 23 | "os" 24 | "strconv" 25 | "time" 26 | 27 | "github.com/bank-vaults/vault-sdk/vault" 28 | vaultapi "github.com/hashicorp/vault/api" 29 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 30 | ) 31 | 32 | type VaultConfig struct { 33 | Addr string 34 | AuthMethod string 35 | Role string 36 | Path string 37 | Namespace string 38 | SkipVerify bool 39 | TLSSecret string 40 | TLSSecretNS string 41 | ClientTimeout time.Duration 42 | IgnoreMissingSecrets bool 43 | } 44 | 45 | func getVaultConfigFromEnv() *VaultConfig { 46 | var vaultConfig VaultConfig 47 | 48 | vaultConfig.Addr = os.Getenv("VAULT_ADDR") 49 | if vaultConfig.Addr == "" { 50 | vaultConfig.Addr = "https://vault:8200" 51 | } 52 | 53 | vaultConfig.AuthMethod = os.Getenv("VAULT_AUTH_METHOD") 54 | if vaultConfig.AuthMethod == "" { 55 | vaultConfig.AuthMethod = "jwt" 56 | } 57 | 58 | vaultConfig.Role = os.Getenv("VAULT_ROLE") 59 | 60 | vaultConfig.Path = os.Getenv("VAULT_PATH") 61 | if vaultConfig.Path == "" { 62 | vaultConfig.Path = "kubernetes" 63 | } 64 | 65 | vaultConfig.Namespace = os.Getenv("VAULT_NAMESPACE") 66 | if vaultConfig.Namespace == "" { 67 | vaultConfig.Namespace = "default" 68 | } 69 | 70 | vaultConfig.SkipVerify, _ = strconv.ParseBool(os.Getenv("VAULT_SKIP_VERIFY")) 71 | 72 | vaultConfig.TLSSecret = os.Getenv("VAULT_TLS_SECRET") 73 | 74 | vaultConfig.TLSSecretNS = os.Getenv("VAULT_TLS_SECRET_NS") 75 | if vaultConfig.TLSSecretNS == "" { 76 | vaultConfig.TLSSecretNS = "default" 77 | } 78 | 79 | vaultConfig.ClientTimeout, _ = time.ParseDuration(os.Getenv("VAULT_CLIENT_TIMEOUT")) 80 | if vaultConfig.ClientTimeout == 0 { 81 | vaultConfig.ClientTimeout = 10 * time.Second 82 | } 83 | 84 | vaultConfig.IgnoreMissingSecrets, _ = strconv.ParseBool(os.Getenv("VAULT_IGNORE_MISSING_SECRETS")) 85 | 86 | return &vaultConfig 87 | } 88 | 89 | func (c *Controller) initVaultClient() error { 90 | if c.vaultClient != nil { 91 | _, err := c.vaultClient.Sys().Health() 92 | if err == nil { 93 | // Client is valid, no need to init 94 | return nil 95 | } 96 | // log error and continue with (re)creating client 97 | c.logger.Error("connection to Vault lost, recreating client") 98 | } 99 | 100 | c.logger.Info("Initializing Vault client") 101 | 102 | c.vaultConfig = getVaultConfigFromEnv() 103 | clientConfig := vaultapi.DefaultConfig() 104 | if clientConfig.Error != nil { 105 | return clientConfig.Error 106 | } 107 | 108 | clientConfig.Address = c.vaultConfig.Addr 109 | clientConfig.Timeout = c.vaultConfig.ClientTimeout 110 | 111 | tlsConfig := vaultapi.TLSConfig{Insecure: c.vaultConfig.SkipVerify} 112 | err := clientConfig.ConfigureTLS(&tlsConfig) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | if c.vaultConfig.TLSSecret != "" { 118 | tlsSecret, err := c.kubeClient.CoreV1().Secrets(c.vaultConfig.TLSSecretNS).Get( 119 | context.Background(), 120 | c.vaultConfig.TLSSecret, 121 | metav1.GetOptions{}, 122 | ) 123 | if err != nil { 124 | return fmt.Errorf("failed to read Vault TLS Secret: %s", err.Error()) 125 | } 126 | 127 | clientTLSConfig := clientConfig.HttpClient.Transport.(*http.Transport).TLSClientConfig 128 | 129 | pool := x509.NewCertPool() 130 | 131 | ok := pool.AppendCertsFromPEM(tlsSecret.Data["ca.crt"]) 132 | if !ok { 133 | return fmt.Errorf("error loading Vault CA PEM from TLS Secret: %s", tlsSecret.Name) 134 | } 135 | 136 | clientTLSConfig.RootCAs = pool 137 | } 138 | 139 | vaultClient, err := vault.NewClientFromConfig( 140 | clientConfig, 141 | vault.ClientRole(c.vaultConfig.Role), 142 | vault.ClientAuthPath(c.vaultConfig.Path), 143 | vault.ClientAuthMethod(c.vaultConfig.AuthMethod), 144 | vault.ClientLogger(&clientLogger{logger: c.logger}), 145 | vault.VaultNamespace(c.vaultConfig.Namespace), 146 | ) 147 | if err != nil { 148 | return err 149 | } 150 | // 151 | // Check connection to Vault 152 | _, err = vaultClient.RawClient().Sys().Health() 153 | if err != nil { 154 | c.logger.Error("testing connection to Vault failed") 155 | return err 156 | } 157 | 158 | c.vaultClient = vaultClient.RawClient() 159 | c.logger.Info("Vault client initialized") 160 | return nil 161 | } 162 | 163 | type ErrSecretNotFound struct { 164 | secretPath string 165 | } 166 | 167 | func (e ErrSecretNotFound) Error() string { 168 | return fmt.Sprintf("Vault secret path %s not found", e.secretPath) 169 | } 170 | 171 | type vaultSecretReader interface { 172 | Read(path string) (*vaultapi.Secret, error) 173 | } 174 | 175 | func getSecretVersionFromVault(vaultClient vaultSecretReader, secretPath string) (int, error) { 176 | secret, err := vaultClient.Read(secretPath) 177 | if err != nil { 178 | return 0, err 179 | } 180 | if secret != nil { 181 | secretVersion, err := secret.Data["metadata"].(map[string]interface{})["version"].(json.Number).Int64() 182 | if err != nil { 183 | return 0, err 184 | } 185 | return int(secretVersion), nil 186 | } 187 | 188 | return 0, ErrSecretNotFound{secretPath: secretPath} 189 | } 190 | -------------------------------------------------------------------------------- /pkg/reloader/collector.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "fmt" 19 | "log/slog" 20 | "regexp" 21 | "slices" 22 | "strings" 23 | "sync" 24 | 25 | "github.com/bank-vaults/secrets-webhook/pkg/common" 26 | corev1 "k8s.io/api/core/v1" 27 | ) 28 | 29 | type workloadSecretsStore interface { 30 | Store(workload workload, secrets []string) 31 | Delete(workload workload) 32 | GetWorkloadSecretsMap() map[workload][]string 33 | GetSecretWorkloadsMap() map[string][]workload 34 | } 35 | 36 | type workload struct { 37 | name string 38 | namespace string 39 | kind string 40 | } 41 | 42 | type workloadSecrets struct { 43 | sync.RWMutex 44 | workloadSecretsMap map[workload][]string 45 | } 46 | 47 | func newWorkloadSecrets() workloadSecretsStore { 48 | return &workloadSecrets{ 49 | workloadSecretsMap: make(map[workload][]string), 50 | } 51 | } 52 | 53 | func (w *workloadSecrets) Store(workload workload, secrets []string) { 54 | w.Lock() 55 | defer w.Unlock() 56 | w.workloadSecretsMap[workload] = secrets 57 | } 58 | 59 | func (w *workloadSecrets) Delete(workload workload) { 60 | w.Lock() 61 | defer w.Unlock() 62 | delete(w.workloadSecretsMap, workload) 63 | } 64 | 65 | func (w *workloadSecrets) GetWorkloadSecretsMap() map[workload][]string { 66 | return w.workloadSecretsMap 67 | } 68 | 69 | func (w *workloadSecrets) GetSecretWorkloadsMap() map[string][]workload { 70 | w.Lock() 71 | defer w.Unlock() 72 | secretWorkloads := make(map[string][]workload) 73 | for workload, secretPaths := range w.workloadSecretsMap { 74 | for _, secretPath := range secretPaths { 75 | secretWorkloads[secretPath] = append(secretWorkloads[secretPath], workload) 76 | } 77 | } 78 | return secretWorkloads 79 | } 80 | 81 | func (c *Controller) collectWorkloadSecrets(workload workload, template corev1.PodTemplateSpec) { 82 | collectorLogger := c.logger.With(slog.String("worker", "collector")) 83 | 84 | // Collect secrets from different locations 85 | vaultSecretPaths := collectSecrets(template) 86 | 87 | if len(vaultSecretPaths) == 0 { 88 | collectorLogger.Debug("No Vault secret paths found in container env vars") 89 | return 90 | } 91 | collectorLogger.Debug(fmt.Sprintf("Vault secret paths found: %v", vaultSecretPaths)) 92 | 93 | // Add workload and secrets to workloadSecrets map 94 | c.workloadSecrets.Store(workload, vaultSecretPaths) 95 | collectorLogger.Info(fmt.Sprintf("Collected secrets from %s %s/%s", workload.kind, workload.namespace, workload.name)) 96 | } 97 | 98 | func collectSecrets(template corev1.PodTemplateSpec) []string { 99 | containers := []corev1.Container{} 100 | containers = append(containers, template.Spec.Containers...) 101 | containers = append(containers, template.Spec.InitContainers...) 102 | 103 | vaultSecretPaths := []string{} 104 | vaultSecretPaths = append(vaultSecretPaths, collectSecretsFromContainerEnvVars(containers)...) 105 | vaultSecretPaths = append(vaultSecretPaths, collectSecretsFromAnnotations(template.GetAnnotations())...) 106 | 107 | // Remove duplicates 108 | slices.Sort(vaultSecretPaths) 109 | return slices.Compact(vaultSecretPaths) 110 | } 111 | 112 | func collectSecretsFromContainerEnvVars(containers []corev1.Container) []string { 113 | vaultSecretPaths := []string{} 114 | // iterate through all environment variables and extract secrets 115 | for _, container := range containers { 116 | for _, env := range container.Env { 117 | // Skip if env var does not contain a vault secret or is a secret with pinned version 118 | if isValidPrefix(env.Value) && unversionedSecretValue(env.Value) { 119 | secret := regexp.MustCompile(`vault:(.*?)#`).FindStringSubmatch(env.Value)[1] 120 | if secret != "" { 121 | vaultSecretPaths = append(vaultSecretPaths, secret) 122 | } 123 | } 124 | } 125 | } 126 | 127 | return vaultSecretPaths 128 | } 129 | 130 | func collectSecretsFromAnnotations(annotations map[string]string) []string { 131 | vaultSecretPaths := []string{} 132 | 133 | secretPaths := annotations[common.VaultFromPathAnnotation] 134 | if secretPaths != "" { 135 | for _, secretPath := range strings.Split(secretPaths, ",") { 136 | if unversionedAnnotationSecretValue(secretPath) { 137 | vaultSecretPaths = append(vaultSecretPaths, secretPath) 138 | } 139 | } 140 | } 141 | 142 | // This is here to preserve backwards compatibility with the deprecated annotation 143 | if len(vaultSecretPaths) == 0 { 144 | deprecatedSecretPaths := annotations[common.VaultEnvFromPathAnnotationDeprecated] 145 | if deprecatedSecretPaths != "" { 146 | for _, secretPath := range strings.Split(deprecatedSecretPaths, ",") { 147 | if unversionedAnnotationSecretValue(secretPath) { 148 | vaultSecretPaths = append(vaultSecretPaths, secretPath) 149 | } 150 | } 151 | } 152 | } 153 | 154 | return vaultSecretPaths 155 | } 156 | 157 | // implementation based on bank-vaults/secrets-webhook/pkg/provider/vault/provider.go 158 | func isValidPrefix(value string) bool { 159 | return strings.HasPrefix(value, "vault:") || strings.HasPrefix(value, ">>vault:") 160 | } 161 | 162 | // implementation based on bank-vaults/internal/pkg/injector/vault/injector.go 163 | func unversionedSecretValue(value string) bool { 164 | split := strings.SplitN(value, "#", 3) 165 | return len(split) == 2 166 | } 167 | 168 | func unversionedAnnotationSecretValue(value string) bool { 169 | split := strings.SplitN(value, "#", 2) 170 | return len(split) == 1 171 | } 172 | -------------------------------------------------------------------------------- /deploy/charts/vault-secrets-reloader/README.md: -------------------------------------------------------------------------------- 1 | # vault-secrets-reloader 2 | 3 | This chart will install Vault Secrets Reloader Controller, that reloads workloads on a referenced secret change in HashiCorp Vault. 4 | 5 | Reloader will collect (unversioned) secrets injected by the Webhook from watched workloads, periodically checks if their version has been updated in Vault and if so, "reloads" the workload with an annotation update, triggering a new rollout so the Webhook can inject the new version of the secret into the pod. 6 | 7 | ## Before you start 8 | 9 | Reloader works in conjunction with the [Secrets Webhook](https://github.com/bank-vaults/secrets-webhook), therefore the prerequisites to start using it would be a Hashicorp Vault instance, and a working Webhook. 10 | 11 | You will need to add the following annotations to the pod template spec of the workloads (i.e. Deployments, DaemonSets and StatefulSets) that you wish to reload: 12 | 13 | ```yaml 14 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 15 | ``` 16 | 17 | ## Installing the Chart 18 | 19 | **Prepare Kubernetes namespace** 20 | 21 | You can prepare a separate namespace for Vault Secrets Reloader beforehand, create it automatically if not yet exist with appending `--create-namespace` to the installation Helm command, or just use the one already created for Secrets Webhook. 22 | 23 | **Install the chart** 24 | 25 | 1. Save Reloader default chart values: 26 | 27 | ```shell 28 | helm show values oci://ghcr.io/bank-vaults/helm-charts/vault-secrets-reloader > values.yaml 29 | ``` 30 | 31 | 2. Check the configuration in `values.yaml` and update the required values if needed. Configure the time for periodic runs of the `collector` and `reloader` workers with a value in Go Duration format: 32 | 33 | ```yaml 34 | collectorSyncPeriod: 30m 35 | reloaderRunPeriod: 1h 36 | ``` 37 | 38 | Additionally, Reloader needs to be supplied with Vault credentials to be able to connect to Vault in order to get the secrets. You can check the list of environmental variables accepted for creating a Vault client [here](https://developer.hashicorp.com/vault/docs/commands#environment-variables). For example: 39 | 40 | ```yaml 41 | env: 42 | # define env vars for Vault used for authentication 43 | VAULT_ROLE: "reloader" 44 | VAULT_ADDR: "https://vault.default.svc.cluster.local:8200" 45 | VAULT_NAMESPACE: "default" 46 | VAULT_TLS_SECRET: "vault-tls" 47 | VAULT_TLS_SECRET_NS: "bank-vaults-infra" 48 | ``` 49 | 50 | 3. Install the chart: 51 | 52 | ```shell 53 | helm upgrade --install --values values.yaml vault-secrets-reloader oci://ghcr.io/bank-vaults/helm-charts/vault-secrets-reloader --namespace bank-vaults-infra --create-namespace 54 | ``` 55 | 56 | ## Values 57 | 58 | The following table lists the configurable parameters of the Helm chart. 59 | 60 | | Parameter | Type | Default | Description | 61 | | --- | ---- | ------- | ----------- | 62 | | `logLevel` | string | `"info"` | Log level | 63 | | `enableJSONLog` | bool | `false` | Use JSON log format instead of text | 64 | | `image.repository` | string | `"ghcr.io/bank-vaults/vault-secrets-reloader"` | Container image repo that contains the Reloader Controller | 65 | | `image.tag` | string | `""` | Container image tag | 66 | | `image.pullPolicy` | string | `"IfNotPresent"` | Container image pull policy | 67 | | `image.imagePullSecrets` | list | `[]` | Container image pull secrets for private repositories | 68 | | `nameOverride` | string | `""` | Override app name | 69 | | `fullnameOverride` | string | `""` | Override app full name | 70 | | `collectorSyncPeriod` | string | `"30m"` | Time interval for the collector worker to run in Go Duration format | 71 | | `reloaderRunPeriod` | string | `"1h"` | Time interval for the reloader worker to run in Go Duration format | 72 | | `serviceAccount.create` | bool | `true` | Specifies whether a service account should be created | 73 | | `serviceAccount.annotations` | object | `{}` | Annotations to add to the service account | 74 | | `serviceAccount.name` | string | `""` | The name of the service account to use. If not set and create is true, a name is generated using the fullname template | 75 | | `podAnnotations` | object | `{}` | Extra annotations to add to pod metadata | 76 | | `podSecurityContext` | object | `{}` | Pod security context for Reloader deployment | 77 | | `securityContext` | object | `{}` | Pod security context for Reloader containers | 78 | | `service.name` | string | `"vault-secrets-reloader"` | Reloader service name | 79 | | `service.type` | string | `"ClusterIP"` | Reloader service type | 80 | | `service.externalPort` | int | `443` | Reloader service external port | 81 | | `service.internalPort` | int | `8443` | Reloader service internal port | 82 | | `service.annotations` | object | `{}` | Reloader service annotations, e.g. if type is AWS LoadBalancer and you want to add security groups | 83 | | `ingress.enabled` | bool | `false` | Enable Reloader ingress | 84 | | `ingress.className` | string | `""` | Reloader IngressClass name | 85 | | `ingress.annotations` | object | `{}` | Reloader ingress annotations | 86 | | `ingress.hosts` | list | `[]` | Reloader ingress hosts | 87 | | `ingress.tls` | list | `[]` | Reloader ingress tls | 88 | | `env` | object | `{}` | Environment variables e.g. for Vault authentication | 89 | | `volumes` | list | `[]` | Extra volume definitions for Reloader deployment | 90 | | `volumeMounts` | list | `[]` | Extra volume mounts for Reloader deployment | 91 | | `resources` | object | `{}` | Resources to request for the deployment and pods | 92 | | `autoscaling.enabled` | bool | `false` | Enable Reloader horizontal pod autoscaling | 93 | | `autoscaling.minReplicas` | int | `1` | Minimum number of replicas | 94 | | `autoscaling.maxReplicas` | int | `100` | Maximum number of replicas | 95 | | `nodeSelector` | object | `{}` | Node labels for pod assignment. Check: | 96 | | `tolerations` | list | `[]` | List of node tolerations for the pods. Check: | 97 | | `affinity` | object | `{}` | Node affinity settings for the pods. Check: | 98 | 99 | Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. 100 | 101 | ### Vault settings 102 | 103 | Make sure to add the `read` and `list` capabilities for secrets to the Vault auth role the Reloader will use. An example can be found in the [example Bank-Vaults Operator CR file](https://github.com/bank-vaults/vault-secrets-reloader/blob/main/e2e/deploy/vault/vault.yaml#L102). 104 | -------------------------------------------------------------------------------- /pkg/reloader/reloader.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "log/slog" 21 | "strconv" 22 | "sync" 23 | 24 | corev1 "k8s.io/api/core/v1" 25 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 | ) 27 | 28 | func (c *Controller) runReloader(ctx context.Context) { 29 | reloaderLogger := c.logger.With(slog.String("worker", "reloader")) 30 | reloaderLogger.Info("Reloader started") 31 | 32 | if len(c.workloadSecrets.GetWorkloadSecretsMap()) == 0 { 33 | reloaderLogger.Info("No workloads to reload") 34 | return 35 | } 36 | 37 | err := c.initVaultClient() 38 | if err != nil { 39 | reloaderLogger.Error(fmt.Errorf("failed to initialize Vault client: %w", err).Error()) 40 | return 41 | } 42 | 43 | // Create a secretWorkloads map and compare the currently used secrets' version 44 | // with the one stored in the secretVersions map, while creating a new secretVersions map 45 | workloadsToReload := make(map[workload]bool) 46 | newSecretVersions := make(map[string]int) 47 | var wg sync.WaitGroup 48 | var mu sync.Mutex 49 | for secretPath, workloads := range c.workloadSecrets.GetSecretWorkloadsMap() { 50 | wg.Add(1) 51 | go func(secretPath string, workloads []workload) { 52 | defer wg.Done() 53 | reloaderLogger.Debug(fmt.Sprintf("Checking secret: %s", secretPath)) 54 | 55 | // Get current secret version 56 | currentVersion, err := getSecretVersionFromVault(c.vaultClient.Logical(), secretPath) 57 | if err != nil { 58 | c.handleSecretError(err, secretPath, reloaderLogger) 59 | return 60 | } 61 | 62 | mu.Lock() 63 | defer mu.Unlock() 64 | 65 | // Compare secret versions 66 | switch c.secretVersions[secretPath] { 67 | case 0: 68 | reloaderLogger.Debug(fmt.Sprintf("Secret %s not found in secretVersions map, creating it", secretPath)) 69 | case currentVersion: 70 | reloaderLogger.Debug(fmt.Sprintf("Secret %s did not change", secretPath)) 71 | default: 72 | reloaderLogger.Debug(fmt.Sprintf("Secret version stored: %d current: %d", c.secretVersions[secretPath], currentVersion)) 73 | for _, workload := range workloads { 74 | workloadsToReload[workload] = true 75 | } 76 | } 77 | 78 | newSecretVersions[secretPath] = currentVersion 79 | }(secretPath, workloads) 80 | } 81 | // wait for secret version checking to complete 82 | wg.Wait() 83 | 84 | // Reloading workloads 85 | wg = sync.WaitGroup{} // Reset the WaitGroup 86 | for workloadToReload := range workloadsToReload { 87 | wg.Add(1) 88 | go func(workloadToReload workload) { 89 | defer wg.Done() 90 | reloaderLogger.Info(fmt.Sprintf("Reloading workload: %s", workloadToReload)) 91 | 92 | err := c.reloadWorkload(ctx, workloadToReload) 93 | if err != nil { 94 | reloaderLogger.Error(fmt.Errorf("failed reloading workload: %s: %w", workloadToReload, err).Error()) 95 | } 96 | }(workloadToReload) 97 | } 98 | // wait for workload reloading to complete 99 | wg.Wait() 100 | 101 | // Replace secretVersions map with the new one so we don't keep deleted secrets in the map 102 | c.secretVersions = newSecretVersions 103 | reloaderLogger.Debug(fmt.Sprintf("Updated secretVersions map: %#v", newSecretVersions)) 104 | 105 | if len(workloadsToReload) == 0 { 106 | reloaderLogger.Info("No workloads to reload") 107 | } 108 | } 109 | 110 | func (c *Controller) reloadWorkload(ctx context.Context, workload workload) error { 111 | // Reload object based on its type 112 | switch workload.kind { 113 | case DeploymentKind: 114 | deployment, err := c.kubeClient.AppsV1().Deployments(workload.namespace).Get(ctx, workload.name, metav1.GetOptions{}) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | incrementReloadCountAnnotation(&deployment.Spec.Template) 120 | 121 | _, err = c.kubeClient.AppsV1().Deployments(workload.namespace).Update(ctx, deployment, metav1.UpdateOptions{}) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | case DaemonSetKind: 127 | daemonSet, err := c.kubeClient.AppsV1().DaemonSets(workload.namespace).Get(ctx, workload.name, metav1.GetOptions{}) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | incrementReloadCountAnnotation(&daemonSet.Spec.Template) 133 | 134 | _, err = c.kubeClient.AppsV1().DaemonSets(workload.namespace).Update(ctx, daemonSet, metav1.UpdateOptions{}) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | case StatefulSetKind: 140 | statefulSet, err := c.kubeClient.AppsV1().StatefulSets(workload.namespace).Get(ctx, workload.name, metav1.GetOptions{}) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | incrementReloadCountAnnotation(&statefulSet.Spec.Template) 146 | 147 | _, err = c.kubeClient.AppsV1().StatefulSets(workload.namespace).Update(ctx, statefulSet, metav1.UpdateOptions{}) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | default: 153 | return fmt.Errorf("unknown object type: %s", workload.kind) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (c *Controller) handleSecretError(err error, secretPath string, logger *slog.Logger) { 160 | switch err.(type) { 161 | case ErrSecretNotFound: 162 | if !c.vaultConfig.IgnoreMissingSecrets { 163 | logger.Error(err.Error()) 164 | } else { 165 | logger.Warn(fmt.Sprintf( 166 | "Path not found: %s - We couldn't find a secret path. This is not an error since missing secrets can be ignored according to the configuration you've set (env: VAULT_IGNORE_MISSING_SECRETS).", 167 | secretPath, 168 | )) 169 | } 170 | 171 | default: 172 | logger.Error(fmt.Errorf("failed to get secret version: %w", err).Error()) 173 | } 174 | } 175 | 176 | func incrementReloadCountAnnotation(podTemplate *corev1.PodTemplateSpec) { 177 | version := "1" 178 | 179 | if reloadCount := podTemplate.GetAnnotations()[ReloadCountAnnotationName]; reloadCount != "" { 180 | count, err := strconv.Atoi(reloadCount) 181 | if err == nil { 182 | count++ 183 | version = strconv.Itoa(count) 184 | } 185 | } 186 | 187 | podTemplate.GetAnnotations()[ReloadCountAnnotationName] = version 188 | } 189 | -------------------------------------------------------------------------------- /e2e/deploy/vault/vault.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "vault.banzaicloud.com/v1alpha1" 2 | kind: "Vault" 3 | metadata: 4 | name: "vault" 5 | spec: 6 | size: 1 7 | image: hashicorp/vault:1.14.1 8 | # specify a custom bank-vaults image with bankVaultsImage: 9 | bankVaultsImage: ghcr.io/bank-vaults/bank-vaults:latest 10 | 11 | # Common annotations for all created resources 12 | annotations: 13 | common/annotation: "true" 14 | 15 | # Vault Pods , Services and TLS Secret annotations 16 | vaultAnnotations: 17 | type/instance: "vault" 18 | 19 | # Vault Configurer Pods and Services annotations 20 | vaultConfigurerAnnotations: 21 | type/instance: "vaultconfigurer" 22 | 23 | # Vault Pods , Services and TLS Secret labels 24 | vaultLabels: 25 | example.com/log-format: "json" 26 | 27 | # Vault Configurer Pods and Services labels 28 | vaultConfigurerLabels: 29 | example.com/log-format: "string" 30 | 31 | # Specify the ServiceAccount where the Vault Pod and the Bank-Vaults configurer/unsealer is running 32 | serviceAccount: vault 33 | 34 | # Specify the Service's type where the Vault Service is exposed 35 | # Please note that some Ingress controllers like https://github.com/kubernetes/ingress-gce 36 | # forces you to expose your Service on a NodePort 37 | serviceType: ClusterIP 38 | 39 | # Request an Ingress controller with the default configuration 40 | ingress: 41 | # Specify Ingress object annotations here, if TLS is enabled (which is by default) 42 | # the operator will add NGINX, Traefik and HAProxy Ingress compatible annotations 43 | # to support TLS backends 44 | annotations: {} 45 | # Override the default Ingress specification here 46 | # This follows the same format as the standard Kubernetes Ingress 47 | # See: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#ingressspec-v1beta1-extensions 48 | spec: {} 49 | 50 | # Use local disk to store Vault file data, see config section. 51 | volumes: 52 | - name: vault-file 53 | persistentVolumeClaim: 54 | claimName: vault-file 55 | 56 | volumeMounts: 57 | - name: vault-file 58 | mountPath: /vault/file 59 | 60 | # Support for distributing the generated CA certificate Secret to other namespaces. 61 | # Define a list of namespaces or use ["*"] for all namespaces. 62 | caNamespaces: 63 | - "bank-vaults-infra" 64 | 65 | # Describe where you would like to store the Vault unseal keys and root token. 66 | unsealConfig: 67 | options: 68 | # The preFlightChecks flag enables unseal and root token storage tests 69 | # This is true by default 70 | preFlightChecks: true 71 | # The storeRootToken flag enables storing of root token in chosen storage 72 | # This is true by default 73 | storeRootToken: true 74 | # The secretShares represents the total number of unseal key shares 75 | # This is 5 by default 76 | secretShares: 5 77 | # The secretThreshold represents the minimum number of shares required to reconstruct the unseal key 78 | # This is 3 by default 79 | secretThreshold: 3 80 | kubernetes: 81 | secretNamespace: default 82 | 83 | # A YAML representation of a final vault config file. 84 | # See https://www.vaultproject.io/docs/configuration/ for more information. 85 | config: 86 | storage: 87 | file: 88 | path: "${ .Env.VAULT_STORAGE_FILE }" # An example how Vault config environment interpolation can be used 89 | listener: 90 | tcp: 91 | address: "0.0.0.0:8200" 92 | # Uncommenting the following line and deleting tls_cert_file and tls_key_file disables TLS 93 | # tls_disable: true 94 | tls_cert_file: /vault/tls/server.crt 95 | tls_key_file: /vault/tls/server.key 96 | telemetry: 97 | statsd_address: localhost:9125 98 | ui: true 99 | 100 | # See: https://banzaicloud.com/docs/bank-vaults/cli-tool/#example-external-vault-configuration 101 | # The repository also contains a lot examples in the test/deploy and operator/deploy directories. 102 | externalConfig: 103 | policies: 104 | - name: allow_secrets 105 | rules: path "secret/*" { 106 | capabilities = ["create", "read", "update", "delete", "list"] 107 | } 108 | # define a new policy for the Reloader 109 | - name: read_secrets 110 | rules: path "secret/*" { 111 | capabilities = ["read", "list"] 112 | } 113 | 114 | groups: 115 | - name: admin 116 | policies: 117 | - allow_secrets 118 | - read_secrets 119 | metadata: 120 | privileged: true 121 | type: external 122 | 123 | group-aliases: 124 | - name: admin 125 | mountpath: token 126 | group: admin 127 | 128 | auth: 129 | - type: kubernetes 130 | roles: 131 | - name: default 132 | bound_service_account_names: ["default", "secrets-webhook"] 133 | bound_service_account_namespaces: ["default", "bank-vaults-infra"] 134 | policies: ["allow_secrets"] 135 | ttl: 1h 136 | - name: reloader 137 | bound_service_account_names: ["vault-secrets-reloader"] 138 | bound_service_account_namespaces: ["bank-vaults-infra"] 139 | policies: ["read_secrets"] # use the read_secrets policy in the reloader role 140 | ttl: 1h 141 | 142 | secrets: 143 | - path: secret 144 | type: kv 145 | description: General secrets. 146 | options: 147 | version: 2 148 | 149 | # Allows writing some secrets to Vault (useful for development purposes). 150 | # See https://www.vaultproject.io/docs/secrets/kv/index.html for more information. 151 | startupSecrets: 152 | - type: kv 153 | path: secret/data/accounts/aws 154 | data: 155 | data: 156 | AWS_ACCESS_KEY_ID: secretId 157 | AWS_SECRET_ACCESS_KEY: s3cr3t 158 | - type: kv 159 | path: secret/data/dockerrepo 160 | data: 161 | data: 162 | DOCKER_REPO_USER: dockerrepouser 163 | DOCKER_REPO_PASSWORD: dockerrepopassword 164 | - type: kv 165 | path: secret/data/mysql 166 | data: 167 | data: 168 | MYSQL_ROOT_PASSWORD: s3cr3t 169 | MYSQL_PASSWORD: 3xtr3ms3cr3t 170 | 171 | secretInitsConfig: 172 | - name: VAULT_LOG_LEVEL 173 | value: debug 174 | - name: VAULT_STORAGE_FILE 175 | value: "/vault/file" 176 | 177 | # Marks presence of Istio, which influences things like port namings 178 | istioEnabled: false 179 | 180 | --- 181 | 182 | apiVersion: v1 183 | kind: PersistentVolumeClaim 184 | metadata: 185 | name: vault-file 186 | spec: 187 | # https://kubernetes.io/docs/concepts/storage/persistent-volumes/#class-1 188 | # storageClassName: "" 189 | accessModes: 190 | - ReadWriteOnce 191 | resources: 192 | requests: 193 | storage: 1Gi 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vault Secrets Reloader 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/bank-vaults/vault-secrets-reloader)](https://goreportcard.com/report/github.com/bank-vaults/vault-secrets-reloader) 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/bank-vaults/vault-secrets-reloader/ci.yaml?branch=main&style=flat-square)](https://github.com/bank-vaults/vault-secrets-reloader/actions/workflows/ci.yaml?query=workflow%3ACI) 5 | 6 | Vault Secrets Reloader can periodically check if a secret that is used in watched workloads has a new version in Hashicorp Vault, and if so, automatically “reloads” them by incrementing an annotation value, initiating a rollout for the workload’s pods. This controller is essentially a complementary to `secrets-webhook`, relying on it for actually injecting secrets into the pods of the affected workloads. 7 | 8 | > [!IMPORTANT] 9 | > This is an **early alpha version** and breaking changes are expected. As such, it is not recommended 10 | > for usage in production. 11 | > 12 | > You can support us with your feedback, bug reports, and feature requests. 13 | 14 | ## Overview 15 | 16 | Upon deployment, the Reloader spawns two “workers”, that run periodically at two different time intervals: 17 | 18 | 1. The `collector` collects and stores information about the workloads that are opted in via the `secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true"` annotation in their pod template metadata and the Vault secrets they use. 19 | 20 | 2. The `reloader` iterates on the data collected by the `collector`, polling the configured Vault instance for the current version of the secrets, and if it finds that it differs from the stored one, adds the workloads where the secret is used to a list of workloads that needs reloading. In a following step, it modifies these workloads by incrementing the value of the `secrets-reloader.security.bank-vaults.io/secret-reload-count` annotation in their pod template metadata, initiating a new rollout. 21 | 22 | To get familiarized, check out [how Reloader fits in the Bank-Vaults ecosystem](https://github.com/bank-vaults/vault-secrets-reloader/blob/main/examples/reloader-in-bank-vaults-ecosystem.md), and how can you [give Reloader a spin](https://github.com/bank-vaults/vault-secrets-reloader/blob/main/examples/try-locally.md) on your local machine. 23 | 24 | ### Current features, limitations 25 | 26 | - The time interval can be set separately for these two workers, to limit resources they use and the number of requests sent to the Vault instance. The interval setting for the `collector` (`collectorSyncPeriod` in the Helm chart) should logically be the same, or lower than for the `reloader` (`reloaderRunPeriod`). 27 | 28 | - Vault credentials can be set through environment variables in the Helm chart. 29 | 30 | - It can only check for updated versions of secrets in one specific instance of Hashicorp Vault, no other secret stores are supported yet. 31 | 32 | - It can only “reload” Deployments, DaemonSets and StatefulSets that have the `secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true"` annotation set among their `spec.template.metadata.annotations`. 33 | 34 | - The `collector` can only look for secrets in the workload’s pod template environment variables directly, and in their `secrets-webhook.security.bank-vaults.io/vault-from-path` annotation, in the format the `secrets-webhook` also uses, and are unversioned. 35 | 36 | - Data collected by the `reloader` is only stored in-memory. 37 | 38 | ### Configuration 39 | 40 | Reloader needs to access the Vault instance on its own, so make sure you set the correct environment variables through 41 | the Helm chart (you can check the list of environmental variables accepted for creating a Vault client 42 | [here](https://developer.hashicorp.com/vault/docs/commands#environment-variables)). Furthermore, configure the workload 43 | data collection and reloading periods (using Go Duration format) that work best for your requirements and use-cases. For 44 | example: 45 | 46 | ```shell 47 | helm upgrade --install vault-secrets-reloader oci://ghcr.io/bank-vaults/helm-charts/vault-secrets-reloader \ 48 | --set collectorSyncPeriod=2h \ 49 | --set reloaderRunPeriod=4h \ 50 | --set env.VAULT_ADDR=[URL for Vault] 51 | --set env.VAULT_PATH=[Auth path] 52 | --set env.VAULT_ROLE=[Auth role] 53 | --set env.VAULT_AUTH_METHOD=[Auth method] 54 | # other environmental variables needed for the auth method of your choice 55 | --namespace bank-vaults-infra --create-namespace 56 | ``` 57 | 58 | Vault also needs to be configured with an auth method for the Reloader to use. Additionally, it is advised to create a 59 | role and policy that allows the Reloader to `read` and `list` secrets from Vault. An example can be found in the 60 | [example Bank-Vaults Operator CR 61 | file](https://github.com/bank-vaults/vault-secrets-reloader/blob/main/e2e/deploy/vault/vault.yaml#L102). 62 | 63 | ## Development 64 | 65 | Install [Go](https://go.dev/dl/) on your computer then run `make deps` to install the rest of the 66 | dependencies. 67 | 68 | Make sure Docker is installed with Compose and Buildx. 69 | 70 | ### Install project dependencies locally 71 | 72 | ```shell 73 | make deps 74 | 75 | make up 76 | ``` 77 | 78 | ### Port-forward Vault 79 | 80 | ```shell 81 | export VAULT_TOKEN=$(kubectl get secrets vault-unseal-keys -o jsonpath={.data.vault-root} | base64 --decode) 82 | 83 | kubectl get secret vault-tls -o jsonpath="{.data.ca\.crt}" | base64 --decode > $PWD/vault-ca.crt 84 | export VAULT_CACERT=$PWD/vault-ca.crt 85 | 86 | export VAULT_ADDR=https://127.0.0.1:8200 87 | 88 | kubectl port-forward service/vault 8200 & 89 | ``` 90 | 91 | ### Run the Reloader 92 | 93 | ```shell 94 | make run 95 | ``` 96 | 97 | ### Run unit tests 98 | 99 | ```shell 100 | make test 101 | ``` 102 | 103 | ### Run end-to-end tests 104 | 105 | The project comes with an e2e test suite that is mostly self-contained, but at the very least, you need Docker 106 | installed. 107 | 108 | By default, the suite launches a [KinD](https://kind.sigs.k8s.io/) cluster, deploys all necessary components and runs 109 | the test suite. This is a good option if you want to run the test suite to make sure everything works. This is also how 110 | the CI runs the test suite (with a few minor differences). 111 | 112 | You can run the test suite by running the following commands: 113 | 114 | ```shell 115 | make container-image 116 | make test-e2e-local 117 | ``` 118 | 119 | ### Run linters 120 | 121 | ```shell 122 | make lint # pass -j option to run them in parallel 123 | ``` 124 | 125 | Some linter violations can automatically be fixed: 126 | 127 | ```shell 128 | make fmt 129 | ``` 130 | 131 | ### Build artifacts locally 132 | 133 | ```shell 134 | make artifacts 135 | ``` 136 | 137 | ### Once you are done either stop or tear down dependencies 138 | 139 | ```shell 140 | make down 141 | ``` 142 | 143 | ## License 144 | 145 | The project is licensed under the [Apache 2.0 License](LICENSE). 146 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github. run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 26 | with: 27 | go-version-file: '.go-version' 28 | 29 | - name: Set up Go cache 30 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 31 | with: 32 | path: | 33 | ~/.cache/go-build 34 | ~/go/pkg/mod 35 | key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 36 | restore-keys: | 37 | ${{ github.job }}-${{ runner.os }}-go- 38 | 39 | - name: Build 40 | run: make build 41 | 42 | test: 43 | name: Test 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 49 | 50 | - name: Set up Go 51 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 52 | with: 53 | go-version-file: '.go-version' 54 | 55 | - name: Set up Go cache 56 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 57 | with: 58 | path: | 59 | ~/.cache/go-build 60 | ~/go/pkg/mod 61 | key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 62 | restore-keys: | 63 | ${{ github.job }}-${{ runner.os }}-go- 64 | 65 | - name: Test 66 | run: make test 67 | 68 | lint: 69 | name: Lint 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Checkout repository 74 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 75 | 76 | - name: Set up Go 77 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 78 | with: 79 | go-version-file: '.go-version' 80 | 81 | - name: Set up Go cache 82 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 83 | with: 84 | path: | 85 | ~/.cache/go-build 86 | ~/go/pkg/mod 87 | key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 88 | restore-keys: | 89 | ${{ github.job }}-${{ runner.os }}-go- 90 | 91 | - name: Make deps 92 | run: make deps 93 | 94 | - name: Lint Go code 95 | run: make lint-go 96 | 97 | - name: Lint Helm charts 98 | run: make lint-helm 99 | 100 | - name: Run Hadolint 101 | uses: hadolint/hadolint-action@v3.3.0 102 | with: 103 | dockerfile: Dockerfile 104 | config: .hadolint.yaml 105 | 106 | - name: Run Yamllint 107 | run: | 108 | pip install --user yamllint 109 | yamllint . 110 | 111 | license-check: 112 | name: License check 113 | runs-on: ubuntu-latest 114 | 115 | steps: 116 | - name: Checkout repository 117 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 118 | 119 | - name: Set up Go 120 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 121 | with: 122 | go-version-file: '.go-version' 123 | 124 | - name: Set up Go cache 125 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 126 | with: 127 | path: | 128 | ~/.cache/go-build 129 | ~/go/pkg/mod 130 | key: ${{ github.job }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 131 | restore-keys: | 132 | ${{ github.job }}-${{ runner.os }}-go- 133 | 134 | - name: Cache license information 135 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 136 | with: 137 | path: .licensei.cache 138 | key: licensei-v1-${{ github.ref_name }}-${{ hashFiles('go.sum') }} 139 | restore-keys: | 140 | licensei-v1-${{ github.ref_name }} 141 | licensei-v1-main 142 | licensei-v1 143 | 144 | - name: Make deps 145 | run: make deps 146 | 147 | - name: Populate license cache 148 | run: make license-cache 149 | env: 150 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 151 | 152 | - name: Check licenses 153 | run: make license-check 154 | env: 155 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 156 | 157 | artifacts: 158 | name: Artifacts 159 | uses: ./.github/workflows/artifacts.yaml 160 | with: 161 | publish: ${{ github.event_name == 'push' }} 162 | permissions: 163 | contents: read 164 | packages: write 165 | id-token: write 166 | security-events: write 167 | 168 | dependency-review: 169 | name: Dependency review 170 | runs-on: ubuntu-latest 171 | if: github.event_name == 'pull_request' 172 | 173 | steps: 174 | - name: Checkout repository 175 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 176 | 177 | - name: Dependency Review 178 | uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 179 | 180 | e2e-test: 181 | name: E2E test 182 | runs-on: ubuntu-latest 183 | needs: [artifacts] 184 | strategy: 185 | matrix: 186 | k8s_version: ["v1.32.0", "v1.33.0", "v1.34.0"] 187 | operator_version: ["v1.22.4", "v1.23.0", "v1.23.1"] # First version that works with the generic webhook: v1.22.1 188 | webhook_version: ["v0.2.1", "v0.3.0", "v0.3.1"] 189 | 190 | steps: 191 | - name: Checkout repository 192 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 193 | 194 | - name: Set up Go 195 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 196 | with: 197 | go-version-file: '.go-version' 198 | 199 | - name: Download docker image 200 | uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 201 | with: 202 | name: "[container-image] Docker tarball" 203 | 204 | - name: Download helm chart 205 | uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 206 | with: 207 | name: "[helm-chart] package" 208 | 209 | - name: Test 210 | run: make test-e2e 211 | env: 212 | KIND_K8S_VERSION: ${{ matrix.k8s_version }} 213 | LOAD_IMAGE_ARCHIVE: ${{ github.workspace }}/docker.tar 214 | RELOADER_VERSION: ${{ needs.artifacts.outputs.container-image-tag }} 215 | OPERATOR_VERSION: ${{ matrix.operator_version }} 216 | WEBHOOK_VERSION: ${{ matrix.webhook_version }} 217 | HELM_CHART: "${{ github.workspace }}/${{ needs.artifacts.outputs.helm-chart-package }}" 218 | LOG_VERBOSE: "true" 219 | -------------------------------------------------------------------------------- /e2e/deploy/workloads/deployments.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: reloader-test-deployment-to-be-reloaded 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app.kubernetes.io/name: reloader-test-deployment-to-be-reloaded 10 | template: 11 | metadata: 12 | labels: 13 | app.kubernetes.io/name: reloader-test-deployment-to-be-reloaded 14 | annotations: 15 | secrets-webhook.security.bank-vaults.io/provider: "vault" 16 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 17 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 18 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 19 | spec: 20 | initContainers: 21 | - name: init-ubuntu 22 | image: ubuntu 23 | command: ["sh", "-c", "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo initContainers ready"] 24 | env: 25 | - name: AWS_SECRET_ACCESS_KEY 26 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 27 | - name: MYSQL_PASSWORD 28 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 29 | resources: 30 | limits: 31 | memory: "128Mi" 32 | cpu: "100m" 33 | containers: 34 | - name: alpine 35 | image: alpine 36 | command: 37 | - "sh" 38 | - "-c" 39 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 40 | env: 41 | - name: AWS_SECRET_ACCESS_KEY 42 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 43 | - name: MYSQL_PASSWORD 44 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 45 | resources: 46 | limits: 47 | memory: "128Mi" 48 | cpu: "100m" 49 | 50 | --- 51 | 52 | apiVersion: apps/v1 53 | kind: Deployment 54 | metadata: 55 | name: reloader-test-deployment-no-reload 56 | spec: 57 | replicas: 1 58 | selector: 59 | matchLabels: 60 | app.kubernetes.io/name: reloader-test-deployment-no-reload 61 | template: 62 | metadata: 63 | labels: 64 | app.kubernetes.io/name: reloader-test-deployment-no-reload 65 | annotations: 66 | secrets-webhook.security.bank-vaults.io/provider: "vault" 67 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 68 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 69 | spec: 70 | containers: 71 | - name: alpine 72 | image: alpine 73 | command: 74 | - "sh" 75 | - "-c" 76 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 77 | env: 78 | - name: AWS_SECRET_ACCESS_KEY 79 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 80 | - name: MYSQL_PASSWORD 81 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 82 | resources: 83 | limits: 84 | memory: "128Mi" 85 | cpu: "100m" 86 | 87 | --- 88 | 89 | apiVersion: apps/v1 90 | kind: Deployment 91 | metadata: 92 | name: reloader-test-deployment-fixed-versions-no-reload 93 | spec: 94 | replicas: 1 95 | selector: 96 | matchLabels: 97 | app.kubernetes.io/name: reloader-test-deployment-fixed-versions-no-reload 98 | template: 99 | metadata: 100 | labels: 101 | app.kubernetes.io/name: reloader-test-deployment-fixed-versions-no-reload 102 | annotations: 103 | secrets-webhook.security.bank-vaults.io/provider: "vault" 104 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 105 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 106 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 107 | spec: 108 | containers: 109 | - name: alpine 110 | image: alpine 111 | command: 112 | - "sh" 113 | - "-c" 114 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 115 | env: 116 | - name: AWS_SECRET_ACCESS_KEY 117 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY#1 118 | - name: MYSQL_PASSWORD 119 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD}#1 120 | resources: 121 | limits: 122 | memory: "128Mi" 123 | cpu: "100m" 124 | 125 | --- 126 | 127 | apiVersion: apps/v1 128 | kind: Deployment 129 | metadata: 130 | name: reloader-test-deployment-annotated-reload 131 | spec: 132 | replicas: 1 133 | selector: 134 | matchLabels: 135 | app.kubernetes.io/name: reloader-test-deployment-annotated-reload 136 | template: 137 | metadata: 138 | labels: 139 | app.kubernetes.io/name: reloader-test-deployment-annotated-reload 140 | annotations: 141 | secrets-webhook.security.bank-vaults.io/provider: "vault" 142 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 143 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 144 | secrets-webhook.security.bank-vaults.io/vault-from-path: "secret/data/accounts/aws" 145 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 146 | spec: 147 | containers: 148 | - name: alpine 149 | image: alpine 150 | command: 151 | - "sh" 152 | - "-c" 153 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 154 | env: 155 | - name: AWS_SECRET_ACCESS_KEY 156 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY#1 157 | - name: MYSQL_PASSWORD 158 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD}#1 159 | resources: 160 | limits: 161 | memory: "128Mi" 162 | cpu: "100m" 163 | 164 | --- 165 | 166 | apiVersion: apps/v1 167 | kind: Deployment 168 | metadata: 169 | name: reloader-test-deployment-annotated-no-reload 170 | spec: 171 | replicas: 1 172 | selector: 173 | matchLabels: 174 | app.kubernetes.io/name: reloader-test-deployment-annotated-no-reload 175 | template: 176 | metadata: 177 | labels: 178 | app.kubernetes.io/name: reloader-test-deployment-annotated-no-reload 179 | annotations: 180 | secrets-webhook.security.bank-vaults.io/provider: "vault" 181 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 182 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 183 | secrets-webhook.security.bank-vaults.io/vault-from-path: "secret/data/dockerrepo#1" 184 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 185 | spec: 186 | containers: 187 | - name: alpine 188 | image: alpine 189 | command: 190 | - "sh" 191 | - "-c" 192 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 193 | env: 194 | - name: AWS_SECRET_ACCESS_KEY 195 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY#1 196 | - name: MYSQL_PASSWORD 197 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD}#1 198 | resources: 199 | limits: 200 | memory: "128Mi" 201 | cpu: "100m" 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 2 | 3 | export PATH := $(abspath bin/):${PATH} 4 | 5 | # Setting SHELL to bash allows bash commands to be executed by recipes. 6 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 7 | SHELL = /usr/bin/env bash -o pipefail 8 | .SHELLFLAGS = -ec 9 | 10 | # Default values for environment variables used in the Makefile 11 | KUBECONFIG ?= $(HOME)/.kube/config 12 | TEST_KIND_CLUSTER ?= vault-secrets-reloader 13 | # Target image name 14 | CONTAINER_IMAGE_REF = ghcr.io/bank-vaults/vault-secrets-reloader:dev 15 | 16 | # Operator and Webhook image name 17 | OPERATOR_VERSION ?= latest 18 | WEBHOOK_VERSION ?= latest 19 | 20 | ##@ General 21 | 22 | # Targets commented with ## will be visible in "make help" info. 23 | # Comments marked with ##@ will be used as categories for a group of targets. 24 | 25 | .PHONY: help 26 | default: help 27 | help: ## Display this help 28 | @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) 29 | 30 | ##@ Development 31 | 32 | .PHONY: up-kind 33 | up-kind: create-kind up ## Start kind development environment 34 | 35 | .PHONY: create-kind 36 | create-kind: ## Create kind cluster 37 | $(KIND_BIN) create cluster --name $(TEST_KIND_CLUSTER) 38 | 39 | .PHONY: up 40 | up: ## Start development environment 41 | $(HELM_BIN) upgrade --install vault-operator oci://ghcr.io/bank-vaults/helm-charts/vault-operator \ 42 | --set image.tag=latest \ 43 | --set image.bankVaultsTag=latest \ 44 | --wait 45 | kubectl create namespace bank-vaults-infra --dry-run=client -o yaml | kubectl apply -f - 46 | kubectl apply -f $(shell pwd)/e2e/deploy/vault/ 47 | sleep 60 48 | $(HELM_BIN) upgrade --install secrets-webhook oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook \ 49 | --set replicaCount=1 \ 50 | --set image.tag=latest \ 51 | --set image.pullPolicy=IfNotPresent \ 52 | --set podsFailurePolicy=Fail \ 53 | --set secretInit.tag=latest \ 54 | --namespace bank-vaults-infra 55 | 56 | .PHONY: down 57 | down: ## Destroy kind development environment 58 | $(KIND_BIN) delete cluster --name $(TEST_KIND_CLUSTER) 59 | 60 | .PHONY: run 61 | run: ## Run manager from your host 62 | go run main.go -log-level=debug -collector-sync-period=30s -reloader-run-period=1m 63 | 64 | ##@ Build 65 | 66 | .PHONY: build 67 | build: ## Build manager binary 68 | @mkdir -p build 69 | go build -race -o build/vault-secrets-reloader . 70 | 71 | .PHONY: artifacts 72 | artifacts: container-image helm-chart ## Build artifacts 73 | 74 | .PHONY: container-image 75 | container-image: ## Build docker image 76 | docker build -t ${CONTAINER_IMAGE_REF} . 77 | 78 | .PHONY: helm-chart 79 | helm-chart: ## Build Helm chart 80 | @mkdir -p build 81 | helm package -d build/ deploy/charts/vault-secrets-reloader 82 | 83 | ##@ Checks 84 | 85 | .PHONY: check 86 | check: test lint ## Run lint checks and tests 87 | 88 | .PHONY: test 89 | test: ## Run tests 90 | go clean -testcache 91 | go test -race -v ./... 92 | 93 | .PHONY: test-e2e 94 | test-e2e: ## Run e2e tests 95 | go clean -testcache 96 | go test -race -v -timeout 900s -tags e2e ./e2e 97 | 98 | .PHONY: test-e2e-local 99 | test-e2e-local: container-image ## Run e2e tests locally 100 | go clean -testcache 101 | LOAD_IMAGE=${CONTAINER_IMAGE_REF} RELOADER_VERSION=dev OPERATOR_VERSION=$(OPERATOR_VERSION) WEBHOOK_VERSION=$(WEBHOOK_VERSION) LOG_VERBOSE=true ${MAKE} test-e2e 102 | 103 | .PHONY: lint 104 | lint: lint-go lint-helm lint-docker lint-yaml ## Run lint checks 105 | 106 | .PHONY: lint-go 107 | lint-go: # Run golang lint check 108 | $(GOLANGCI_LINT_BIN) run 109 | 110 | .PHONY: lint-helm 111 | lint-helm: # Run helm lint check 112 | $(HELM_BIN) lint deploy/charts/vault-secrets-reloader 113 | 114 | .PHONY: lint-docker 115 | lint-docker: # Run Dockerfile lint check 116 | $(HADOLINT_BIN) Dockerfile 117 | 118 | .PHONY: lint-yaml 119 | lint-yaml: 120 | $(YAMLLINT_BIN) $(if ${CI},-f github,) --no-warnings . 121 | 122 | .PHONY: fmt 123 | fmt: ## Run go fmt against code 124 | $(GOLANGCI_LINT_BIN) run --fix 125 | 126 | .PHONY: license-cache 127 | license-cache: ## Populate license cache 128 | $(LICENSEI_BIN) cache 129 | 130 | .PHONY: license-check 131 | license-check: ## Run license check 132 | $(LICENSEI_BIN) check 133 | $(LICENSEI_BIN) header 134 | 135 | ##@ Autogeneration 136 | 137 | .PHONY: generate 138 | generate: gen-helm-docs ## Generate manifests, code, and docs resources 139 | 140 | .PHONY: gen-helm-docs 141 | gen-helm-docs: ## Generate Helm chart documentation 142 | $(HELM_DOCS_BIN) -s file -c deploy/charts/ -t README.md.gotmpl 143 | 144 | ##@ Deployment 145 | 146 | .PHONY: deploy-kind 147 | deploy-kind: upload-kind deploy ## Deploy Reloder controller resources to the kind cluster 148 | 149 | .PHONY: upload-kind 150 | upload-kind: 151 | $(KIND_BIN) load docker-image ${CONTAINER_IMAGE_REF} --name $(TEST_KIND_CLUSTER) ## Load docker image to kind cluster 152 | 153 | .PHONY: deploy 154 | deploy: ## Deploy Reloader controller resources to the K8s cluster 155 | kubectl create namespace bank-vaults-infra --dry-run=client -o yaml | kubectl apply -f - 156 | $(HELM_BIN) upgrade --install vault-secrets-reloader deploy/charts/vault-secrets-reloader \ 157 | --set image.tag=dev \ 158 | --set collectorSyncPeriod=30s \ 159 | --set reloaderRunPeriod=1m \ 160 | --set env.VAULT_ROLE=reloader \ 161 | --set env.VAULT_ADDR=https://vault.default.svc.cluster.local:8200 \ 162 | --set env.VAULT_TLS_SECRET=vault-tls \ 163 | --set env.VAULT_TLS_SECRET_NS=bank-vaults-infra \ 164 | --namespace bank-vaults-infra 165 | 166 | .PHONY: undeploy 167 | undeploy: ## Clean manager resources from the K8s cluster. 168 | $(HELM_BIN) uninstall vault-secrets-reloader --namespace bank-vaults-infra 169 | 170 | ##@ Dependencies 171 | 172 | deps: bin/golangci-lint bin/licensei bin/kind bin/helm bin/helm-docs 173 | deps: ## Install dependencies 174 | 175 | # Dependency versions 176 | GOLANGCI_LINT_VERSION = 2.7.2 177 | LICENSEI_VERSION = 0.9.0 178 | KIND_VERSION = 0.30.0 179 | HELM_VERSION = 4.0.1 180 | HELM_DOCS_VERSION = 1.14.2 181 | 182 | # Dependency binaries 183 | GOLANGCI_LINT_BIN := golangci-lint 184 | LICENSEI_BIN := licensei 185 | KIND_BIN := kind 186 | HELM_BIN := helm 187 | HELM_DOCS_BIN := helm-docs 188 | 189 | # TODO: add support for hadolint and yamllint dependencies 190 | HADOLINT_BIN := hadolint 191 | YAMLLINT_BIN := yamllint 192 | # If we have "bin" dir, use those binaries instead 193 | ifneq ($(wildcard ./bin/.),) 194 | GOLANGCI_LINT_BIN := bin/$(GOLANGCI_LINT_BIN) 195 | LICENSEI_BIN := bin/$(LICENSEI_BIN) 196 | KIND_BIN := bin/$(KIND_BIN) 197 | HELM_BIN := bin/$(HELM_BIN) 198 | HELM_DOCS_BIN := bin/$(HELM_DOCS_BIN) 199 | endif 200 | 201 | bin/golangci-lint: 202 | @mkdir -p bin 203 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- v${GOLANGCI_LINT_VERSION} 204 | 205 | bin/licensei: 206 | @mkdir -p bin 207 | curl -sfL https://raw.githubusercontent.com/goph/licensei/master/install.sh | bash -s -- v${LICENSEI_VERSION} 208 | bin/kind: 209 | @mkdir -p bin 210 | curl -Lo bin/kind https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-$(shell uname -s | tr '[:upper:]' '[:lower:]')-$(shell uname -m | sed -e "s/aarch64/arm64/; s/x86_64/amd64/") 211 | @chmod +x bin/kind 212 | 213 | bin/helm: 214 | @mkdir -p bin 215 | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | USE_SUDO=false HELM_INSTALL_DIR=bin DESIRED_VERSION=v$(HELM_VERSION) bash 216 | @chmod +x bin/helm 217 | 218 | bin/helm-docs: 219 | @mkdir -p bin 220 | curl -L https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_$(shell uname)_x86_64.tar.gz | tar -zOxf - helm-docs > ./bin/helm-docs 221 | @chmod +x bin/helm-docs 222 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bank-vaults/vault-secrets-reloader 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/bank-vaults/secrets-webhook v0.3.1 7 | github.com/bank-vaults/vault-operator v1.23.4 8 | github.com/bank-vaults/vault-sdk v0.11.2 9 | github.com/hashicorp/vault/api v1.22.0 10 | github.com/samber/slog-multi v1.6.0 11 | github.com/stretchr/testify v1.11.1 12 | k8s.io/api v0.35.0 13 | k8s.io/apiextensions-apiserver v0.35.0 14 | k8s.io/apimachinery v0.35.0 15 | k8s.io/client-go v0.35.0 16 | k8s.io/klog/v2 v2.130.1 17 | sigs.k8s.io/controller-runtime v0.22.4 18 | sigs.k8s.io/e2e-framework v0.6.0 19 | ) 20 | 21 | require ( 22 | cloud.google.com/go/auth v0.17.0 // indirect 23 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 24 | cloud.google.com/go/compute/metadata v0.9.0 // indirect 25 | cloud.google.com/go/iam v1.5.3 // indirect 26 | emperror.dev/errors v0.8.1 // indirect 27 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 28 | github.com/aws/aws-sdk-go v1.55.8 // indirect 29 | github.com/beorn7/perks v1.0.1 // indirect 30 | github.com/blang/semver/v4 v4.0.0 // indirect 31 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 32 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 34 | github.com/distribution/reference v0.6.0 // indirect 35 | github.com/emicklei/go-restful/v3 v3.13.0 // indirect 36 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 37 | github.com/fatih/color v1.18.0 // indirect 38 | github.com/felixge/httpsnoop v1.0.4 // indirect 39 | github.com/fsnotify/fsnotify v1.9.0 // indirect 40 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 41 | github.com/go-jose/go-jose/v4 v4.1.3 // indirect 42 | github.com/go-logr/logr v1.4.3 // indirect 43 | github.com/go-logr/stdr v1.2.2 // indirect 44 | github.com/go-openapi/jsonpointer v0.22.4 // indirect 45 | github.com/go-openapi/jsonreference v0.21.4 // indirect 46 | github.com/go-openapi/swag v0.25.4 // indirect 47 | github.com/go-openapi/swag/cmdutils v0.25.4 // indirect 48 | github.com/go-openapi/swag/conv v0.25.4 // indirect 49 | github.com/go-openapi/swag/fileutils v0.25.4 // indirect 50 | github.com/go-openapi/swag/jsonname v0.25.4 // indirect 51 | github.com/go-openapi/swag/jsonutils v0.25.4 // indirect 52 | github.com/go-openapi/swag/loading v0.25.4 // indirect 53 | github.com/go-openapi/swag/mangling v0.25.4 // indirect 54 | github.com/go-openapi/swag/netutils v0.25.4 // indirect 55 | github.com/go-openapi/swag/stringutils v0.25.4 // indirect 56 | github.com/go-openapi/swag/typeutils v0.25.4 // indirect 57 | github.com/go-openapi/swag/yamlutils v0.25.4 // indirect 58 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 59 | github.com/google/btree v1.1.3 // indirect 60 | github.com/google/gnostic-models v0.7.1 // indirect 61 | github.com/google/go-cmp v0.7.0 // indirect 62 | github.com/google/s2a-go v0.1.9 // indirect 63 | github.com/google/uuid v1.6.0 // indirect 64 | github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect 65 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 66 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 67 | github.com/hashicorp/errwrap v1.1.0 // indirect 68 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 69 | github.com/hashicorp/go-hclog v1.6.3 // indirect 70 | github.com/hashicorp/go-multierror v1.1.1 // indirect 71 | github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 72 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 73 | github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect 74 | github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 75 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 76 | github.com/hashicorp/go-sockaddr v1.0.7 // indirect 77 | github.com/hashicorp/go-uuid v1.0.3 // indirect 78 | github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 79 | github.com/hashicorp/vault/api/auth/aws v0.11.0 // indirect 80 | github.com/hashicorp/vault/api/auth/azure v0.10.0 // indirect 81 | github.com/hashicorp/vault/api/auth/gcp v0.11.0 // indirect 82 | github.com/hashicorp/vault/api/auth/kubernetes v0.10.0 // indirect 83 | github.com/imdario/mergo v1.0.1 // indirect 84 | github.com/jmespath/go-jmespath v0.4.0 // indirect 85 | github.com/json-iterator/go v1.1.12 // indirect 86 | github.com/mattn/go-colorable v0.1.14 // indirect 87 | github.com/mattn/go-isatty v0.0.20 // indirect 88 | github.com/mitchellh/go-homedir v1.1.0 // indirect 89 | github.com/mitchellh/mapstructure v1.5.0 // indirect 90 | github.com/moby/spdystream v0.5.0 // indirect 91 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 92 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 93 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 94 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 95 | github.com/opencontainers/go-digest v1.0.0 // indirect 96 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 97 | github.com/pkg/errors v0.9.1 // indirect 98 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 99 | github.com/prometheus/client_golang v1.23.2 // indirect 100 | github.com/prometheus/client_model v0.6.2 // indirect 101 | github.com/prometheus/common v0.67.4 // indirect 102 | github.com/prometheus/procfs v0.19.2 // indirect 103 | github.com/ryanuber/go-glob v1.0.0 // indirect 104 | github.com/sagikazarmark/docker-ref v0.2.0 // indirect 105 | github.com/sagikazarmark/locafero v0.12.0 // indirect 106 | github.com/samber/lo v1.52.0 // indirect 107 | github.com/samber/slog-common v0.19.0 // indirect 108 | github.com/spf13/afero v1.15.0 // indirect 109 | github.com/spf13/cast v1.10.0 // indirect 110 | github.com/spf13/pflag v1.0.10 // indirect 111 | github.com/spf13/viper v1.21.0 // indirect 112 | github.com/subosito/gotenv v1.6.0 // indirect 113 | github.com/vladimirvivien/gexe v0.4.1 // indirect 114 | github.com/x448/float16 v0.8.4 // indirect 115 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 116 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect 117 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect 118 | go.opentelemetry.io/otel v1.39.0 // indirect 119 | go.opentelemetry.io/otel/metric v1.39.0 // indirect 120 | go.opentelemetry.io/otel/trace v1.39.0 // indirect 121 | go.uber.org/multierr v1.11.0 // indirect 122 | go.yaml.in/yaml/v2 v2.4.3 // indirect 123 | go.yaml.in/yaml/v3 v3.0.4 // indirect 124 | golang.org/x/crypto v0.46.0 // indirect 125 | golang.org/x/net v0.48.0 // indirect 126 | golang.org/x/oauth2 v0.34.0 // indirect 127 | golang.org/x/sync v0.19.0 // indirect 128 | golang.org/x/sys v0.39.0 // indirect 129 | golang.org/x/term v0.38.0 // indirect 130 | golang.org/x/text v0.32.0 // indirect 131 | golang.org/x/time v0.14.0 // indirect 132 | gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect 133 | google.golang.org/api v0.257.0 // indirect 134 | google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect 135 | google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect 136 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect 137 | google.golang.org/grpc v1.77.0 // indirect 138 | google.golang.org/protobuf v1.36.10 // indirect 139 | gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 140 | gopkg.in/inf.v0 v0.9.1 // indirect 141 | gopkg.in/yaml.v3 v3.0.1 // indirect 142 | k8s.io/component-base v0.35.0 // indirect 143 | k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect 144 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 145 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 146 | sigs.k8s.io/randfill v1.0.0 // indirect 147 | sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect 148 | sigs.k8s.io/yaml v1.6.0 // indirect 149 | ) 150 | 151 | replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16 152 | -------------------------------------------------------------------------------- /pkg/reloader/controller.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Cisco 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package reloader 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "log/slog" 21 | "time" 22 | 23 | vaultapi "github.com/hashicorp/vault/api" 24 | appsv1 "k8s.io/api/apps/v1" 25 | corev1 "k8s.io/api/core/v1" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 28 | "k8s.io/apimachinery/pkg/util/wait" 29 | appsinformers "k8s.io/client-go/informers/apps/v1" 30 | "k8s.io/client-go/kubernetes" 31 | appslisters "k8s.io/client-go/listers/apps/v1" 32 | "k8s.io/client-go/tools/cache" 33 | ) 34 | 35 | const ( 36 | DeploymentKind = "Deployment" 37 | DaemonSetKind = "DaemonSet" 38 | StatefulSetKind = "StatefulSet" 39 | 40 | SecretReloadAnnotationName = "secrets-reloader.security.bank-vaults.io/reload-on-secret-change" 41 | ReloadCountAnnotationName = "secrets-reloader.security.bank-vaults.io/secret-reload-count" 42 | ) 43 | 44 | // Controller is the controller implementation for Foo resources 45 | type Controller struct { 46 | kubeClient kubernetes.Interface 47 | vaultClient *vaultapi.Client 48 | vaultConfig *VaultConfig 49 | logger *slog.Logger 50 | 51 | deploymentsLister appslisters.DeploymentLister 52 | deploymentsSynced cache.InformerSynced 53 | daemonSetsSynced cache.InformerSynced 54 | daemonSetsLister appslisters.DaemonSetLister 55 | statefulSetsLister appslisters.StatefulSetLister 56 | statefulSetsSynced cache.InformerSynced 57 | 58 | // workloadSecrets map[Workload][]string 59 | workloadSecrets workloadSecretsStore 60 | secretVersions map[string]int 61 | } 62 | 63 | // NewController returns a new sample controller 64 | func NewController( 65 | logger *slog.Logger, 66 | kubeClient kubernetes.Interface, 67 | deploymentInformer appsinformers.DeploymentInformer, 68 | daemonSetInformer appsinformers.DaemonSetInformer, 69 | statefulSetInformer appsinformers.StatefulSetInformer, 70 | ) *Controller { 71 | controller := &Controller{ 72 | kubeClient: kubeClient, 73 | logger: logger, 74 | deploymentsLister: deploymentInformer.Lister(), 75 | deploymentsSynced: deploymentInformer.Informer().HasSynced, 76 | daemonSetsLister: daemonSetInformer.Lister(), 77 | daemonSetsSynced: daemonSetInformer.Informer().HasSynced, 78 | statefulSetsLister: statefulSetInformer.Lister(), 79 | statefulSetsSynced: deploymentInformer.Informer().HasSynced, 80 | workloadSecrets: newWorkloadSecrets(), 81 | secretVersions: make(map[string]int), 82 | } 83 | 84 | logger.Info("Setting up event handlers") 85 | 86 | // Set up event handlers for Deployments, DaemonSets and StatefulSets 87 | _, _ = deploymentInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ 88 | AddFunc: controller.handleObject, 89 | UpdateFunc: func(_, newObj interface{}) { controller.handleObject(newObj) }, 90 | DeleteFunc: controller.handleObjectDelete, 91 | }) 92 | 93 | _, _ = daemonSetInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ 94 | AddFunc: controller.handleObject, 95 | UpdateFunc: func(_, newObj interface{}) { controller.handleObject(newObj) }, 96 | DeleteFunc: controller.handleObjectDelete, 97 | }) 98 | 99 | _, _ = statefulSetInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ 100 | AddFunc: controller.handleObject, 101 | UpdateFunc: func(_, newObj interface{}) { controller.handleObject(newObj) }, 102 | DeleteFunc: controller.handleObjectDelete, 103 | }) 104 | 105 | return controller 106 | } 107 | 108 | // Run will set up the event handlers for types we are interested in, as well 109 | // as syncing informer caches and starting reloader worker. It will block until stopCh 110 | // is closed, at which point it will wait for the reloader to finish processing. 111 | func (c *Controller) Run(ctx context.Context, reloaderPeriod time.Duration) error { 112 | defer utilruntime.HandleCrash() 113 | 114 | // Start the informer factories to begin populating the informer caches 115 | c.logger.Info("Starting vault-secrets-reloader controller") 116 | 117 | // Wait for the caches to be synced before starting reloader 118 | c.logger.Info("Waiting for informer caches to sync") 119 | 120 | if !cache.WaitForCacheSync(ctx.Done(), c.deploymentsSynced, c.daemonSetsSynced, c.statefulSetsSynced) { 121 | return fmt.Errorf("failed to wait for caches to sync") 122 | } 123 | 124 | // Launch reloader to reload resources with changed secrets 125 | go wait.UntilWithContext(ctx, c.runReloader, reloaderPeriod) 126 | 127 | <-ctx.Done() 128 | c.logger.Info("Shutting down reloader") 129 | 130 | return nil 131 | } 132 | 133 | // handleObject will take any resource implementing metav1.Object and collects 134 | // Vault secret references from environment variables of their pod template to a 135 | // shared store if it is a workload and has the reload annotation set. 136 | func (c *Controller) handleObject(obj interface{}) { 137 | // Get required params from supported workloads 138 | var workloadData workload 139 | var podTemplateSpec corev1.PodTemplateSpec 140 | switch o := obj.(type) { 141 | case *appsv1.Deployment: 142 | workloadData = workload{name: o.Name, namespace: o.Namespace, kind: DeploymentKind} 143 | podTemplateSpec = o.Spec.Template 144 | 145 | case *appsv1.DaemonSet: 146 | workloadData = workload{name: o.Name, namespace: o.Namespace, kind: DaemonSetKind} 147 | podTemplateSpec = o.Spec.Template 148 | 149 | case *appsv1.StatefulSet: 150 | workloadData = workload{name: o.Name, namespace: o.Namespace, kind: StatefulSetKind} 151 | podTemplateSpec = o.Spec.Template 152 | 153 | default: 154 | // Unsupported workload 155 | c.logger.Error("error decoding object, invalid type") 156 | return 157 | } 158 | 159 | // Process workload, skip if reload annotation not present 160 | if podTemplateSpec.GetAnnotations()[SecretReloadAnnotationName] != "true" { 161 | return 162 | } 163 | c.logger.Debug(fmt.Sprintf("Processing workload: %#v", workloadData)) 164 | c.collectWorkloadSecrets(workloadData, podTemplateSpec) 165 | } 166 | 167 | // handleObjectDelete will take any resource implementing metav1.Object and deletes 168 | // it from the shared store if it is a workload and has the reload annotation set. 169 | func (c *Controller) handleObjectDelete(obj interface{}) { 170 | var object metav1.Object 171 | var ok bool 172 | if object, ok = obj.(metav1.Object); !ok { 173 | tombstone, ok := obj.(cache.DeletedFinalStateUnknown) 174 | if !ok { 175 | c.logger.Error("error decoding object, invalid type") 176 | return 177 | } 178 | object, ok = tombstone.Obj.(metav1.Object) 179 | if !ok { 180 | c.logger.Error("error decoding object tombstone, invalid type") 181 | return 182 | } 183 | c.logger.Debug(fmt.Sprintf("Recovered deleted object: %s", object.GetName())) 184 | } 185 | 186 | var workloadData workload 187 | var podTemplateSpec corev1.PodTemplateSpec 188 | switch o := object.(type) { 189 | case *appsv1.Deployment: 190 | workloadData = workload{name: o.GetName(), namespace: o.GetNamespace(), kind: DeploymentKind} 191 | podTemplateSpec = o.Spec.Template 192 | 193 | case *appsv1.DaemonSet: 194 | workloadData = workload{name: o.GetName(), namespace: o.GetNamespace(), kind: DaemonSetKind} 195 | podTemplateSpec = o.Spec.Template 196 | 197 | case *appsv1.StatefulSet: 198 | workloadData = workload{name: o.GetName(), namespace: o.GetNamespace(), kind: StatefulSetKind} 199 | podTemplateSpec = o.Spec.Template 200 | 201 | default: 202 | c.logger.Error("error decoding object, invalid type") 203 | return 204 | } 205 | 206 | // Delete workload, skip if reload annotation not present 207 | if podTemplateSpec.GetAnnotations()[SecretReloadAnnotationName] != "true" { 208 | return 209 | } 210 | c.logger.Debug(fmt.Sprintf("Deleting workload from store: %#v", workloadData)) 211 | c.workloadSecrets.Delete(workloadData) 212 | } 213 | -------------------------------------------------------------------------------- /examples/try-locally.md: -------------------------------------------------------------------------------- 1 | # Trying out Vault Secrets Reloader locally 2 | 3 | To gain experience in this tool, and to get familiar with the potential of the Bank-Vaults ecosystem, in this guidle we will: 4 | 5 | - start a [kind](https://kind.sigs.k8s.io/) cluster 6 | - install the [Vault Operator](https://github.com/bank-vaults/vault-operator), [Secrets Webhook](https://github.com/bank-vaults/secrets-webhook) and Vault Secrets Reloader all configured to work together nicely 7 | - start a Vault instance 8 | - deploy some workloads 9 | - try out some scenarios with the Reloader 10 | 11 | You only need Docker and the [Vault CLI](https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install#install-vault) to be installed! 12 | 13 | ## 1. Prepare the environment 14 | 15 | Clone the [repo](https://github.com/bank-vaults/vault-secrets-reloader) and `cd` into it. With only a few `make` commands, you will have a kind cluster running with the Bank-Vaults ecosystem, including the Reloader: 16 | 17 | ```bash 18 | # install dependencies 19 | make deps 20 | 21 | # start a kind cluster with Bank-Vaults operator, a Vault instance and Secrets Webhook 22 | make up-kind 23 | 24 | # build the Vault Secrets Reloader image 25 | make container-image 26 | 27 | # deploy Vault Secrets Reloader 28 | make deploy-kind 29 | ``` 30 | 31 | The last command will install the Reloader Helm chart with the following configuration: 32 | 33 | ```bash 34 | helm upgrade --install vault-secrets-reloader deploy/charts/vault-secrets-reloader \ 35 | --set image.tag=dev \ 36 | --set collectorSyncPeriod=30s \ 37 | --set reloaderRunPeriod=1m \ 38 | --set env.VAULT_ROLE=reloader \ 39 | --set env.VAULT_ADDR=https://vault.default.svc.cluster.local:8200 \ 40 | --set env.VAULT_TLS_SECRET=vault-tls \ 41 | --set env.VAULT_TLS_SECRET_NS=bank-vaults-infra \ 42 | --namespace bank-vaults-infra 43 | ``` 44 | 45 | Two important set of configurations are being set here: 46 | 47 | - Time periods for the `collector` and `reloader` - these are set to be very frequent here for the sake of not waiting too long during testing the tool. In real world scenarios they can be set to a higher value (depending on your requirements) not to spam the Kubernetes and Vault API with too many requests. 48 | - Environment variables necessary for the Reloader to create a client for communicating with our Vault instance. 49 | 50 | To trigger a new rollout for the affected workloads, you must be able to change secrets in Vault. If you followed the previous steps, you can export these environmental variables and port-forward the Vault pod to be manageable with the Vault CLI (that needs to be [installed](https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install#install-vault) separately): 51 | 52 | ```bash 53 | export VAULT_TOKEN=$(kubectl get secrets vault-unseal-keys -o jsonpath={.data.vault-root} | base64 --decode) 54 | 55 | kubectl get secret vault-tls -o jsonpath="{.data.ca\.crt}" | base64 --decode > $PWD/vault-ca.crt 56 | export VAULT_CACERT=$PWD/vault-ca.crt 57 | 58 | export VAULT_ADDR=https://127.0.0.1:8200 59 | 60 | kubectl port-forward service/vault 8200 & 61 | ``` 62 | 63 | ## 2. Deploy workloads 64 | 65 | Now that we have the Bank-Vaults ecosystem running in our kind cluster, we can deploy some workloads: 66 | 67 | ```bash 68 | # deploy some workloads 69 | kubectl apply -f e2e/deploy/workloads 70 | ``` 71 | 72 | Looking at the manifest of one of the deployments, the only difference from the one that is prepared to work with the Bank-Vaults Webhook with all the annotations starting with `secrets-webhook.security.bank-vaults.io` and the env values starting with `vault:` is the presence of the new `secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true"` annotation telling the Reloader to collect secrets and reload it if necessary. 73 | 74 | ```yaml 75 | apiVersion: apps/v1 76 | kind: Deployment 77 | metadata: 78 | name: reloader-test-deployment-to-be-reloaded 79 | spec: 80 | replicas: 1 81 | selector: 82 | matchLabels: 83 | app.kubernetes.io/name: reloader-test-deployment-to-be-reloaded 84 | template: 85 | metadata: 86 | labels: 87 | app.kubernetes.io/name: reloader-test-deployment-to-be-reloaded 88 | annotations: 89 | secrets-webhook.security.bank-vaults.io/provider: "vault" 90 | secrets-webhook.security.bank-vaults.io/vault-addr: "https://vault:8200" 91 | secrets-webhook.security.bank-vaults.io/vault-tls-secret: vault-tls 92 | secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true" 93 | spec: 94 | initContainers: 95 | - name: init-ubuntu 96 | image: ubuntu 97 | command: ["sh", "-c", "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo initContainers ready"] 98 | env: 99 | - name: AWS_SECRET_ACCESS_KEY 100 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 101 | - name: MYSQL_PASSWORD 102 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 103 | resources: 104 | limits: 105 | memory: "128Mi" 106 | cpu: "100m" 107 | containers: 108 | - name: alpine 109 | image: alpine 110 | command: 111 | - "sh" 112 | - "-c" 113 | - "echo $AWS_SECRET_ACCESS_KEY && echo $MYSQL_PASSWORD && echo going to sleep... && sleep 10000" 114 | env: 115 | - name: AWS_SECRET_ACCESS_KEY 116 | value: vault:secret/data/accounts/aws#AWS_SECRET_ACCESS_KEY 117 | - name: MYSQL_PASSWORD 118 | value: vault:secret/data/mysql#${.MYSQL_PASSWORD} 119 | resources: 120 | limits: 121 | memory: "128Mi" 122 | cpu: "100m" 123 | ``` 124 | 125 | ## 3. Put Reloader to the test 126 | 127 | To see the Reloader in action, first of all take a look at the logs to see information about which workload secrets are being collected and if any of them needs to be reloaded. 128 | 129 | ```bash 130 | # watch reloader logs 131 | kubectl logs -n bank-vaults-infra -l app.kubernetes.io/name=vault-secrets-reloader --follow 132 | ``` 133 | 134 | Now everything is set to try some things out with the Reloader: 135 | 136 | 1. Change a secret, observe the pods of the affected workloads ( `reloader-test-deployment-to-be-reloaded-xxx`, and `reloader-test-statefulset-0`) to be recreated (this might take up to a minute), check their logs for the updated secret. 137 | 138 | ```bash 139 | vault kv patch secret/mysql MYSQL_PASSWORD=totallydifferentsecret 140 | ``` 141 | 142 | Also notice that there are two pods with the now changed `MYSQL_PASSWORD` injected into them not being restarted, for the following reasons: 143 | 144 | - the pod `reloader-test-deployment-no-reload-xxx` does not have the `secrets-reloader.security.bank-vaults.io/reload-on-secret-change: "true"` annotation set 145 | - the pod `reloader-test-deployment-fixed-versions-no-reload-xxx` - although it does have the annotation - only uses versioned secrets, so they won't be reloaded for the latest version of the secret. 146 | 147 | 2. Change two secrets used in a workload, observe the previous pod to be recreated again, also that the pod `reloader-test-daemonset-xxx` only restarted once, although it uses both of these secrets. The number a workload got "reloaded" by the Reloader can be checked on the `secrets-reloader.security.bank-vaults.io/secret-reload-count` annotation that is used to trigger a new rollout. 148 | 149 | ```bash 150 | vault kv patch secret/accounts/aws AWS_SECRET_ACCESS_KEY=s3cr3t2 151 | vault kv patch secret/dockerrepo DOCKER_REPO_PASSWORD=dockerrepopassword2 152 | 153 | # check the reload count after the new rollout has been completed 154 | kubectl get po -l app.kubernetes.io/name=reloader-test-daemonset -o jsonpath='{ .items[*].metadata.annotations.alpha\.vault\.security\.banzaicloud\.io/secret-reload-count }' 155 | ``` 156 | 157 | 3. Update a workload to no longer have a secret, then change that secret, observe the workload not to be reloaded. This demonstrates that the collector worker keeps the list of watched workloads and secrets up-to-date whether they are newly created, updated or even removed. 158 | 159 | ```bash 160 | # delete MYSQL_PASSWORD from the initContainer and the container as well 161 | kubectl edit deployment reloader-test-deployment-to-be-reloaded 162 | 163 | vault kv patch secret/mysql MYSQL_PASSWORD=totallydifferentsecret2 164 | ``` 165 | 166 | 4. Remove a secret from Vault, observe the error message in the logs of the Reloader. 167 | 168 | ```bash 169 | vault kv metadata delete secret/mysql 170 | 171 | # watch reloader logs, there should be similar error message soon: 172 | # time=xxx level=ERROR msg="Vault secret path secret/data/mysql not found" app=vault-secrets-reloader worker=reloader 173 | kubectl logs -n bank-vaults-infra -l app.kubernetes.io/name=vault-secrets-reloader --follow 174 | ``` 175 | 176 | You can tear down the test cluster with `make down` once you finished. 177 | -------------------------------------------------------------------------------- /e2e/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Bank-Vaults Maintainers 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build e2e 16 | 17 | package e2e 18 | 19 | import ( 20 | "context" 21 | "flag" 22 | "fmt" 23 | "os" 24 | "strings" 25 | "testing" 26 | "time" 27 | 28 | appsv1 "k8s.io/api/apps/v1" 29 | v1 "k8s.io/api/core/v1" 30 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 31 | klog "k8s.io/klog/v2" 32 | "sigs.k8s.io/controller-runtime/pkg/log" 33 | "sigs.k8s.io/e2e-framework/klient/conf" 34 | "sigs.k8s.io/e2e-framework/klient/decoder" 35 | "sigs.k8s.io/e2e-framework/klient/k8s/resources" 36 | "sigs.k8s.io/e2e-framework/klient/wait" 37 | "sigs.k8s.io/e2e-framework/klient/wait/conditions" 38 | "sigs.k8s.io/e2e-framework/pkg/env" 39 | "sigs.k8s.io/e2e-framework/pkg/envconf" 40 | "sigs.k8s.io/e2e-framework/pkg/envfuncs" 41 | "sigs.k8s.io/e2e-framework/support/kind" 42 | "sigs.k8s.io/e2e-framework/third_party/helm" 43 | ) 44 | 45 | const defaultTimeout = 3 * time.Minute 46 | 47 | var testenv env.Environment 48 | 49 | func TestMain(m *testing.M) { 50 | // See https://github.com/kubernetes-sigs/e2e-framework/issues/269 51 | // testenv = env.New() 52 | testenv = &reverseFinishEnvironment{Environment: env.New()} 53 | 54 | if os.Getenv("LOG_VERBOSE") == "true" { 55 | flags := flag.NewFlagSet("", flag.ContinueOnError) 56 | klog.InitFlags(flags) 57 | flags.Parse([]string{"-v", "4"}) 58 | } 59 | log.SetLogger(klog.NewKlogr()) 60 | 61 | bootstrap := strings.ToLower(os.Getenv("BOOTSTRAP")) != "false" 62 | useRealCluster := !bootstrap || strings.ToLower(os.Getenv("USE_REAL_CLUSTER")) == "true" 63 | 64 | // Set up cluster 65 | if useRealCluster { 66 | path := conf.ResolveKubeConfigFile() 67 | cfg := envconf.NewWithKubeConfig(path) 68 | 69 | if context := os.Getenv("USE_CONTEXT"); context != "" { 70 | cfg.WithKubeContext(context) 71 | } 72 | 73 | // See https://github.com/kubernetes-sigs/e2e-framework/issues/269 74 | // testenv = env.NewWithConfig(cfg) 75 | testenv = &reverseFinishEnvironment{Environment: env.NewWithConfig(cfg)} 76 | } else { 77 | clusterName := envconf.RandomName("vault-secrets-reloader-test", 32) 78 | 79 | kindCluster := kind.NewProvider() 80 | if v := os.Getenv("KIND_K8S_VERSION"); v != "" { 81 | // kindCluster = kindCluster.WithVersion(v) 82 | kindCluster.WithOpts(kind.WithImage("kindest/node:" + v)) 83 | } 84 | testenv.Setup(envfuncs.CreateClusterWithConfig(kindCluster, clusterName, "kind.yaml")) 85 | 86 | testenv.Finish(envfuncs.DestroyCluster(clusterName)) 87 | 88 | if image := os.Getenv("LOAD_IMAGE"); image != "" { 89 | testenv.Setup(envfuncs.LoadDockerImageToCluster(clusterName, image)) 90 | } 91 | 92 | if imageArchive := os.Getenv("LOAD_IMAGE_ARCHIVE"); imageArchive != "" { 93 | testenv.Setup(envfuncs.LoadImageArchiveToCluster(clusterName, imageArchive)) 94 | } 95 | } 96 | 97 | if bootstrap { 98 | // Install vault-operator 99 | testenv.Setup(installVaultOperator) 100 | testenv.Finish(uninstallVaultOperator) 101 | 102 | // Install webhook 103 | testenv.Setup(envfuncs.CreateNamespace("bank-vaults-infra"), installSecretsWebhook) 104 | testenv.Finish(uninstallSecretsWebhook, envfuncs.DeleteNamespace("bank-vaults-infra")) 105 | 106 | // Install reloader 107 | testenv.Setup(installVaultSecretsReloader) 108 | testenv.Finish(uninstallVaultSecretsReloader) 109 | 110 | // Unsealing and Vault access only works in the default namespace at the moment 111 | testenv.Setup(useNamespace("default")) 112 | 113 | testenv.Setup(installVault, waitForVaultTLS) 114 | testenv.Finish(uninstallVault) 115 | } else { 116 | // Unsealing and Vault access only works in the default namespace at the moment 117 | testenv.Setup(useNamespace("default")) 118 | } 119 | 120 | os.Exit(testenv.Run(m)) 121 | } 122 | 123 | func installVaultOperator(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 124 | manager := helm.New(cfg.KubeconfigFile()) 125 | 126 | version := "latest" 127 | if v := os.Getenv("OPERATOR_VERSION"); v != "" { 128 | version = v 129 | } 130 | 131 | err := manager.RunInstall( 132 | helm.WithName("vault-operator"), // This is weird that ReleaseName works differently, but it is what it is 133 | helm.WithChart("oci://ghcr.io/bank-vaults/helm-charts/vault-operator"), 134 | helm.WithNamespace("vault-operator"), 135 | helm.WithArgs("--create-namespace", "--set", "image.tag="+version), 136 | helm.WithWait(), 137 | helm.WithTimeout(defaultTimeout.String()), 138 | ) 139 | if err != nil { 140 | return ctx, fmt.Errorf("installing vault-operator: %w", err) 141 | } 142 | 143 | return ctx, nil 144 | } 145 | 146 | func uninstallVaultOperator(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 147 | manager := helm.New(cfg.KubeconfigFile()) 148 | 149 | err := manager.RunUninstall( 150 | helm.WithName("vault-operator"), 151 | helm.WithNamespace("default"), 152 | helm.WithWait(), 153 | helm.WithTimeout(defaultTimeout.String()), 154 | ) 155 | if err != nil { 156 | return ctx, fmt.Errorf("uninstalling vault-operator: %w", err) 157 | } 158 | 159 | return ctx, nil 160 | } 161 | 162 | func installSecretsWebhook(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 163 | manager := helm.New(cfg.KubeconfigFile()) 164 | 165 | version := "latest" 166 | if v := os.Getenv("WEBHOOK_VERSION"); v != "" { 167 | version = v 168 | } 169 | 170 | err := manager.RunInstall( 171 | helm.WithName("secrets-webhook"), 172 | helm.WithChart("oci://ghcr.io/bank-vaults/helm-charts/secrets-webhook"), 173 | helm.WithNamespace("bank-vaults-infra"), 174 | helm.WithArgs("--set", "replicaCount=1", "--set", "podsFailurePolicy=Fail", "--set", "secretInit.tag=latest", "--set", "image.tag="+version), 175 | helm.WithWait(), 176 | helm.WithTimeout(defaultTimeout.String()), 177 | ) 178 | if err != nil { 179 | return ctx, fmt.Errorf("installing secrets-webhook: %w", err) 180 | } 181 | 182 | return ctx, nil 183 | } 184 | 185 | func uninstallSecretsWebhook(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 186 | manager := helm.New(cfg.KubeconfigFile()) 187 | 188 | err := manager.RunUninstall( 189 | helm.WithName("secrets-webhook"), 190 | helm.WithNamespace("bank-vaults-infra"), 191 | helm.WithWait(), 192 | helm.WithTimeout(defaultTimeout.String()), 193 | ) 194 | if err != nil { 195 | return ctx, fmt.Errorf("uninstalling secrets-webhook: %w", err) 196 | } 197 | 198 | return ctx, nil 199 | } 200 | 201 | func installVaultSecretsReloader(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 202 | manager := helm.New(cfg.KubeconfigFile()) 203 | 204 | version := "latest" 205 | if v := os.Getenv("RELOADER_VERSION"); v != "" { 206 | version = v 207 | } 208 | 209 | chart := "../deploy/charts/vault-secrets-reloader/" 210 | if v := os.Getenv("HELM_CHART"); v != "" { 211 | chart = v 212 | } 213 | 214 | err := manager.RunInstall( 215 | helm.WithName("vault-secrets-reloader"), 216 | helm.WithChart(chart), 217 | helm.WithNamespace("bank-vaults-infra"), 218 | helm.WithArgs("--set", "image.tag="+version, "--set", "logLevel=debug", "--set", "collectorSyncPeriod=15s", "--set", "reloaderRunPeriod=15s", "--set", "env.VAULT_ROLE=reloader", "--set", "env.VAULT_ADDR=https://vault.default.svc.cluster.local:8200", "--set", "env.VAULT_TLS_SECRET=vault-tls", "--set", "env.VAULT_TLS_SECRET_NS=bank-vaults-infra"), 219 | helm.WithWait(), 220 | helm.WithTimeout(defaultTimeout.String()), 221 | ) 222 | if err != nil { 223 | return ctx, fmt.Errorf("installing vault-secrets-reloader: %w", err) 224 | } 225 | 226 | return ctx, nil 227 | } 228 | 229 | func uninstallVaultSecretsReloader(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 230 | manager := helm.New(cfg.KubeconfigFile()) 231 | 232 | err := manager.RunUninstall( 233 | helm.WithName("vault-secrets-reloader"), 234 | helm.WithNamespace("bank-vaults-infra"), 235 | helm.WithWait(), 236 | helm.WithTimeout(defaultTimeout.String()), 237 | ) 238 | if err != nil { 239 | return ctx, fmt.Errorf("uninstalling vault-secrets-reloader: %w", err) 240 | } 241 | 242 | return ctx, nil 243 | } 244 | 245 | func useNamespace(ns string) env.Func { 246 | return func(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 247 | cfg.WithNamespace(ns) 248 | 249 | return ctx, nil 250 | } 251 | } 252 | 253 | func installVault(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 254 | r, err := resources.New(cfg.Client().RESTConfig()) 255 | if err != nil { 256 | return ctx, err 257 | } 258 | 259 | err = decoder.DecodeEachFile( 260 | ctx, os.DirFS("deploy/vault"), "*", 261 | decoder.CreateHandler(r), 262 | decoder.MutateNamespace(cfg.Namespace()), 263 | ) 264 | if err != nil { 265 | return ctx, err 266 | } 267 | 268 | statefulSets := &appsv1.StatefulSetList{ 269 | Items: []appsv1.StatefulSet{ 270 | { 271 | ObjectMeta: metav1.ObjectMeta{Name: "vault", Namespace: cfg.Namespace()}, 272 | }, 273 | }, 274 | } 275 | 276 | // wait for the statefulSet to become available 277 | err = wait.For(conditions.New(r).ResourcesFound(statefulSets), wait.WithTimeout(defaultTimeout)) 278 | if err != nil { 279 | return ctx, err 280 | } 281 | 282 | time.Sleep(5 * time.Second) 283 | 284 | pod := v1.Pod{ 285 | ObjectMeta: metav1.ObjectMeta{Name: "vault-0", Namespace: cfg.Namespace()}, 286 | } 287 | 288 | // wait for the pod to become available 289 | err = wait.For(conditions.New(r).PodReady(&pod), wait.WithTimeout(defaultTimeout)) 290 | if err != nil { 291 | return ctx, err 292 | } 293 | 294 | return ctx, nil 295 | } 296 | 297 | func waitForVaultTLS(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 298 | vaultTLSSecrets := &v1.SecretList{ 299 | Items: []v1.Secret{ 300 | { 301 | ObjectMeta: metav1.ObjectMeta{Name: "vault-tls", Namespace: cfg.Namespace()}, 302 | }, 303 | }, 304 | } 305 | 306 | // wait for the vault-tls secret to become available 307 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourcesFound(vaultTLSSecrets), wait.WithTimeout(3*time.Minute)) 308 | if err != nil { 309 | return ctx, err 310 | } 311 | 312 | return ctx, nil 313 | } 314 | 315 | func uninstallVault(ctx context.Context, cfg *envconf.Config) (context.Context, error) { 316 | r, err := resources.New(cfg.Client().RESTConfig()) 317 | if err != nil { 318 | return ctx, err 319 | } 320 | 321 | err = decoder.DecodeEachFile( 322 | ctx, os.DirFS("deploy/vault"), "*", 323 | decoder.DeleteHandler(r), 324 | decoder.MutateNamespace(cfg.Namespace()), 325 | ) 326 | 327 | if err != nil { 328 | return ctx, err 329 | } 330 | 331 | return ctx, nil 332 | } 333 | 334 | type reverseFinishEnvironment struct { 335 | env.Environment 336 | 337 | finishFuncs []env.Func 338 | } 339 | 340 | // Finish registers funcs that are executed at the end of the test suite in a reverse order. 341 | func (e *reverseFinishEnvironment) Finish(f ...env.Func) env.Environment { 342 | e.finishFuncs = append(f[:], e.finishFuncs...) 343 | 344 | return e 345 | } 346 | 347 | // Run launches the test suite from within a TestMain. 348 | func (e *reverseFinishEnvironment) Run(m *testing.M) int { 349 | e.Environment.Finish(e.finishFuncs...) 350 | 351 | return e.Environment.Run(m) 352 | } 353 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /e2e/reloader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2023 Bank-Vaults Maintainers 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build e2e 16 | 17 | package e2e 18 | 19 | import ( 20 | "context" 21 | "encoding/json" 22 | "fmt" 23 | "os" 24 | "strings" 25 | "testing" 26 | "time" 27 | 28 | vaultV1alpha1 "github.com/bank-vaults/vault-operator/pkg/apis/vault/v1alpha1" 29 | vaultClientV1alpha1 "github.com/bank-vaults/vault-operator/pkg/client/clientset/versioned/typed/vault/v1alpha1" 30 | "github.com/bank-vaults/vault-secrets-reloader/pkg/reloader" 31 | "github.com/stretchr/testify/require" 32 | appsv1 "k8s.io/api/apps/v1" 33 | v1 "k8s.io/api/core/v1" 34 | extv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 35 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 36 | "k8s.io/apimachinery/pkg/types" 37 | "sigs.k8s.io/e2e-framework/klient/decoder" 38 | "sigs.k8s.io/e2e-framework/klient/k8s" 39 | "sigs.k8s.io/e2e-framework/klient/wait" 40 | "sigs.k8s.io/e2e-framework/klient/wait/conditions" 41 | "sigs.k8s.io/e2e-framework/pkg/envconf" 42 | "sigs.k8s.io/e2e-framework/pkg/features" 43 | ) 44 | 45 | func TestWorkloadReload(t *testing.T) { 46 | workloads := applyWorkloads(). 47 | WithStep("update secrets in Vault", features.Level(1), func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 48 | // create a patch to Vault to update startup secrets 49 | vaultPatch := &vaultV1alpha1.Vault{ 50 | ObjectMeta: metav1.ObjectMeta{Name: "vault", Namespace: cfg.Namespace()}, 51 | Spec: vaultV1alpha1.VaultSpec{ 52 | Config: extv1beta1.JSON{Raw: []byte("{\"ui\": true}")}, 53 | ExternalConfig: extv1beta1.JSON{Raw: []byte(getVaultPatch())}, 54 | }, 55 | Status: vaultV1alpha1.VaultStatus{ 56 | Nodes: []string{}, 57 | }, 58 | } 59 | 60 | vaultV1alpha1Client, err := vaultClientV1alpha1.NewForConfig(cfg.Client().RESTConfig()) 61 | require.NoError(t, err) 62 | 63 | vaultPatchJSON, err := json.Marshal(vaultPatch) 64 | require.NoError(t, err) 65 | 66 | _, err = vaultV1alpha1Client.Vaults(cfg.Namespace()).Patch(ctx, "vault", types.MergePatchType, vaultPatchJSON, metav1.PatchOptions{}) 67 | require.NoError(t, err) 68 | 69 | return ctx 70 | }). 71 | Assess("daemonset reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 72 | daemonSet := &appsv1.DaemonSet{ 73 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-daemonset", Namespace: cfg.Namespace()}, 74 | } 75 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(daemonSet, func(obj k8s.Object) bool { 76 | return obj.(*appsv1.DaemonSet).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "1" 77 | }), wait.WithTimeout(3*time.Minute)) 78 | require.NoError(t, err) 79 | 80 | return ctx 81 | }). 82 | Assess("statefulset reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 83 | statefulSet := &appsv1.StatefulSet{ 84 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-statefulset", Namespace: cfg.Namespace()}, 85 | } 86 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(statefulSet, func(obj k8s.Object) bool { 87 | return obj.(*appsv1.StatefulSet).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "1" 88 | }), wait.WithTimeout(3*time.Minute)) 89 | require.NoError(t, err) 90 | 91 | return ctx 92 | }). 93 | Assess("deployment to be reloaded is reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 94 | deployment := &appsv1.Deployment{ 95 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-to-be-reloaded", Namespace: cfg.Namespace()}, 96 | } 97 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(deployment, func(obj k8s.Object) bool { 98 | return obj.(*appsv1.Deployment).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "1" 99 | }), wait.WithTimeout(3*time.Minute)) 100 | require.NoError(t, err) 101 | 102 | return ctx 103 | }). 104 | Assess("deployment without reload annotation not reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 105 | deployment := &appsv1.Deployment{ 106 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-no-reload", Namespace: cfg.Namespace()}, 107 | } 108 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(deployment, func(obj k8s.Object) bool { 109 | return obj.(*appsv1.Deployment).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "" 110 | }), wait.WithTimeout(3*time.Minute)) 111 | require.NoError(t, err) 112 | 113 | return ctx 114 | }). 115 | Assess("deployment with fixed version secrets not reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 116 | deployment := &appsv1.Deployment{ 117 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-fixed-versions-no-reload", Namespace: cfg.Namespace()}, 118 | } 119 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(deployment, func(obj k8s.Object) bool { 120 | return obj.(*appsv1.Deployment).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "" 121 | }), wait.WithTimeout(3*time.Minute)) 122 | require.NoError(t, err) 123 | 124 | return ctx 125 | }). 126 | Assess("deployment witch secret path annotation reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 127 | deployment := &appsv1.Deployment{ 128 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-annotated-reload", Namespace: cfg.Namespace()}, 129 | } 130 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(deployment, func(obj k8s.Object) bool { 131 | return obj.(*appsv1.Deployment).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "1" 132 | }), wait.WithTimeout(3*time.Minute)) 133 | require.NoError(t, err) 134 | 135 | return ctx 136 | }). 137 | Assess("deployment witch secret path annotation not reloaded", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 138 | deployment := &appsv1.Deployment{ 139 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-annotated-no-reload", Namespace: cfg.Namespace()}, 140 | } 141 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(deployment, func(obj k8s.Object) bool { 142 | return obj.(*appsv1.Deployment).Spec.Template.Annotations[reloader.ReloadCountAnnotationName] == "" 143 | }), wait.WithTimeout(3*time.Minute)) 144 | require.NoError(t, err) 145 | 146 | return ctx 147 | }). 148 | Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 149 | err := decoder.DecodeEachFile( 150 | ctx, os.DirFS("deploy/workloads"), "*", 151 | decoder.DeleteHandler(cfg.Client().Resources()), 152 | decoder.MutateNamespace(cfg.Namespace()), 153 | ) 154 | require.NoError(t, err) 155 | 156 | return ctx 157 | }). 158 | Feature() 159 | 160 | testenv.Test(t, workloads) 161 | } 162 | 163 | func applyWorkloads() *features.FeatureBuilder { 164 | return features.New("workloads"). 165 | Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { 166 | err := decoder.DecodeEachFile( 167 | ctx, os.DirFS("deploy/workloads"), "*", 168 | decoder.CreateHandler(cfg.Client().Resources()), 169 | decoder.MutateNamespace(cfg.Namespace()), 170 | ) 171 | require.NoError(t, err) 172 | 173 | // workloadsAvailable fails on Github Runners, so waiting a little for the workloads to come up is fine for now 174 | // err = workloadsAvailable(cfg) 175 | // require.NoError(t, err) 176 | time.Sleep(2 * time.Minute) 177 | 178 | return ctx 179 | }) 180 | } 181 | 182 | func workloadsAvailable(cfg *envconf.Config) error { 183 | var errors []error 184 | 185 | // wait for the daemonset to become available 186 | daemonSet := &appsv1.DaemonSet{ 187 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-daemonset", Namespace: cfg.Namespace()}, 188 | } 189 | err := wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(daemonSet, func(obj k8s.Object) bool { 190 | return obj.(*appsv1.DaemonSet).Status.NumberReady == obj.(*appsv1.DaemonSet).Status.DesiredNumberScheduled 191 | }), wait.WithTimeout(10*time.Minute)) 192 | if err != nil { 193 | errors = append(errors, fmt.Errorf("reloader-test-daemonset: %w", err)) 194 | } 195 | 196 | // wait for the statefulset to become available 197 | statefulSet := &appsv1.StatefulSet{ 198 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-statefulset", Namespace: cfg.Namespace()}, 199 | } 200 | err = wait.For(conditions.New(cfg.Client().Resources()).ResourceMatch(statefulSet, func(obj k8s.Object) bool { 201 | return obj.(*appsv1.StatefulSet).Status.ReadyReplicas == *obj.(*appsv1.StatefulSet).Spec.Replicas 202 | }), wait.WithTimeout(10*time.Minute)) 203 | if err != nil { 204 | errors = append(errors, fmt.Errorf("reloader-test-statefulset: %w", err)) 205 | } 206 | 207 | // wait for the deployments to become available 208 | deploymentToBeReloaded := &appsv1.Deployment{ 209 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-to-be-reloaded", Namespace: cfg.Namespace()}, 210 | } 211 | err = wait.For(conditions.New(cfg.Client().Resources()).DeploymentConditionMatch(deploymentToBeReloaded, appsv1.DeploymentAvailable, v1.ConditionTrue), wait.WithTimeout(10*time.Minute)) 212 | if err != nil { 213 | errors = append(errors, fmt.Errorf("reloader-test-deployment-to-be-reloaded: %w", err)) 214 | } 215 | 216 | deploymentNoReload := &appsv1.Deployment{ 217 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-no-reload", Namespace: cfg.Namespace()}, 218 | } 219 | err = wait.For(conditions.New(cfg.Client().Resources()).DeploymentConditionMatch(deploymentNoReload, appsv1.DeploymentAvailable, v1.ConditionTrue), wait.WithTimeout(10*time.Minute)) 220 | if err != nil { 221 | errors = append(errors, fmt.Errorf("reloader-test-deployment-no-reload: %w", err)) 222 | } 223 | 224 | deploymentFixedVersionsNoReload := &appsv1.Deployment{ 225 | ObjectMeta: metav1.ObjectMeta{Name: "reloader-test-deployment-fixed-versions-no-reload", Namespace: cfg.Namespace()}, 226 | } 227 | err = wait.For(conditions.New(cfg.Client().Resources()).DeploymentConditionMatch(deploymentFixedVersionsNoReload, appsv1.DeploymentAvailable, v1.ConditionTrue), wait.WithTimeout(10*time.Minute)) 228 | if err != nil { 229 | errors = append(errors, fmt.Errorf("reloader-test-deployment-fixed-versions-no-reload: %w", err)) 230 | } 231 | 232 | if len(errors) == 0 { 233 | return nil 234 | } 235 | 236 | var errorStrings []string 237 | for _, err := range errors { 238 | errorStrings = append(errorStrings, err.Error()) 239 | } 240 | 241 | return fmt.Errorf("%s", strings.Join(errorStrings, ", ")) 242 | } 243 | 244 | func getVaultPatch() string { 245 | return `{ 246 | "startupSecrets": [ 247 | { 248 | "type": "kv", 249 | "path": "secret/data/accounts/aws", 250 | "data": { 251 | "data": { 252 | "AWS_ACCESS_KEY_ID": "secretId2", 253 | "AWS_SECRET_ACCESS_KEY": "s3cr3t2" 254 | } 255 | } 256 | }, 257 | { 258 | "type": "kv", 259 | "path": "secret/data/dockerrepo", 260 | "data": { 261 | "data": { 262 | "DOCKER_REPO_USER": "dockerrepouser2", 263 | "DOCKER_REPO_PASSWORD": "dockerrepopassword2" 264 | } 265 | } 266 | }, 267 | { 268 | "type": "kv", 269 | "path": "secret/data/mysql", 270 | "data": { 271 | "data": { 272 | "MYSQL_ROOT_PASSWORD": "s3cr3t2", 273 | "MYSQL_PASSWORD": "3xtr3ms3cr3t2" 274 | } 275 | } 276 | } 277 | ] 278 | }` 279 | } 280 | -------------------------------------------------------------------------------- /.github/workflows/artifacts.yaml: -------------------------------------------------------------------------------- 1 | name: Artifacts 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | publish: 7 | description: Publish artifacts to the artifact store 8 | default: false 9 | required: false 10 | type: boolean 11 | release: 12 | description: Whether this is a release build 13 | default: false 14 | required: false 15 | type: boolean 16 | outputs: 17 | container-image-name: 18 | description: Container image name 19 | value: ${{ jobs.container-image.outputs.name }} 20 | container-image-digest: 21 | description: Container image digest 22 | value: ${{ jobs.container-image.outputs.digest }} 23 | container-image-tag: 24 | description: Container image tag 25 | value: ${{ jobs.container-image.outputs.tag }} 26 | container-image-ref: 27 | description: Container image ref 28 | value: ${{ jobs.container-image.outputs.ref }} 29 | helm-chart-name: 30 | description: Helm chart OCI name 31 | value: ${{ jobs.helm-chart.outputs.name }} 32 | helm-chart-tag: 33 | description: Helm chart tag 34 | value: ${{ jobs.helm-chart.outputs.tag }} 35 | helm-chart-package: 36 | description: Helm chart package name 37 | value: ${{ jobs.helm-chart.outputs.package }} 38 | 39 | permissions: 40 | contents: read 41 | 42 | jobs: 43 | container-image: 44 | name: Container image 45 | runs-on: ubuntu-latest 46 | 47 | permissions: 48 | contents: read 49 | packages: write 50 | id-token: write 51 | security-events: write 52 | 53 | outputs: 54 | name: ${{ steps.image-name.outputs.value }} 55 | digest: ${{ steps.build.outputs.digest }} 56 | tag: ${{ steps.meta.outputs.version }} 57 | ref: ${{ steps.image-ref.outputs.value }} 58 | 59 | steps: 60 | - name: Checkout repository 61 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 62 | 63 | - name: Set up QEMU 64 | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 65 | 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 68 | 69 | - name: Set up Cosign 70 | uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 71 | if: ${{ inputs.publish }} 72 | 73 | - name: Set image name 74 | id: image-name 75 | run: echo "value=ghcr.io/${{ github.repository }}" >> "$GITHUB_OUTPUT" 76 | 77 | - name: Gather build metadata 78 | id: meta 79 | uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 80 | with: 81 | images: ${{ steps.image-name.outputs.value }} 82 | flavor: | 83 | latest = false 84 | tags: | 85 | type=ref,event=branch 86 | type=ref,event=pr,prefix=pr- 87 | type=semver,pattern={{raw}} 88 | type=raw,value=latest,enable={{is_default_branch}} 89 | 90 | # Multiple exporters are not supported yet 91 | # See https://github.com/moby/buildkit/pull/2760 92 | - name: Determine build output 93 | uses: haya14busa/action-cond@94f77f7a80cd666cb3155084e428254fea4281fd # v1.2.1 94 | id: build-output 95 | with: 96 | cond: ${{ inputs.publish }} 97 | if_true: type=image,push=true 98 | if_false: type=oci,dest=image.tar 99 | 100 | - name: Login to GitHub Container Registry 101 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 102 | with: 103 | registry: ghcr.io 104 | username: ${{ github.actor }} 105 | password: ${{ github.token }} 106 | if: inputs.publish 107 | 108 | - name: Build and push image 109 | id: build 110 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 111 | with: 112 | context: . 113 | platforms: linux/amd64,linux/arm64,linux/arm/v7 114 | tags: ${{ steps.meta.outputs.tags }} 115 | labels: ${{ steps.meta.outputs.labels }} 116 | cache-from: type=gha 117 | cache-to: type=gha,mode=max 118 | outputs: ${{ steps.build-output.outputs.value }} 119 | # push: ${{ inputs.publish }} 120 | 121 | - name: Sign image with GitHub OIDC Token 122 | if: ${{ inputs.publish && github.repository_owner == 'bank-vaults' }} # Check if the workflow is called by the same GitHub organization 123 | env: 124 | DIGEST: ${{ steps.build.outputs.digest }} 125 | TAGS: ${{ steps.meta.outputs.tags }} 126 | run: | 127 | images="" 128 | for tag in ${TAGS[@]}; do 129 | images+="${tag}@${DIGEST} " 130 | done 131 | 132 | cosign sign --yes --rekor-url "https://rekor.sigstore.dev/" ${images} 133 | 134 | - name: Verify signed image with cosign 135 | if: ${{ inputs.publish && github.repository_owner == 'bank-vaults' }} # Check if the workflow is called by the same GitHub organization 136 | env: 137 | DIGEST: ${{ steps.build.outputs.digest }} 138 | TAGS: ${{ steps.meta.outputs.tags }} 139 | run: | 140 | for tag in ${TAGS[@]}; do 141 | cosign verify "${tag}@${DIGEST}" \ 142 | --rekor-url "https://rekor.sigstore.dev/" \ 143 | --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/artifacts.yaml@${{ github.ref }}" \ 144 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" | jq 145 | done 146 | 147 | - name: Set image ref 148 | id: image-ref 149 | run: echo "value=${{ steps.image-name.outputs.value }}@${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT" 150 | 151 | - name: Fetch image 152 | run: skopeo --insecure-policy copy docker://${{ steps.image-name.outputs.value }}:${{ steps.meta.outputs.version }} oci-archive:image.tar 153 | if: inputs.publish 154 | 155 | - name: Upload image as artifact 156 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 157 | with: 158 | name: "[${{ github.job }}] OCI tarball" 159 | path: image.tar 160 | 161 | - name: Extract OCI tarball 162 | run: | 163 | mkdir -p image 164 | tar -xf image.tar -C image 165 | 166 | # See https://github.com/anchore/syft/issues/1545 167 | - name: Extract image from multi-arch image 168 | run: skopeo --override-os linux --override-arch amd64 --insecure-policy copy --additional-tag ${{ steps.image-name.outputs.value }}:${{ steps.meta.outputs.version }} oci:image docker-archive:docker.tar 169 | 170 | - name: Upload image as artifact 171 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 172 | with: 173 | name: "[${{ github.job }}] Docker tarball" 174 | path: docker.tar 175 | 176 | - name: Run Trivy vulnerability scanner 177 | uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 178 | with: 179 | input: image 180 | format: sarif 181 | output: trivy-results.sarif 182 | 183 | - name: Upload Trivy scan results as artifact 184 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 185 | with: 186 | name: "[${{ github.job }}] Trivy scan results" 187 | path: trivy-results.sarif 188 | retention-days: 5 189 | 190 | - name: Upload Trivy scan results to GitHub Security tab 191 | uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 192 | with: 193 | sarif_file: trivy-results.sarif 194 | 195 | helm-chart: 196 | name: Helm chart 197 | runs-on: ubuntu-latest 198 | 199 | permissions: 200 | contents: read 201 | packages: write 202 | id-token: write 203 | security-events: write 204 | 205 | outputs: 206 | name: ${{ steps.oci-chart-name.outputs.value }} 207 | tag: ${{ steps.version.outputs.value }} 208 | package: ${{ steps.build.outputs.package }} 209 | 210 | steps: 211 | - name: Checkout repository 212 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 213 | 214 | - name: Set up Helm 215 | uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 216 | with: 217 | version: v3.12.0 218 | 219 | - name: Set up Cosign 220 | uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 221 | if: ${{ inputs.publish }} 222 | 223 | - name: Set chart name 224 | id: chart-name 225 | run: echo "value=${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT" 226 | 227 | - name: Set OCI registry name 228 | id: oci-registry-name 229 | run: echo "value=ghcr.io/${{ github.repository_owner }}/helm-charts" >> "$GITHUB_OUTPUT" 230 | 231 | - name: Set OCI chart name 232 | id: oci-chart-name 233 | run: echo "value=${{ steps.oci-registry-name.outputs.value }}/${{ steps.chart-name.outputs.value }}" >> "$GITHUB_OUTPUT" 234 | 235 | - name: Helm lint 236 | run: helm lint deploy/charts/${{ steps.chart-name.outputs.value }} 237 | 238 | - name: Determine raw version 239 | uses: haya14busa/action-cond@94f77f7a80cd666cb3155084e428254fea4281fd # v1.2.1 240 | id: raw-version 241 | with: 242 | cond: ${{ inputs.release }} 243 | if_true: ${{ github.ref_name }} 244 | if_false: v0.0.0 245 | 246 | - name: Determine version 247 | id: version 248 | run: | 249 | VERSION=${{ steps.raw-version.outputs.value }} 250 | echo "value=${VERSION#v}" >> "$GITHUB_OUTPUT" 251 | 252 | - name: Helm package 253 | id: build 254 | run: | 255 | helm package deploy/charts/${{ steps.chart-name.outputs.value }} --version ${{ steps.version.outputs.value }} --app-version ${{ steps.raw-version.outputs.value }} 256 | echo "package=${{ steps.chart-name.outputs.value }}-${{ steps.version.outputs.value }}.tgz" >> "$GITHUB_OUTPUT" 257 | 258 | - name: Sign chart with GitHub OIDC Token 259 | if: ${{ inputs.publish && github.repository_owner == 'bank-vaults' }} # Check if the workflow is called by the same GitHub organization 260 | env: 261 | PACKAGE: ${{ steps.build.outputs.package }} 262 | run: | 263 | cosign sign-blob --yes $PACKAGE 264 | 265 | - name: Upload chart as artifact 266 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 267 | with: 268 | name: "[${{ github.job }}] Helm chart" 269 | path: ${{ steps.build.outputs.package }} 270 | 271 | - name: Login to GitHub Container Registry 272 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 273 | with: 274 | registry: ghcr.io 275 | username: ${{ github.actor }} 276 | password: ${{ github.token }} 277 | if: inputs.publish && inputs.release 278 | 279 | - name: Helm push 280 | id: push 281 | run: | 282 | helm push ${{ steps.build.outputs.package }} oci://${{ steps.oci-registry-name.outputs.value }} &> push-metadata.txt 283 | echo "digest=$(awk '/Digest: /{print $2}' push-metadata.txt)" >> "$GITHUB_OUTPUT" 284 | env: 285 | HELM_REGISTRY_CONFIG: ~/.docker/config.json 286 | if: inputs.publish && inputs.release 287 | 288 | - name: Sign chart with GitHub OIDC Token 289 | if: ${{ inputs.publish && inputs.release && github.repository_owner == 'bank-vaults' }} # Check if the workflow is called by the same GitHub organization 290 | env: 291 | DIGEST: ${{ steps.push.outputs.digest }} 292 | run: cosign sign --yes --rekor-url "https://rekor.sigstore.dev/" "${{ steps.oci-chart-name.outputs.value }}@${DIGEST}" 293 | 294 | - name: Verify signed chart with cosign 295 | if: ${{ inputs.publish && inputs.release && github.repository_owner == 'bank-vaults' }} # Check if the workflow is called by the same GitHub organization 296 | env: 297 | DIGEST: ${{ steps.push.outputs.digest }} 298 | run: | 299 | cosign verify "${{ steps.oci-chart-name.outputs.value }}@${DIGEST}" \ 300 | --rekor-url "https://rekor.sigstore.dev/" \ 301 | --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/artifacts.yaml@${{ github.ref }}" \ 302 | --certificate-oidc-issuer "https://token.actions.githubusercontent.com" | jq 303 | 304 | - name: Upload package as artifact 305 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 306 | with: 307 | name: "[${{ github.job }}] package" 308 | path: ${{ steps.build.outputs.package }} 309 | 310 | - name: Run Trivy vulnerability scanner 311 | uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 312 | with: 313 | scan-type: config 314 | scan-ref: deploy/charts/${{ steps.chart-name.outputs.value }} 315 | format: sarif 316 | output: trivy-results.sarif 317 | 318 | - name: Upload Trivy scan results as artifact 319 | uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 320 | with: 321 | name: "[${{ github.job }}] Trivy scan results" 322 | path: trivy-results.sarif 323 | retention-days: 5 324 | 325 | - name: Upload Trivy scan results to GitHub Security tab 326 | uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5 327 | with: 328 | sarif_file: trivy-results.sarif 329 | --------------------------------------------------------------------------------