├── config ├── webhook │ ├── manifests.yaml │ ├── kustomization.yaml │ ├── service.yaml │ └── kustomizeconfig.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── overlays │ ├── examples │ │ ├── kustomization.yaml │ │ └── namespace.yaml │ └── kind │ │ ├── kustomization.yaml │ │ └── manager_kind.yaml ├── certmanager │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── certificate.yaml ├── samples │ ├── kube-secret-syncer-ns.yaml │ └── secrets_v1_syncedsecret.yaml ├── rbac │ ├── role_binding.yaml │ ├── auth_proxy_role_binding.yaml │ ├── leader_election_role_binding.yaml │ ├── auth_proxy_role.yaml │ ├── kustomization.yaml │ ├── auth_proxy_service.yaml │ ├── leader_election_role.yaml │ └── role.yaml ├── crd │ ├── patches │ │ ├── cainjection_in_syncedsecrets.yaml │ │ └── webhook_in_syncedsecrets.yaml │ ├── kustomizeconfig.yaml │ ├── kustomization.yaml │ └── bases │ │ └── secrets.contentful.com_syncedsecrets.yaml └── default │ ├── manager_prometheus_metrics_patch.yaml │ ├── manager_webhook_patch.yaml │ ├── webhookcainjection_patch.yaml │ ├── manager_auth_proxy_patch.yaml │ └── kustomization.yaml ├── codecov.yml ├── .github └── CODEOWNERS ├── examples ├── v1_namespace_kube-secret-syncer.yaml ├── rbac.authorization.k8s.io_v1_clusterrolebinding_kube-secret-syncer-manager-rolebinding.yaml ├── rbac.authorization.k8s.io_v1_rolebinding_kube-secret-syncer-leader-election-rolebinding.yaml ├── rbac.authorization.k8s.io_v1_role_kube-secret-syncer-leader-election-role.yaml ├── rbac.authorization.k8s.io_v1_clusterrole_kube-secret-syncer-manager-role.yaml ├── apps_v1_deployment_kube-secret-syncer-controller.yaml └── apiextensions.k8s.io_v1beta1_customresourcedefinition_syncedsecrets.secrets.contentful.com.yaml ├── PROJECT ├── .dependabot └── config.yml ├── CHANGELOG.md ├── .gitignore ├── hack └── boilerplate.go.txt ├── pkg ├── iam │ ├── arn_client_cache.go │ ├── arn_test.go │ └── arn.go ├── namespacevalidator │ ├── validator.go │ └── validator_test.go ├── rolevalidator │ ├── validator.go │ └── validator_test.go ├── k8snamespace │ └── k8snamespace.go ├── secretsmanager │ ├── secrets.go │ ├── poller.go │ ├── secrets_test.go │ └── poller_test.go └── k8ssecret │ ├── secret.go │ └── secret_test.go ├── main_test.go ├── Dockerfile ├── api └── v1 │ ├── groupversion_info.go │ ├── syncedsecret_types.go │ └── zz_generated.deepcopy.go ├── docs └── development.md ├── .circleci └── config.yml ├── Makefile ├── go.mod ├── main.go ├── README.md ├── controllers ├── suite_test.go ├── syncedsecret_controller.go └── syncedsecret_controller_test.go └── LICENSE /config/webhook/manifests.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 55..100 3 | round: up 4 | precision: 1 5 | status: 6 | patch: off -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | commonLabels: 4 | app: kube-secret-syncer 5 | -------------------------------------------------------------------------------- /config/overlays/examples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | bases: 2 | - ../../default 3 | 4 | resources: 5 | - namespace.yaml -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lewiscowper @juliabiro @phoebeschmidt @loctherapy @omegion @yannh @cakejelly @m99coder @kiyutink 2 | -------------------------------------------------------------------------------- /config/overlays/examples/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: kube-secret-syncer 5 | -------------------------------------------------------------------------------- /config/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - certificate.yaml 3 | 4 | configurations: 5 | - kustomizeconfig.yaml 6 | -------------------------------------------------------------------------------- /config/overlays/kind/kustomization.yaml: -------------------------------------------------------------------------------- 1 | bases: 2 | - ../../default 3 | 4 | patchesStrategicMerge: 5 | - manager_kind.yaml -------------------------------------------------------------------------------- /config/samples/kube-secret-syncer-ns.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: kube-secret-syncer 5 | -------------------------------------------------------------------------------- /examples/v1_namespace_kube-secret-syncer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: kube-secret-syncer 5 | -------------------------------------------------------------------------------- /config/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | version: "2" 2 | domain: contentful.com 3 | repo: github.com/contentful-labs/kube-secret-syncer 4 | resources: 5 | - group: secrets 6 | version: v1 7 | kind: SyncedSecret 8 | -------------------------------------------------------------------------------- /config/webhook/service.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: webhook-service 6 | namespace: system 7 | spec: 8 | ports: 9 | - port: 443 10 | targetPort: 443 11 | selector: 12 | control-plane: controller-manager 13 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | # https://dependabot.com/docs/config-file/#dependabot-config-files 2 | version: 1 3 | update_configs: 4 | - package_manager: "go:modules" 5 | directory: "/" 6 | update_schedule: "weekly" 7 | allowed_updates: 8 | - match: 9 | update_type: "security" 10 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: ["authentication.k8s.io"] 7 | resources: 8 | - tokenreviews 9 | verbs: ["create"] 10 | - apiGroups: ["authorization.k8s.io"] 11 | resources: 12 | - subjectaccessreviews 13 | verbs: ["create"] 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # kube-secret-syncer Changelog 2 | 3 | ## v1.0.1 4 | 5 | Notable changes: 6 | 7 | * https://github.com/contentful-labs/kube-secret-syncer/pull/55 Spread start of operators more evenly across the POLL_INTERVAL by @yannh 8 | * https://github.com/contentful-labs/kube-secret-syncer/pull/60 Ignore deleted secrets while polling by @yannh 9 | 10 | ## v1.0.0 11 | 12 | Initial release. 13 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - role.yaml 3 | - role_binding.yaml 4 | - leader_election_role.yaml 5 | - leader_election_role_binding.yaml 6 | # Comment the following 3 lines if you want to disable 7 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 8 | # which protects your /metrics endpoint. 9 | #- auth_proxy_service.yaml 10 | #- auth_proxy_role.yaml 11 | #- auth_proxy_role_binding.yaml 12 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_syncedsecrets.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1beta1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | certmanager.k8s.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 8 | name: syncedsecrets.secrets.contentful.com 9 | -------------------------------------------------------------------------------- /examples/rbac.authorization.k8s.io_v1_clusterrolebinding_kube-secret-syncer-manager-rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: kube-secret-syncer-manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: kube-secret-syncer-manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: kube-secret-syncer 13 | -------------------------------------------------------------------------------- /config/samples/secrets_v1_syncedsecret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: secrets.contentful.com/v1 2 | kind: SyncedSecret 3 | metadata: 4 | name: syncedsecret-sample-ks 5 | namespace: kube-secret-syncer 6 | spec: 7 | secretMetadata: 8 | name: demo-service-secret 9 | namespace: kube-secret-syncer 10 | annotations: 11 | randomkey: randomval 12 | data: 13 | DB_NAME: database_name 14 | DB_PASS: database_pass 15 | secretid: secretsyncer/secret/sample 16 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: 5 | prometheus.io/port: "8443" 6 | prometheus.io/scheme: https 7 | prometheus.io/scrape: "true" 8 | labels: 9 | control-plane: controller-manager 10 | name: controller-manager-metrics-service 11 | namespace: system 12 | spec: 13 | ports: 14 | - name: https 15 | port: 8443 16 | targetPort: https 17 | selector: 18 | control-plane: controller-manager 19 | -------------------------------------------------------------------------------- /examples/rbac.authorization.k8s.io_v1_rolebinding_kube-secret-syncer-leader-election-rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: kube-secret-syncer-leader-election-rolebinding 5 | namespace: kube-secret-syncer 6 | roleRef: 7 | apiGroup: rbac.authorization.k8s.io 8 | kind: Role 9 | name: kube-secret-syncer-leader-election-role 10 | subjects: 11 | - kind: ServiceAccount 12 | name: default 13 | namespace: kube-secret-syncer 14 | -------------------------------------------------------------------------------- /config/certmanager/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for teaching kustomize how to update name ref and var substitution 2 | nameReference: 3 | - kind: Issuer 4 | group: certmanager.k8s.io 5 | fieldSpecs: 6 | - kind: Certificate 7 | group: certmanager.k8s.io 8 | path: spec/issuerRef/name 9 | 10 | varReference: 11 | - kind: Certificate 12 | group: certmanager.k8s.io 13 | path: spec/commonName 14 | - kind: Certificate 15 | group: certmanager.k8s.io 16 | path: spec/dnsNames 17 | -------------------------------------------------------------------------------- /config/overlays/kind/manager_kind.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - command: 10 | env: 11 | - name: AWS_REGION 12 | value: eu-west-1 13 | - name: POLL_INTERVAL_SEC 14 | value: "15" 15 | image: kube-secret-syncer:kind 16 | name: kube-secret-syncer 17 | envFrom: 18 | - configMapRef: 19 | name: aws-creds 20 | -------------------------------------------------------------------------------- /config/default/manager_prometheus_metrics_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch enables Prometheus scraping for the manager pod. 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: controller 6 | spec: 7 | template: 8 | metadata: 9 | annotations: 10 | prometheus.io/scrape: 'true' 11 | spec: 12 | containers: 13 | # Expose the prometheus metrics on default port 14 | - name: kube-secret-syncer 15 | ports: 16 | - containerPort: 8080 17 | name: metrics 18 | protocol: TCP 19 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | group: apiextensions.k8s.io 8 | path: spec/conversion/webhookClientConfig/service/name 9 | 10 | namespace: 11 | - kind: CustomResourceDefinition 12 | group: apiextensions.k8s.io 13 | path: spec/conversion/webhookClientConfig/service/namespace 14 | create: false 15 | 16 | varReference: 17 | - path: metadata/annotations 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | .DS_Store 27 | out/ 28 | 29 | # go binary built with `go build -a -o manager main.go` 30 | manager 31 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 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 | */ -------------------------------------------------------------------------------- /config/default/manager_webhook_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: kube-secret-syncer 11 | ports: 12 | - containerPort: 443 13 | name: webhook-server 14 | protocol: TCP 15 | volumeMounts: 16 | - mountPath: /tmp/k8s-webhook-server/serving-certs 17 | name: cert 18 | readOnly: true 19 | volumes: 20 | - name: cert 21 | secret: 22 | defaultMode: 420 23 | secretName: webhook-server-cert 24 | -------------------------------------------------------------------------------- /examples/rbac.authorization.k8s.io_v1_role_kube-secret-syncer-leader-election-role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: kube-secret-syncer-leader-election-role 5 | namespace: kube-secret-syncer 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - "" 21 | resources: 22 | - configmaps/status 23 | verbs: 24 | - get 25 | - update 26 | - patch 27 | - apiGroups: 28 | - "" 29 | resources: 30 | - events 31 | verbs: 32 | - create 33 | -------------------------------------------------------------------------------- /pkg/iam/arn_client_cache.go: -------------------------------------------------------------------------------- 1 | package iam 2 | 3 | type ArnClientCached struct { 4 | arnCache map[string]string 5 | arnGetter func(string) (string, error) 6 | } 7 | 8 | func NewARNClientWithCache(getter func(string) (string, error)) *ArnClientCached { 9 | return &ArnClientCached{ 10 | arnCache: map[string]string{}, 11 | arnGetter: getter, 12 | } 13 | } 14 | 15 | func (ag *ArnClientCached) GetARN(role string) (string, error) { 16 | var err error 17 | 18 | arn, ok := ag.arnCache[role] 19 | if !ok { 20 | arn, err = ag.arnGetter(role) 21 | if err != nil { 22 | return "", err 23 | } 24 | ag.arnCache[role] = arn 25 | } 26 | return arn, err 27 | } 28 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - "" 21 | resources: 22 | - configmaps/status 23 | verbs: 24 | - get 25 | - update 26 | - patch 27 | - apiGroups: 28 | - "" 29 | resources: 30 | - events 31 | verbs: 32 | - create 33 | - apiGroups: 34 | - coordination.k8s.io 35 | resources: 36 | - leases 37 | verbs: 38 | - get 39 | - create 40 | -------------------------------------------------------------------------------- /config/default/webhookcainjection_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch add annotation to admission webhook config and 2 | # the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. 3 | apiVersion: admissionregistration.k8s.io/v1beta1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | name: mutating-webhook-configuration 7 | annotations: 8 | certmanager.k8s.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 9 | --- 10 | apiVersion: admissionregistration.k8s.io/v1beta1 11 | kind: ValidatingWebhookConfiguration 12 | metadata: 13 | name: validating-webhook-configuration 14 | annotations: 15 | certmanager.k8s.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 16 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_syncedsecrets.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables conversion webhook for CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1beta1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | name: syncedsecrets.secrets.contentful.com 7 | spec: 8 | conversion: 9 | strategy: Webhook 10 | webhookClientConfig: 11 | # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, 12 | # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) 13 | caBundle: Cg== 14 | service: 15 | namespace: system 16 | name: webhook-service 17 | path: /convert 18 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - namespaces 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - "" 17 | resources: 18 | - secrets 19 | verbs: 20 | - create 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - secrets.contentful.com 28 | resources: 29 | - syncedsecrets 30 | verbs: 31 | - create 32 | - delete 33 | - get 34 | - list 35 | - patch 36 | - update 37 | - watch 38 | - apiGroups: 39 | - secrets.contentful.com 40 | resources: 41 | - syncedsecrets/status 42 | verbs: 43 | - get 44 | - patch 45 | - update 46 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller 5 | labels: 6 | spec: 7 | selector: 8 | matchLabels: 9 | replicas: 1 10 | template: 11 | metadata: 12 | annotations: 13 | iam.amazonaws.com/role: "kube-secret-syncer-role" 14 | labels: 15 | spec: 16 | containers: 17 | - command: 18 | - /manager 19 | args: 20 | - --enable-leader-election 21 | env: 22 | - name: AWS_REGION 23 | value: 'us-east-1' 24 | image: "contentful-labs/kube-secret-syncer:latest" 25 | name: kube-secret-syncer 26 | resources: 27 | limits: 28 | cpu: 100m 29 | memory: 100Mi 30 | requests: 31 | cpu: 100m 32 | memory: 100Mi 33 | terminationGracePeriodSeconds: 10 34 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestGetDurationFromEnv(t *testing.T) { 10 | const defaultPollInterval = 120 * time.Second 11 | defer os.Unsetenv("POLL_INTERVAL_SEC") 12 | 13 | for _, test := range []struct { 14 | have string 15 | want time.Duration 16 | }{ 17 | { 18 | have: "", 19 | want: defaultPollInterval, 20 | }, 21 | { 22 | have: "1000", 23 | want: time.Second * time.Duration(1000), 24 | }, 25 | } { 26 | if test.have != "" { 27 | os.Setenv("POLL_INTERVAL_SEC", test.have) 28 | } 29 | got, err := getDurationFromEnv("POLL_INTERVAL_SEC", defaultPollInterval) 30 | if err != nil { 31 | t.Errorf("error getting poll interval: %s", err) 32 | } 33 | if got != test.want { 34 | t.Errorf("poller interval: wanted %s got %s", test.want, got) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/rbac.authorization.k8s.io_v1_clusterrole_kube-secret-syncer-manager-role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | creationTimestamp: null 5 | name: kube-secret-syncer-manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - namespaces 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - "" 17 | resources: 18 | - secrets 19 | verbs: 20 | - create 21 | - get 22 | - list 23 | - patch 24 | - update 25 | - watch 26 | - apiGroups: 27 | - secrets.contentful.com 28 | resources: 29 | - syncedsecrets 30 | verbs: 31 | - create 32 | - delete 33 | - get 34 | - list 35 | - patch 36 | - update 37 | - watch 38 | - apiGroups: 39 | - secrets.contentful.com 40 | resources: 41 | - syncedsecrets/status 42 | verbs: 43 | - get 44 | - patch 45 | - update 46 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the controller manager, 2 | # it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.4.0 14 | args: 15 | - "--secure-listen-address=0.0.0.0:8443" 16 | - "--upstream=http://127.0.0.1:8080/" 17 | - "--logtostderr=true" 18 | - "--v=10" 19 | ports: 20 | - containerPort: 8443 21 | name: https 22 | - name: kube-secret-syncer 23 | args: 24 | - "--metrics-addr=127.0.0.1:8080" 25 | - "--enable-leader-election" 26 | -------------------------------------------------------------------------------- /config/webhook/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # the following config is for teaching kustomize where to look at when substituting vars. 2 | # It requires kustomize v2.1.0 or newer to work properly. 3 | nameReference: 4 | - kind: Service 5 | version: v1 6 | fieldSpecs: 7 | - kind: MutatingWebhookConfiguration 8 | group: admissionregistration.k8s.io 9 | path: webhooks/clientConfig/service/name 10 | - kind: ValidatingWebhookConfiguration 11 | group: admissionregistration.k8s.io 12 | path: webhooks/clientConfig/service/name 13 | 14 | namespace: 15 | - kind: MutatingWebhookConfiguration 16 | group: admissionregistration.k8s.io 17 | path: webhooks/clientConfig/service/namespace 18 | create: true 19 | - kind: ValidatingWebhookConfiguration 20 | group: admissionregistration.k8s.io 21 | path: webhooks/clientConfig/service/namespace 22 | create: true 23 | 24 | varReference: 25 | - path: metadata/annotations 26 | -------------------------------------------------------------------------------- /config/certmanager/certificate.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | apiVersion: certmanager.k8s.io/v1alpha1 4 | kind: Issuer 5 | metadata: 6 | name: selfsigned-issuer 7 | namespace: system 8 | spec: 9 | selfSigned: {} 10 | --- 11 | apiVersion: certmanager.k8s.io/v1alpha1 12 | kind: Certificate 13 | metadata: 14 | name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml 15 | namespace: system 16 | spec: 17 | # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize 18 | commonName: $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc 19 | dnsNames: 20 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local 21 | issuerRef: 22 | kind: Issuer 23 | name: selfsigned-issuer 24 | secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize 25 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/secrets.contentful.com_syncedsecrets.yaml 6 | # +kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patchesStrategicMerge: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- patches/webhook_in_syncedsecrets.yaml 12 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- patches/cainjection_in_syncedsecrets.yaml 17 | # +kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | configurations: 21 | - kustomizeconfig.yaml 22 | -------------------------------------------------------------------------------- /examples/apps_v1_deployment_kube-secret-syncer-controller.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | app: kube-secret-syncer 6 | name: kube-secret-syncer-controller 7 | namespace: kube-secret-syncer 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: kube-secret-syncer 13 | template: 14 | metadata: 15 | annotations: 16 | iam.amazonaws.com/role: kube-secret-syncer-role 17 | labels: 18 | app: kube-secret-syncer 19 | spec: 20 | containers: 21 | - args: 22 | - --enable-leader-election 23 | command: 24 | - /manager 25 | env: 26 | - name: AWS_REGION 27 | value: us-east-1 28 | image: contentful-labs/kube-secret-syncer:latest 29 | name: kube-secret-syncer 30 | resources: 31 | limits: 32 | cpu: 100m 33 | memory: 100Mi 34 | requests: 35 | cpu: 100m 36 | memory: 100Mi 37 | terminationGracePeriodSeconds: 10 38 | -------------------------------------------------------------------------------- /pkg/namespacevalidator/validator.go: -------------------------------------------------------------------------------- 1 | package namespacevalidator 2 | 3 | import ( 4 | "fmt" 5 | 6 | awssecretsmanager "github.com/aws/aws-sdk-go/service/secretsmanager" 7 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 8 | ) 9 | 10 | type NamespaceValidator struct { 11 | nsCache k8snamespace.NamespaceGetter 12 | } 13 | 14 | func NewNamespaceValidator(nsCache k8snamespace.NamespaceGetter) *NamespaceValidator { 15 | return &NamespaceValidator{ 16 | nsCache: nsCache, 17 | } 18 | } 19 | 20 | func (rv *NamespaceValidator) HasNamespaceType(secret awssecretsmanager.DescribeSecretOutput, namespace string) (bool, error) { 21 | ns, err := rv.nsCache.Get(namespace) 22 | if err != nil { 23 | return false, err 24 | } 25 | 26 | const nsTypeLabel = "k8s.contentful.com/namespace-type" 27 | const nsTypeTag = "k8s.contentful.com/namespace_type" 28 | 29 | label, labelFound := ns.Labels[nsTypeLabel] 30 | if !labelFound { 31 | return false, nil 32 | } 33 | 34 | for _, tag := range secret.Tags { 35 | if *tag.Key == fmt.Sprintf("%s/%s", nsTypeTag, label) && *tag.Value == "1" { 36 | return true, nil 37 | } 38 | } 39 | 40 | return false, nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/iam/arn_test.go: -------------------------------------------------------------------------------- 1 | package iam 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsValidBaseARN(t *testing.T) { 8 | arns := []string{ 9 | "arn:aws:iam::123456789012:role/path", 10 | "arn:aws:iam::123456789012:role/path/", 11 | "arn:aws:iam::123456789012:role/path/sub-path", 12 | "arn:aws:iam::123456789012:role/path/sub_path", 13 | "arn:aws:iam::123456789012:role/subdomain.domain", 14 | "arn:aws:iam::123456789012:role", 15 | "arn:aws:iam::123456789012:role/", 16 | "arn:aws:iam::123456789012:role-part", 17 | "arn:aws:iam::123456789012:role_part", 18 | "arn:aws:iam::123456789012:role_123", 19 | "arn:aws-us-gov:iam::123456789012:role", 20 | } 21 | for _, arn := range arns { 22 | if !isValidARN(arn) { 23 | t.Errorf("%s is a valid base arn", arn) 24 | } 25 | } 26 | } 27 | 28 | func TestIsValidBaseARNWithInvalid(t *testing.T) { 29 | arns := []string{ 30 | "arn:aws:iam::123456789012::role/path", 31 | "arn:aws:iam:us-east-1:123456789012:role/path", 32 | "arn:aws:s3::123456789012:role/path", 33 | "arn:aws:iam::123456789012:role/$", 34 | "arn:aws:iam::12345-6789012:role/", 35 | "arn:aws:iam::abcdef:role/", 36 | "arn:aws:iam:::role", 37 | } 38 | for _, arn := range arns { 39 | if isValidARN(arn) { 40 | t.Errorf("%s is not a valid base arn", arn) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.23 as base 3 | 4 | WORKDIR /workspace 5 | # Copy the Go Modules manifests 6 | COPY go.mod go.mod 7 | COPY go.sum go.sum 8 | # cache deps before building and copying source so that we don't need to re-download as much 9 | # and so that source changes don't invalidate our downloaded layer 10 | RUN go mod download 11 | 12 | # Copy the go source 13 | COPY main.go main.go 14 | COPY api/ api/ 15 | COPY controllers/ controllers/ 16 | COPY pkg/ pkg/ 17 | 18 | # Test image 19 | FROM base as test 20 | 21 | RUN curl -L https://github.com/kubernetes-sigs/kubebuilder/releases/download/v2.3.1/kubebuilder_2.3.1_linux_amd64.tar.gz | \ 22 | tar -xz -C /tmp/ && \ 23 | mv /tmp/kubebuilder_2.3.1_linux_amd64 /usr/local/kubebuilder 24 | 25 | COPY Makefile Makefile 26 | COPY hack/ hack/ 27 | COPY config/ config/ 28 | 29 | ENV PATH=$PATH:/usr/local/kubebuilder/bin 30 | 31 | FROM base as builder 32 | 33 | # Build 34 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o manager main.go 35 | 36 | # Use distroless as minimal base image to package the manager binary 37 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 38 | FROM gcr.io/distroless/static:latest 39 | WORKDIR / 40 | COPY --from=builder /workspace/manager . 41 | ENTRYPOINT ["/manager"] 42 | -------------------------------------------------------------------------------- /api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 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 | 16 | // Package v1 contains API Schema definitions for the secrets v1 API group 17 | // +kubebuilder:object:generate=true 18 | // +groupName=secrets.contentful.com 19 | package v1 20 | 21 | import ( 22 | "k8s.io/apimachinery/pkg/runtime/schema" 23 | "sigs.k8s.io/controller-runtime/pkg/scheme" 24 | ) 25 | 26 | var ( 27 | // GroupVersion is group version used to register these objects 28 | GroupVersion = schema.GroupVersion{Group: "secrets.contentful.com", Version: "v1"} 29 | 30 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 31 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 32 | 33 | // AddToScheme adds the types in this group-version to the given scheme. 34 | AddToScheme = SchemeBuilder.AddToScheme 35 | ) 36 | -------------------------------------------------------------------------------- /pkg/iam/arn.go: -------------------------------------------------------------------------------- 1 | package iam 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | ) 11 | 12 | const fullArnPrefix = "arn:" 13 | 14 | // ARNRegexp is the regex to check that the base ARN is valid, 15 | // see http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-arns. 16 | var ARNRegexp = regexp.MustCompile(`^arn:(\w|-)*:iam::\d+:role\/?(\w+|-|\/|\.)*$`) 17 | 18 | // isValidBaseARN validates that the base ARN is valid. 19 | func isValidARN(arn string) bool { 20 | return ARNRegexp.MatchString(arn) 21 | } 22 | 23 | type ARNGetter interface { 24 | GetARN(role string) (string, error) 25 | } 26 | 27 | // getARN returns the full iam role ARN. 28 | func GetARN(role string) (string, error) { 29 | if isValidARN(role) { 30 | return role, nil 31 | } 32 | 33 | if strings.HasPrefix(strings.ToLower(role), fullArnPrefix) && !isValidARN(role) { 34 | return "", fmt.Errorf("%s is not a valid ARN", role) 35 | } 36 | 37 | baseArn, err := getBaseArn() 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | arn := fmt.Sprintf("%s%s", baseArn, role) 43 | if !isValidARN(arn) { 44 | return "", fmt.Errorf("%s is not a valid ARN", arn) 45 | } 46 | 47 | return arn, nil 48 | } 49 | 50 | func getBaseArn() (string, error) { 51 | sess, err := session.NewSession() 52 | if err != nil { 53 | return "", err 54 | } 55 | metadata := ec2metadata.New(sess) 56 | if !metadata.Available() { 57 | return "", fmt.Errorf("EC2 Metadata is not available, are you running on EC2?") 58 | } 59 | iamInfo, err := metadata.IAMInfo() 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | arn := strings.Replace(iamInfo.InstanceProfileArn, "instance-profile", "role", 1) 65 | splitArn := strings.Split(arn, "/") 66 | if len(splitArn) < 2 { 67 | return "", fmt.Errorf("can't determine BaseARN") 68 | } 69 | 70 | return fmt.Sprintf("%s/", splitArn[0]), nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/rolevalidator/validator.go: -------------------------------------------------------------------------------- 1 | package rolevalidator 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/contentful-labs/kube-secret-syncer/pkg/iam" 6 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type RoleValidator struct { 11 | arnGetter iam.ARNGetter 12 | nsCache k8snamespace.NamespaceGetter 13 | annotationName string 14 | } 15 | 16 | func NewRoleValidator(getter iam.ARNGetter, nsCache k8snamespace.NamespaceGetter, annotationName string) *RoleValidator { 17 | return &RoleValidator{ 18 | arnGetter: getter, 19 | nsCache: nsCache, 20 | annotationName: annotationName, 21 | } 22 | } 23 | 24 | func (rv *RoleValidator) IsWhitelisted(role, namespace string) (bool, error) { 25 | ns, err := rv.nsCache.Get(namespace) 26 | if err != nil { 27 | return false, err 28 | } 29 | 30 | annotation, annotationFound := ns.Annotations[rv.annotationName] 31 | if !annotationFound { // The namespace does not use kube2iam. We should not specify an IAMRole, but we dont prevent it 32 | return true, nil 33 | } 34 | 35 | if role == "" { // Secrets must have a role defined if an annotation is found 36 | return false, nil 37 | } 38 | 39 | return rv.isRoleAllowed(role, annotation) 40 | } 41 | 42 | func (rv *RoleValidator) isRoleAllowed(role, kube2iamAnnotation string) (bool, error) { 43 | roleArn, err := rv.arnGetter.GetARN(role) 44 | if err != nil { 45 | return false, errors.WithMessagef(err, "failed getting ARN for role %s", role) 46 | } 47 | 48 | var allowedRoles []string 49 | if err := json.Unmarshal([]byte(kube2iamAnnotation), &allowedRoles); err != nil { 50 | return false, err 51 | } 52 | 53 | for _, allowedRole := range allowedRoles { 54 | allowedRoleArn, err := rv.arnGetter.GetARN(allowedRole) 55 | if err != nil { 56 | return false, errors.WithMessagef(err, "failed getting ARN for role %s", allowedRole) 57 | } 58 | 59 | if roleArn == allowedRoleArn { 60 | return true, nil 61 | } 62 | } 63 | 64 | return false, nil 65 | } 66 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Local Development 2 | 3 | ## Requires 4 | 5 | * Kubebuilder ([install instructions](https://book.kubebuilder.io/quick-start.html#installation)) 6 | * Kustomize ([install instructions](https://github.com/kubernetes-sigs/kustomize/blob/master/docs/INSTALL.md)) 7 | * Kind ([install instructions](https://kind.sigs.k8s.io/docs/user/quick-start/#installation)) 8 | * Docker 9 | * go 10 | 11 | Make sure you install these listed tools via go and not your local package manager, this can lead to path issues. 12 | 13 | ## Local Development 14 | 15 | ### Development flow 16 | 17 | To develop locally you'll need a kubernetes cluster installed. *kind* can setup a local cluster within docker. 18 | After you have *kind* installed, follow the *kind* 19 | [Creating a Cluster](https://kind.sigs.k8s.io/docs/user/quick-start/#creating-a-cluster) and 20 | [Interacting With Your Cluster](https://kind.sigs.k8s.io/docs/user/quick-start/#interacting-with-your-cluster) 21 | instructions to get setup with a working local cluster. Be sure to set the environment variable 22 | `KIND_CTX` to the context created by kind, for example: 23 | 24 | ``` 25 | export KIND_CTX=kind-kind 26 | ``` 27 | 28 | You should now be able to run `make docker-build kind` and have the controller run in your local cluster. The 29 | namespace and CRD for use with kind is available in 30 | [config/samples/secrets_v1_syncedsecret.yaml](../config/samples/secrets_v1_syncedsecret.yaml). You can apply these using 31 | `kubectl apply -f config/samples/secrets_v1_syncedsecret.yaml --context kubernetes-admin@kind`. 32 | 33 | Additionaly, to ensure all tests pass, run `make tests`. 34 | 35 | Here's what your flow would look like after 36 | 1. do code changes 37 | 2. `make docker-build kind` 38 | 39 | #### AWS Credentials 40 | 41 | When you run `make kind` above, a config map is added to your local kind cluster which contains the aws credentials 42 | from the `preview` profile on the host system. To use a different profile set the `AWS_KIND_PROFILE` make variable. 43 | Eg `make AWS_KIND_PROFILE=staging docker-build kind` 44 | -------------------------------------------------------------------------------- /pkg/namespacevalidator/validator_test.go: -------------------------------------------------------------------------------- 1 | package namespacevalidator 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | awssecretsmanager "github.com/aws/aws-sdk-go/service/secretsmanager" 8 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 9 | v1 "k8s.io/api/core/v1" 10 | ) 11 | 12 | type mockNSGetter struct { 13 | namespaceLabel string 14 | } 15 | 16 | func (m *mockNSGetter) Get(string) (*v1.Namespace, error) { 17 | ns := &v1.Namespace{} 18 | ns.Labels = map[string]string{ 19 | "k8s.contentful.com/namespace-type": m.namespaceLabel, 20 | } 21 | 22 | return ns, nil 23 | } 24 | 25 | func TestHasNamespaceType(t *testing.T) { 26 | testCases := []struct { 27 | name string 28 | secret awssecretsmanager.DescribeSecretOutput 29 | namespace string 30 | getter k8snamespace.NamespaceGetter 31 | expectHasNamespaceType bool 32 | }{ 33 | { 34 | name: "secret has correct namespace tag", 35 | secret: awssecretsmanager.DescribeSecretOutput{ 36 | Tags: []*awssecretsmanager.Tag{ 37 | { 38 | Key: aws.String("k8s.contentful.com/namespace_type/some-namespace"), 39 | Value: aws.String("1"), 40 | }, 41 | }, 42 | }, 43 | namespace: "some-namespace", 44 | getter: &mockNSGetter{ 45 | namespaceLabel: "some-namespace", 46 | }, 47 | expectHasNamespaceType: true, 48 | }, 49 | { 50 | name: "secret has incorrect namespace tag", 51 | secret: awssecretsmanager.DescribeSecretOutput{ 52 | Tags: []*awssecretsmanager.Tag{ 53 | { 54 | Key: aws.String("k8s.contentful.com/namespace_type/some-other-namespace"), 55 | Value: aws.String("1"), 56 | }, 57 | }, 58 | }, 59 | namespace: "some-namespace", 60 | getter: &mockNSGetter{ 61 | namespaceLabel: "some-namespace", 62 | }, 63 | expectHasNamespaceType: false, 64 | }, 65 | { 66 | name: "secret has incorrect namespace tag", 67 | secret: awssecretsmanager.DescribeSecretOutput{ 68 | Tags: []*awssecretsmanager.Tag{}, 69 | }, 70 | namespace: "some-namespace", 71 | getter: &mockNSGetter{ 72 | namespaceLabel: "some-namespace", 73 | }, 74 | expectHasNamespaceType: false, 75 | }, 76 | } 77 | 78 | for _, test := range testCases { 79 | rv := NewNamespaceValidator(test.getter) 80 | isAllowed, err := rv.HasNamespaceType(test.secret, test.namespace) 81 | if err != nil { 82 | t.Errorf("got error with namespace %s: %s", test.namespace, err) 83 | } 84 | if isAllowed != test.expectHasNamespaceType { 85 | t.Errorf("failed %s", test.name) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/k8snamespace/k8snamespace.go: -------------------------------------------------------------------------------- 1 | package k8snamespace 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | v1 "k8s.io/api/core/v1" 10 | "k8s.io/apimachinery/pkg/fields" 11 | "k8s.io/client-go/kubernetes" 12 | "k8s.io/client-go/rest" 13 | restclient "k8s.io/client-go/rest" 14 | "k8s.io/client-go/tools/cache" 15 | "k8s.io/client-go/tools/clientcmd" 16 | ) 17 | 18 | type NamespaceGetter interface { 19 | Get(string) (*v1.Namespace, error) 20 | } 21 | 22 | type NamespaceCache struct { 23 | indexer cache.Indexer 24 | informer cache.Controller 25 | } 26 | 27 | func NewWatcher(ctx context.Context) (*NamespaceCache, error) { 28 | var config *restclient.Config 29 | var err error 30 | 31 | // config, err := rest.InClusterConfig() 32 | kubeconfig, found := os.LookupEnv("KUBECONFIG") 33 | if found { 34 | config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) 35 | } else { 36 | config, err = rest.InClusterConfig() 37 | } 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | client, err := kubernetes.NewForConfig(config) 43 | if err != nil { 44 | return nil, err 45 | } 46 | source := cache.NewListWatchFromClient(client.CoreV1().RESTClient(), "namespaces", "", fields.Everything()) 47 | 48 | eventHandler := &cache.FilteringResourceEventHandler{ 49 | FilterFunc: func(obj interface{}) bool { 50 | _, isNamespace := obj.(*v1.Namespace) 51 | if !isNamespace { 52 | return false 53 | } 54 | // TODO change filter to relevant ns annotation 55 | return true 56 | }, 57 | Handler: &namespaceLogger{}, 58 | } 59 | 60 | // OnUpdate is called every resyncPeriod 61 | indexer, informer := cache.NewIndexerInformer(source, &v1.Namespace{}, time.Minute, eventHandler, cache.Indexers{}) 62 | 63 | c := &NamespaceCache{ 64 | indexer: indexer, 65 | informer: informer, 66 | } 67 | 68 | go c.informer.Run(ctx.Done()) 69 | 70 | ok := cache.WaitForCacheSync(ctx.Done(), c.informer.HasSynced) 71 | if !ok { 72 | return nil, fmt.Errorf("failed to sync cache") 73 | } 74 | 75 | return c, nil 76 | } 77 | 78 | // GetNamespace finds the Namespace by its name 79 | func (c *NamespaceCache) Get(name string) (*v1.Namespace, error) { 80 | obj, exists, err := c.indexer.GetByKey(name) 81 | if err != nil { 82 | return nil, err 83 | } 84 | if !exists { 85 | return nil, nil 86 | } 87 | return obj.(*v1.Namespace), nil 88 | } 89 | 90 | type namespaceLogger struct { 91 | } 92 | 93 | func (o *namespaceLogger) OnAdd(obj interface{}, isInInitialList bool) { 94 | } 95 | 96 | func (o *namespaceLogger) OnDelete(obj interface{}) { 97 | } 98 | 99 | func (o *namespaceLogger) OnUpdate(old, new interface{}) { 100 | } 101 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: kube-secret-syncer 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: kube-secret-syncer- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in crd/kustomization.yaml 20 | #- ../webhook 21 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 22 | #- ../certmanager 23 | 24 | patchesStrategicMerge: 25 | # Protect the /metrics endpoint by putting it behind auth. 26 | # Only one of manager_auth_proxy_patch.yaml and 27 | # manager_prometheus_metrics_patch.yaml should be enabled. 28 | #- manager_auth_proxy_patch.yaml 29 | # If you want your controller-manager to expose the /metrics 30 | # endpoint w/o any authn/z, uncomment the following line and 31 | # comment manager_auth_proxy_patch.yaml. 32 | # Only one of manager_auth_proxy_patch.yaml and 33 | # manager_prometheus_metrics_patch.yaml should be enabled. 34 | #- manager_prometheus_metrics_patch.yaml 35 | 36 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in crd/kustomization.yaml 37 | #- manager_webhook_patch.yaml 38 | 39 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 40 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 41 | # 'CERTMANAGER' needs to be enabled to use ca injection 42 | #- webhookcainjection_patch.yaml 43 | 44 | # the following config is for teaching kustomize how to do var substitution 45 | vars: 46 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 47 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 48 | # objref: 49 | # kind: Certificate 50 | # group: certmanager.k8s.io 51 | # version: v1alpha1 52 | # name: serving-cert # this name should match the one in certificate.yaml 53 | # fieldref: 54 | # fieldpath: metadata.namespace 55 | #- name: CERTIFICATE_NAME 56 | # objref: 57 | # kind: Certificate 58 | # group: certmanager.k8s.io 59 | # version: v1alpha1 60 | # name: serving-cert # this name should match the one in certificate.yaml 61 | #- name: SERVICE_NAMESPACE # namespace of the service 62 | # objref: 63 | # kind: Service 64 | # version: v1 65 | # name: webhook-service 66 | # fieldref: 67 | # fieldpath: metadata.namespace 68 | #- name: SERVICE_NAME 69 | # objref: 70 | # kind: Service 71 | # version: v1 72 | # name: webhook-service 73 | -------------------------------------------------------------------------------- /pkg/rolevalidator/validator_test.go: -------------------------------------------------------------------------------- 1 | package rolevalidator 2 | 3 | import ( 4 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 5 | v1 "k8s.io/api/core/v1" 6 | "testing" 7 | ) 8 | 9 | type mockARNGetter struct{} 10 | 11 | func (*mockARNGetter) GetARN(role string) (string, error) { return role, nil } 12 | 13 | type mockNSGetter struct { 14 | annotationName string 15 | annotation string 16 | } 17 | 18 | func (m *mockNSGetter) Get(string) (*v1.Namespace, error) { 19 | ns := &v1.Namespace{} 20 | ns.Annotations = map[string]string{ 21 | m.annotationName: m.annotation, 22 | } 23 | 24 | return ns, nil 25 | } 26 | 27 | type mockUnannottatedNSGetter struct { 28 | annotation string 29 | } 30 | 31 | func (m *mockUnannottatedNSGetter) Get(string) (*v1.Namespace, error) { 32 | ns := &v1.Namespace{} 33 | ns.Annotations = map[string]string{} 34 | 35 | return ns, nil 36 | } 37 | 38 | func TestIsWhitelisted(t *testing.T) { 39 | const annotationName = "iam.amazonaws.com/allowed-roles" 40 | 41 | testCases := []struct { 42 | name string 43 | role string 44 | expectAllowed bool 45 | getter k8snamespace.NamespaceGetter 46 | }{ 47 | { 48 | name: "namespace has annotation whitelisting specified role", 49 | role: "role1", 50 | expectAllowed: true, 51 | getter: &mockNSGetter{ 52 | annotationName: annotationName, 53 | annotation: "[\"role1\"]", 54 | }, 55 | }, 56 | { 57 | 58 | name: "namespace has several annotations and is whitelisting specified role", 59 | role: "role2", 60 | expectAllowed: true, 61 | getter: &mockNSGetter{ 62 | annotationName: annotationName, 63 | annotation: "[\"role1\", \"role2\"]", 64 | }, 65 | }, 66 | { 67 | name: "namespace has an annotations but is not whitelisting specified role", 68 | role: "role1", 69 | expectAllowed: false, 70 | getter: &mockNSGetter{ 71 | annotationName: annotationName, 72 | annotation: "[\"role2\"]", 73 | }, 74 | }, 75 | { 76 | name: "namespace has no annotation, role is specified", 77 | role: "role1", 78 | expectAllowed: true, 79 | getter: &mockUnannottatedNSGetter{}, 80 | }, 81 | { 82 | name: "namespace has an annotation, role is not specified", 83 | role: "", 84 | expectAllowed: false, 85 | getter: &mockNSGetter{ 86 | annotationName: annotationName, 87 | annotation: "[\"role2\"]", 88 | }, 89 | }, 90 | } 91 | 92 | for _, test := range testCases { 93 | rv := NewRoleValidator(&mockARNGetter{}, test.getter, annotationName) 94 | isAllowed, err := rv.IsWhitelisted(test.role, "test") 95 | if err != nil { 96 | t.Errorf("got error with role %s: %s", test.role, err) 97 | } 98 | if isAllowed != test.expectAllowed { 99 | t.Errorf("failed %s", test.name) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | executors: 3 | docker-publisher: 4 | environment: 5 | IMAGE_NAME: contentful/kube-secret-syncer 6 | machine: 7 | docker_layer_caching: true 8 | jobs: 9 | test: 10 | machine: 11 | image: default 12 | environment: 13 | steps: 14 | - checkout 15 | - run: make docker-test 16 | build: 17 | executor: docker-publisher 18 | steps: 19 | - checkout 20 | - run: make docker-build 21 | - run: 22 | name: Archive Docker image 23 | command: docker save -o image.tar contentful-labs/kube-secret-syncer 24 | - persist_to_workspace: 25 | root: . 26 | paths: 27 | - ./image.tar 28 | publish-branch: 29 | executor: docker-publisher 30 | steps: 31 | - attach_workspace: 32 | at: /tmp/workspace 33 | - run: 34 | name: Load archived Docker image 35 | command: docker load -i /tmp/workspace/image.tar 36 | - run: 37 | name: publish docker image with branch 38 | command: | 39 | docker tag contentful-labs/kube-secret-syncer:latest $IMAGE_NAME:$CIRCLE_BRANCH 40 | echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USER" --password-stdin 41 | docker push $IMAGE_NAME:$CIRCLE_BRANCH 42 | publish-tag: 43 | executor: docker-publisher 44 | steps: 45 | - attach_workspace: 46 | at: /tmp/workspace 47 | - run: 48 | name: Load archived Docker image 49 | command: docker load -i /tmp/workspace/image.tar 50 | - run: 51 | name: publish docker image with tag 52 | command: | 53 | docker tag contentful-labs/kube-secret-syncer:latest $IMAGE_NAME:$CIRCLE_TAG 54 | echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USER" --password-stdin 55 | docker push $IMAGE_NAME:$CIRCLE_TAG 56 | publish-master: 57 | executor: docker-publisher 58 | steps: 59 | - attach_workspace: 60 | at: /tmp/workspace 61 | - run: 62 | name: Load archived Docker image 63 | command: docker load -i /tmp/workspace/image.tar 64 | - run: 65 | name: publish docker image with latest tag 66 | command: | 67 | docker tag contentful-labs/kube-secret-syncer:latest $IMAGE_NAME:latest 68 | echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USER" --password-stdin 69 | docker push $IMAGE_NAME:latest 70 | workflows: 71 | version: 2 72 | flow: 73 | jobs: 74 | - test: 75 | filters: 76 | tags: 77 | only: /v[0-9]+(\.[0-9]+)*(-.*)*/ 78 | - build: 79 | filters: 80 | tags: 81 | only: /v[0-9]+(\.[0-9]+)*(-.*)*/ 82 | - publish-branch: 83 | requires: 84 | - build 85 | - test 86 | filters: 87 | branches: 88 | ignore: 89 | - master 90 | - /pull\/[0-9]+/ 91 | - /dependabot.*/ 92 | - publish-tag: 93 | # Only run this job on git tag pushes 94 | requires: 95 | - build 96 | - test 97 | filters: 98 | branches: 99 | ignore: /.*/ 100 | tags: 101 | only: /v[0-9]+(\.[0-9]+)*(-.*)*/ 102 | - publish-master: 103 | requires: 104 | - build 105 | - test 106 | filters: 107 | branches: 108 | only: master 109 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: examples 2 | # Image URL to use all building/pushing image targets 3 | IMG ?= contentful-labs/kube-secret-syncer 4 | # Produce CRDs that work back to Kubernetes 1.11 (no version conversion) 5 | CRD_OPTIONS ?= "crd:crdVersions=v1" 6 | # Directory for storing generated manifests 7 | OP_OUT ?= out 8 | # kind cluster context 9 | KIND_CTX ?= kubernetes-admin@kind 10 | # AWS Profile credentials to pass to kind cluster 11 | AWS_KIND_PROFILE ?= preview 12 | 13 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 14 | ifeq (,$(shell go env GOBIN)) 15 | GOBIN=$(shell go env GOPATH)/bin 16 | else 17 | GOBIN=$(shell go env GOBIN) 18 | endif 19 | 20 | # BSD vs GNU sed, fight! 21 | PLATFORM := $(shell uname) 22 | ifeq ($(PLATFORM),Linux) 23 | SED_I=sed -i 24 | else 25 | SED_I=sed -i '' 26 | endif 27 | 28 | all: manager 29 | 30 | # Run tests 31 | test: generate fmt vet manifests 32 | go test -v ./... -coverprofile cover.out -coverpkg ./controllers/...,./pkg/... 33 | 34 | # Build manager binary 35 | manager: generate fmt vet 36 | go build -o bin/manager main.go 37 | 38 | # Run against the configured Kubernetes cluster in ~/.kube/config 39 | run: generate fmt vet manifests 40 | go run ./main.go 41 | 42 | operator: 43 | @rm -rf ${OP_OUT} 44 | @mkdir -p ${OP_OUT} 45 | @kustomize build config/default -o ${OP_OUT}/ 46 | @find ${OP_OUT} -type f -name "*.yaml" -print0 | xargs -0 ${SED_I} '/^ creationTimestamp: null/d' 47 | @echo "built operators in ${OP_OUT}" 48 | 49 | # Generate manifests e.g. CRD, RBAC etc. 50 | manifests: controller-gen 51 | $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases 52 | 53 | # Run go fmt against code 54 | fmt: 55 | go fmt ./... 56 | 57 | # Run go vet against code 58 | vet: 59 | go vet ./... 60 | 61 | # Generate code 62 | generate: controller-gen 63 | $(CONTROLLER_GEN) object:headerFile=./hack/boilerplate.go.txt paths="./..." 64 | 65 | # Run tests in a container 66 | docker-test: 67 | docker build . -t ${IMG}-test --target=test 68 | docker run -it -v $(PWD):/repo --rm ${IMG}-test go test -v ./... -coverprofile /repo/cover.out -coverpkg ./controllers/...,./pkg/... 69 | 70 | # Build the docker image 71 | docker-build: 72 | docker build . -t ${IMG} 73 | 74 | kind: 75 | docker tag contentful-labs/kube-secret-syncer:latest kube-secret-syncer:kind 76 | kind load docker-image kube-secret-syncer:kind 77 | @kubectl --context=${KIND_CTX} apply -f config/samples/kube-secret-syncer-ns.yaml 78 | @kubectl --context=${KIND_CTX} -n kube-secret-syncer delete --ignore-not-found configmap aws-creds 79 | @kubectl --context=${KIND_CTX} -n kube-secret-syncer create configmap aws-creds \ 80 | --from-literal=AWS_ACCESS_KEY_ID=$(shell aws configure get aws_access_key_id --profile ${AWS_KIND_PROFILE}) \ 81 | --from-literal=AWS_SECRET_ACCESS_KEY=$(shell aws configure get aws_secret_access_key --profile ${AWS_KIND_PROFILE}) \ 82 | --from-literal=AWS_SESSION_TOKEN=$(shell aws configure get aws_session_token --profile ${AWS_KIND_PROFILE}) 83 | 84 | @kubectl --context=${KIND_CTX} -n kube-secret-syncer delete deployment kube-secret-syncer-controller --ignore-not-found=true 85 | kustomize build config/overlays/kind | kubectl apply --context=${KIND_CTX} -f - 86 | 87 | # find or download controller-gen 88 | # download controller-gen if necessary 89 | controller-gen: 90 | ifeq (, $(shell which controller-gen)) 91 | go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.1 92 | CONTROLLER_GEN=$(GOBIN)/controller-gen 93 | else 94 | CONTROLLER_GEN=$(shell which controller-gen) 95 | endif 96 | 97 | examples: 98 | @rm -rf examples/* 99 | @kustomize build config/overlays/examples/ -o examples/ 100 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/contentful-labs/kube-secret-syncer 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.3 6 | 7 | require ( 8 | github.com/Masterminds/sprig v2.22.0+incompatible 9 | github.com/aws/aws-sdk-go v1.55.6 10 | github.com/go-logr/logr v1.4.2 11 | github.com/hashicorp/golang-lru v1.0.2 12 | github.com/onsi/ginkgo v1.16.5 13 | github.com/onsi/gomega v1.36.2 14 | github.com/pkg/errors v0.9.1 15 | github.com/prometheus/client_golang v1.20.5 16 | go.uber.org/zap v1.27.0 17 | k8s.io/api v0.32.1 18 | k8s.io/apimachinery v0.32.1 19 | k8s.io/client-go v0.32.1 20 | sigs.k8s.io/controller-runtime v0.20.2 21 | ) 22 | 23 | require ( 24 | github.com/Masterminds/goutils v1.1.1 // indirect 25 | github.com/Masterminds/semver v1.5.0 // indirect 26 | github.com/beorn7/perks v1.0.1 // indirect 27 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 30 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 31 | github.com/fsnotify/fsnotify v1.7.0 // indirect 32 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 33 | github.com/go-logr/zapr v1.3.0 // indirect 34 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 35 | github.com/go-openapi/jsonreference v0.20.2 // indirect 36 | github.com/go-openapi/swag v0.23.0 // indirect 37 | github.com/gogo/protobuf v1.3.2 // indirect 38 | github.com/golang/protobuf v1.5.4 // indirect 39 | github.com/google/btree v1.1.3 // indirect 40 | github.com/google/gnostic-models v0.6.8 // indirect 41 | github.com/google/go-cmp v0.6.0 // indirect 42 | github.com/google/gofuzz v1.2.0 // indirect 43 | github.com/google/uuid v1.6.0 // indirect 44 | github.com/huandu/xstrings v1.3.0 // indirect 45 | github.com/imdario/mergo v0.3.12 // indirect 46 | github.com/jmespath/go-jmespath v0.4.0 // indirect 47 | github.com/josharian/intern v1.0.0 // indirect 48 | github.com/json-iterator/go v1.1.12 // indirect 49 | github.com/klauspost/compress v1.17.9 // indirect 50 | github.com/mailru/easyjson v0.7.7 // indirect 51 | github.com/mitchellh/copystructure v1.0.0 // indirect 52 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 54 | github.com/modern-go/reflect2 v1.0.2 // indirect 55 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 56 | github.com/nxadm/tail v1.4.8 // indirect 57 | github.com/prometheus/client_model v0.6.1 // indirect 58 | github.com/prometheus/common v0.55.0 // indirect 59 | github.com/prometheus/procfs v0.15.1 // indirect 60 | github.com/spf13/pflag v1.0.5 // indirect 61 | github.com/x448/float16 v0.8.4 // indirect 62 | go.uber.org/multierr v1.11.0 // indirect 63 | golang.org/x/crypto v0.35.0 // indirect 64 | golang.org/x/net v0.34.0 // indirect 65 | golang.org/x/oauth2 v0.27.0 // indirect 66 | golang.org/x/sync v0.11.0 // indirect 67 | golang.org/x/sys v0.30.0 // indirect 68 | golang.org/x/term v0.29.0 // indirect 69 | golang.org/x/text v0.22.0 // indirect 70 | golang.org/x/time v0.7.0 // indirect 71 | golang.org/x/tools v0.29.0 // indirect 72 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 73 | google.golang.org/protobuf v1.36.4 // indirect 74 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 75 | gopkg.in/inf.v0 v0.9.1 // indirect 76 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 77 | gopkg.in/yaml.v3 v3.0.1 // indirect 78 | k8s.io/apiextensions-apiserver v0.32.1 // indirect 79 | k8s.io/klog/v2 v2.130.1 // indirect 80 | k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect 81 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 82 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 83 | sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect 84 | sigs.k8s.io/yaml v1.4.0 // indirect 85 | ) 86 | -------------------------------------------------------------------------------- /pkg/secretsmanager/secrets.go: -------------------------------------------------------------------------------- 1 | package secretsmanager 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/secretsmanager" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | func FilterByTagKey(secrets Secrets, tagKey string) Secrets { 10 | filteredSecrets := Secrets{} 11 | for secretName, secretMeta := range secrets { 12 | for tagName, _ := range secretMeta.Tags { 13 | if tagName == tagKey { 14 | filteredSecrets[secretName] = secretMeta 15 | } 16 | } 17 | } 18 | 19 | return filteredSecrets 20 | } 21 | 22 | // GetCurrentSecret Returns the secret value for `secretId` with stage `AWSCURRENT` 23 | // TODO add a test to ensure this is mocked well including the error 24 | func (p *Poller) GetSecret(secretID *string, IAMRole string) (string, string, error) { 25 | if secretValueOut, ok := p.fetchCurrentSecretCache(secretID, IAMRole); ok { 26 | return *secretValueOut.SecretString, *secretValueOut.VersionId, nil 27 | } 28 | 29 | smClient, err := p.getSMClient(IAMRole) 30 | if err != nil { 31 | return "", "", err 32 | } 33 | 34 | // Not in cache, or new versionID found 35 | secretValueOut, err := smClient.GetSecretValue(&secretsmanager.GetSecretValueInput{ 36 | SecretId: secretID, 37 | VersionStage: aws.String("AWSCURRENT"), 38 | }) 39 | if err != nil { 40 | return "", "", errors.WithMessagef(err, "can't find AWSCURRENT version for secretID %s", *secretID) 41 | } 42 | 43 | if cachedElem, ok := p.cachedSecretValuesByRole.Get(*secretID); !ok { 44 | cachedElem := map[string]secretsmanager.GetSecretValueOutput{ 45 | IAMRole: *secretValueOut, 46 | } 47 | p.cachedSecretValuesByRole.Add(*secretID, cachedElem) 48 | } else { 49 | cachedElem.(map[string]secretsmanager.GetSecretValueOutput)[IAMRole] = *secretValueOut 50 | } 51 | 52 | return *secretValueOut.SecretString, *secretValueOut.VersionId, nil 53 | } 54 | 55 | func (p *Poller) fetchCurrentSecretCache(secretID *string, role string) (*secretsmanager.GetSecretValueOutput, bool) { 56 | if cachedElem, ok := p.cachedSecretValuesByRole.Get(*secretID); ok { 57 | //old secretValueOut := cachedElem.(map[string]*secretsmanager.GetSecretValueOutput) 58 | secretValuesByRole := cachedElem.(map[string]secretsmanager.GetSecretValueOutput) 59 | if secretValueOut, ok := secretValuesByRole[role]; ok { 60 | polledSecretMeta, found := p.PolledSecrets[*secretID] 61 | if found && polledSecretMeta.CurrentVersionID == *secretValueOut.VersionId { 62 | return &secretValueOut, found 63 | } 64 | } 65 | } 66 | 67 | return nil, false 68 | } 69 | 70 | func (p *Poller) DescribeSecret(secretID *string, IAMRole string) (secretsmanager.DescribeSecretOutput, error) { 71 | if secretValueOut, ok := p.fetchCurrentDescribedSecretCache(secretID, IAMRole); ok { 72 | return *secretValueOut, nil 73 | } 74 | 75 | smClient, err := p.getSMClient(IAMRole) 76 | if err != nil { 77 | return secretsmanager.DescribeSecretOutput{}, err 78 | } 79 | 80 | // Not in cache, or new versionID found 81 | secretValueOut, err := smClient.DescribeSecret(&secretsmanager.DescribeSecretInput{ 82 | SecretId: secretID, 83 | }) 84 | if err != nil { 85 | return secretsmanager.DescribeSecretOutput{}, errors.WithMessagef(err, "can't find AWSCURRENT version for secretID %s", *secretID) 86 | } 87 | 88 | if cachedElem, ok := p.cachedSecretsByRole.Get(*secretID); !ok { 89 | cachedElem := map[string]secretsmanager.DescribeSecretOutput{ 90 | IAMRole: *secretValueOut, 91 | } 92 | p.cachedSecretsByRole.Add(*secretID, cachedElem) 93 | } else { 94 | cachedElem.(map[string]secretsmanager.DescribeSecretOutput)[IAMRole] = *secretValueOut 95 | } 96 | 97 | return *secretValueOut, nil 98 | } 99 | 100 | func (p *Poller) fetchCurrentDescribedSecretCache(secretID *string, role string) (*secretsmanager.DescribeSecretOutput, bool) { 101 | if cachedElem, ok := p.cachedSecretsByRole.Get(*secretID); ok { 102 | secretsByRole := cachedElem.(map[string]secretsmanager.DescribeSecretOutput) 103 | if secretValueOut, ok := secretsByRole[role]; ok { 104 | _, found := p.PolledSecrets[*secretID] 105 | if found { 106 | return &secretValueOut, found 107 | } 108 | } 109 | } 110 | 111 | return nil, false 112 | } 113 | -------------------------------------------------------------------------------- /api/v1/syncedsecret_types.go: -------------------------------------------------------------------------------- 1 | /* 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 | 16 | package v1 17 | 18 | import ( 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | ) 21 | 22 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 23 | // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. 24 | 25 | type SecretRef struct { 26 | Name *string `json:"name"` 27 | } 28 | 29 | type DataFrom struct { 30 | SecretRef *SecretRef `json:"secretRef,omitempty"` 31 | } 32 | 33 | type SecretKeyRef struct { 34 | Name *string `json:"name"` 35 | Key *string `json:"key"` 36 | } 37 | 38 | type ValueFrom struct { 39 | // SecretRef 40 | // +optional 41 | SecretRef *SecretRef `json:"secretRef,omitempty"` 42 | 43 | // SecretKeyRef 44 | // +optional 45 | SecretKeyRef *SecretKeyRef `json:"secretKeyRef,omitempty"` 46 | 47 | // Template 48 | // +optional 49 | Template *string `json:"template,omitempty"` 50 | } 51 | 52 | type SecretField struct { 53 | Name *string `json:"name"` 54 | 55 | // Value 56 | // +optional 57 | Value *string `json:"value,omitempty"` 58 | 59 | // ValueFrom 60 | // +optional 61 | ValueFrom *ValueFrom `json:"valueFrom,omitempty"` 62 | } 63 | 64 | type SecretMetadata struct { 65 | Name string `json:"name,omitempty"` 66 | Namespace string `json:"namespace,omitempty"` 67 | Labels map[string]string `json:"labels,omitempty"` 68 | Annotations map[string]string `json:"annotations,omitempty"` 69 | CreationTimestamp metav1.Time `json:"creationTimestamp,omitempty"` 70 | } 71 | 72 | // SyncedSecretSpec defines the desired state of SyncedSecret 73 | type SyncedSecretSpec struct { 74 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 75 | // Important: Run "make" to regenerate code after modifying this file 76 | 77 | // Secret Metadata 78 | SecretMetadata SecretMetadata `json:"secretMetadata,omitempty"` 79 | // IAMRole 80 | // +optional 81 | IAMRole *string `json:"IAMRole"` 82 | 83 | // Data 84 | // +optional 85 | Data []*SecretField `json:"data,omitempty"` 86 | 87 | // DataFrom 88 | // +optional 89 | DataFrom *DataFrom `json:"dataFrom,omitempty"` 90 | 91 | // AWSAccountID 92 | // +optional 93 | AWSAccountID *string `json:"AWSAccountID,omitempty"` 94 | } 95 | 96 | // SyncedSecretStatus defines the observed state of SyncedSecret 97 | type SyncedSecretStatus struct { 98 | // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster 99 | // Important: Run "make" to regenerate code after modifying this file 100 | 101 | // this is the version of the secret that is present in k8s secret this should be coming from the local cache 102 | CurrentVersionID string `json:"currentVersionID"` 103 | 104 | // hash(secret.data) that was generated, used for checking of a Secret has diverged and if it needs reconciling 105 | SecretHash string `json:"generatedSecretHash,omitempty"` 106 | } 107 | 108 | // +kubebuilder:object:root=true 109 | // +kubebuilder:subresource:status 110 | 111 | // SyncedSecret is the Schema for the SyncedSecrets API 112 | type SyncedSecret struct { 113 | metav1.TypeMeta `json:",inline"` 114 | metav1.ObjectMeta `json:"metadata,omitempty"` 115 | 116 | Spec SyncedSecretSpec `json:"spec,omitempty"` 117 | Status SyncedSecretStatus `json:"status,omitempty"` 118 | } 119 | 120 | // +kubebuilder:object:root=true 121 | 122 | // SyncedSecretList contains a list of SyncedSecret 123 | type SyncedSecretList struct { 124 | metav1.TypeMeta `json:",inline"` 125 | metav1.ListMeta `json:"metadata,omitempty"` 126 | Items []SyncedSecret `json:"items"` 127 | } 128 | 129 | func init() { 130 | SchemeBuilder.Register(&SyncedSecret{}, &SyncedSecretList{}) 131 | } 132 | -------------------------------------------------------------------------------- /pkg/secretsmanager/poller.go: -------------------------------------------------------------------------------- 1 | package secretsmanager 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/go-logr/logr" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | 11 | "github.com/aws/aws-sdk-go/aws/awserr" 12 | "github.com/aws/aws-sdk-go/service/secretsmanager" 13 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 14 | lru "github.com/hashicorp/golang-lru" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | type SecretGetter interface { 19 | GetSecret(secretID *string) (string, string, error) 20 | } 21 | type Secrets map[string]PolledSecretMeta 22 | 23 | type Poller struct { 24 | PolledSecrets Secrets 25 | getSMClient func(string) (secretsmanageriface.SecretsManagerAPI, error) 26 | defaultSearchRole string 27 | 28 | smLastPolledOn time.Time 29 | cachedSecretValuesByRole *lru.TwoQueueCache 30 | cachedSecretsByRole *lru.TwoQueueCache 31 | wg sync.WaitGroup 32 | errs chan<- error 33 | quit chan bool 34 | Log logr.Logger 35 | } 36 | 37 | // SecretMeta meta information of a polled secret 38 | type PolledSecretMeta struct { 39 | Tags map[string]string 40 | CurrentVersionID string 41 | UpdatedAt time.Time 42 | } 43 | 44 | // New creates a new poller, will send polling or other non critical errors through the errs channel 45 | func New(interval time.Duration, errs chan error, getSMClient func(string) (secretsmanageriface.SecretsManagerAPI, error), defaultSearchRole string, logger logr.Logger) (*Poller, error) { 46 | p := &Poller{ 47 | errs: errs, 48 | getSMClient: getSMClient, 49 | quit: make(chan bool), 50 | defaultSearchRole: defaultSearchRole, 51 | Log: logger, 52 | } 53 | var err error 54 | // init a lru cache that can hold 10000 items (arbit value for now) 55 | // this doesn't init the size to value set here, but is only used to figure if eviction is required or not 56 | p.cachedSecretValuesByRole, err = lru.New2Q(10000) 57 | p.cachedSecretsByRole, err = lru.New2Q(10000) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | // poll in sync the first time to ensure that we have a populated cache before reconciler kicks in 63 | p.PolledSecrets, err = p.fetchSecrets() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | p.Log.Info("Fetched secrets from AWS after starting", "numberOfSecrets", len(p.PolledSecrets)) 69 | go func() { 70 | p.wg.Add(1) 71 | ticker := time.NewTicker(interval) 72 | p.poll(ticker) 73 | ticker.Stop() 74 | p.wg.Done() 75 | }() 76 | 77 | return p, nil 78 | } 79 | 80 | func (p *Poller) Stop() { 81 | p.quit <- true 82 | p.wg.Wait() 83 | } 84 | 85 | // poller polls secrets manager at `tick` defined intervals, caches it locally, 86 | func (p *Poller) poll(ticker *time.Ticker) { 87 | for { 88 | select { 89 | case _ = <-ticker.C: 90 | polledSecrets, err := p.fetchSecrets() 91 | if err != nil { 92 | p.errs <- errors.WithMessagef(err, "failed polling secrets") 93 | } else { 94 | p.PolledSecrets = polledSecrets 95 | p.Log.Info("Fetched secrets from AWS", "numberOfSecres", len(p.PolledSecrets)) 96 | } 97 | 98 | case <-p.quit: 99 | close(p.errs) 100 | return 101 | } 102 | } 103 | } 104 | 105 | func (p *Poller) fetchSecrets() (Secrets, error) { 106 | fetchedSecrets := make(Secrets) 107 | 108 | allSecrets := []*secretsmanager.SecretListEntry{} 109 | input := &secretsmanager.ListSecretsInput{ 110 | MaxResults: aws.Int64(100), 111 | } 112 | 113 | smClient, err := p.getSMClient(p.defaultSearchRole) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | err = smClient.ListSecretsPages(input, func(page *secretsmanager.ListSecretsOutput, lastPage bool) bool { 119 | allSecrets = append(allSecrets, page.SecretList...) 120 | return !lastPage 121 | }) 122 | 123 | if err != nil { 124 | if aerr, ok := err.(awserr.Error); ok { 125 | return nil, errors.WithMessagef(aerr, "failed listing secrets, error code: %s", aerr.Code()) 126 | } 127 | return nil, errors.WithMessagef(err, "failed listing secrets") 128 | } 129 | 130 | for _, secret := range allSecrets { 131 | if secret.DeletedDate != nil { 132 | continue 133 | } 134 | 135 | versionID, err := getCurrentVersion(secret.SecretVersionsToStages) 136 | if err != nil { 137 | continue 138 | } 139 | 140 | secretTags := map[string]string{} 141 | for _, t := range secret.Tags { 142 | secretTags[*t.Key] = *t.Value 143 | } 144 | 145 | fetchedSecrets[*secret.Name] = PolledSecretMeta{ 146 | Tags: secretTags, 147 | CurrentVersionID: versionID, 148 | UpdatedAt: *secret.LastChangedDate, 149 | } 150 | } 151 | 152 | p.smLastPolledOn = time.Now().UTC() 153 | return fetchedSecrets, nil 154 | } 155 | 156 | // getCurrentVersion finds the versionid with AWSCURRENT 157 | func getCurrentVersion(secretVersionToStages map[string][]*string) (string, error) { 158 | for uuid, stages := range secretVersionToStages { 159 | for _, stage := range stages { 160 | if *stage == "AWSCURRENT" { 161 | return uuid, nil 162 | } 163 | } 164 | } 165 | return "", errors.New("version with stage AWSCURRENT not found") 166 | } 167 | -------------------------------------------------------------------------------- /config/crd/bases/secrets.contentful.com_syncedsecrets.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: syncedsecrets.secrets.contentful.com 8 | spec: 9 | group: secrets.contentful.com 10 | names: 11 | kind: SyncedSecret 12 | listKind: SyncedSecretList 13 | plural: syncedsecrets 14 | singular: syncedsecret 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: SyncedSecret is the Schema for the SyncedSecrets API 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: SyncedSecretSpec defines the desired state of SyncedSecret 41 | properties: 42 | AWSAccountID: 43 | description: AWSAccountID 44 | type: string 45 | IAMRole: 46 | description: IAMRole 47 | type: string 48 | data: 49 | description: Data 50 | items: 51 | properties: 52 | name: 53 | type: string 54 | value: 55 | description: Value 56 | type: string 57 | valueFrom: 58 | description: ValueFrom 59 | properties: 60 | secretKeyRef: 61 | description: SecretKeyRef 62 | properties: 63 | key: 64 | type: string 65 | name: 66 | type: string 67 | required: 68 | - key 69 | - name 70 | type: object 71 | secretRef: 72 | description: SecretRef 73 | properties: 74 | name: 75 | type: string 76 | required: 77 | - name 78 | type: object 79 | template: 80 | description: Template 81 | type: string 82 | type: object 83 | required: 84 | - name 85 | type: object 86 | type: array 87 | dataFrom: 88 | description: DataFrom 89 | properties: 90 | secretRef: 91 | properties: 92 | name: 93 | type: string 94 | required: 95 | - name 96 | type: object 97 | type: object 98 | secretMetadata: 99 | description: Secret Metadata 100 | properties: 101 | annotations: 102 | additionalProperties: 103 | type: string 104 | type: object 105 | creationTimestamp: 106 | format: date-time 107 | type: string 108 | labels: 109 | additionalProperties: 110 | type: string 111 | type: object 112 | name: 113 | type: string 114 | namespace: 115 | type: string 116 | type: object 117 | type: object 118 | status: 119 | description: SyncedSecretStatus defines the observed state of SyncedSecret 120 | properties: 121 | currentVersionID: 122 | description: this is the version of the secret that is present in 123 | k8s secret this should be coming from the local cache 124 | type: string 125 | generatedSecretHash: 126 | description: hash(secret.data) that was generated, used for checking 127 | of a Secret has diverged and if it needs reconciling 128 | type: string 129 | required: 130 | - currentVersionID 131 | type: object 132 | type: object 133 | served: true 134 | storage: true 135 | subresources: 136 | status: {} 137 | -------------------------------------------------------------------------------- /examples/apiextensions.k8s.io_v1beta1_customresourcedefinition_syncedsecrets.secrets.contentful.com.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.17.1 7 | name: syncedsecrets.secrets.contentful.com 8 | spec: 9 | group: secrets.contentful.com 10 | names: 11 | kind: SyncedSecret 12 | listKind: SyncedSecretList 13 | plural: syncedsecrets 14 | singular: syncedsecret 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: SyncedSecret is the Schema for the SyncedSecrets API 21 | properties: 22 | apiVersion: 23 | description: |- 24 | APIVersion defines the versioned schema of this representation of an object. 25 | Servers should convert recognized schemas to the latest internal value, and 26 | may reject unrecognized values. 27 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources 28 | type: string 29 | kind: 30 | description: |- 31 | Kind is a string value representing the REST resource this object represents. 32 | Servers may infer this from the endpoint the client submits requests to. 33 | Cannot be updated. 34 | In CamelCase. 35 | More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 36 | type: string 37 | metadata: 38 | type: object 39 | spec: 40 | description: SyncedSecretSpec defines the desired state of SyncedSecret 41 | properties: 42 | AWSAccountID: 43 | description: AWSAccountID 44 | type: string 45 | IAMRole: 46 | description: IAMRole 47 | type: string 48 | data: 49 | description: Data 50 | items: 51 | properties: 52 | name: 53 | type: string 54 | value: 55 | description: Value 56 | type: string 57 | valueFrom: 58 | description: ValueFrom 59 | properties: 60 | secretKeyRef: 61 | description: SecretKeyRef 62 | properties: 63 | key: 64 | type: string 65 | name: 66 | type: string 67 | required: 68 | - key 69 | - name 70 | type: object 71 | secretRef: 72 | description: SecretRef 73 | properties: 74 | name: 75 | type: string 76 | required: 77 | - name 78 | type: object 79 | template: 80 | description: Template 81 | type: string 82 | type: object 83 | required: 84 | - name 85 | type: object 86 | type: array 87 | dataFrom: 88 | description: DataFrom 89 | properties: 90 | secretRef: 91 | properties: 92 | name: 93 | type: string 94 | required: 95 | - name 96 | type: object 97 | type: object 98 | secretMetadata: 99 | description: Secret Metadata 100 | properties: 101 | annotations: 102 | additionalProperties: 103 | type: string 104 | type: object 105 | creationTimestamp: 106 | format: date-time 107 | type: string 108 | labels: 109 | additionalProperties: 110 | type: string 111 | type: object 112 | name: 113 | type: string 114 | namespace: 115 | type: string 116 | type: object 117 | type: object 118 | status: 119 | description: SyncedSecretStatus defines the observed state of SyncedSecret 120 | properties: 121 | currentVersionID: 122 | description: this is the version of the secret that is present in 123 | k8s secret this should be coming from the local cache 124 | type: string 125 | generatedSecretHash: 126 | description: hash(secret.data) that was generated, used for checking 127 | of a Secret has diverged and if it needs reconciling 128 | type: string 129 | required: 130 | - currentVersionID 131 | type: object 132 | type: object 133 | served: true 134 | storage: true 135 | subresources: 136 | status: {} 137 | -------------------------------------------------------------------------------- /pkg/k8ssecret/secret.go: -------------------------------------------------------------------------------- 1 | package k8ssecret 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "reflect" 9 | "text/template" 10 | 11 | "github.com/Masterminds/sprig" 12 | "github.com/contentful-labs/kube-secret-syncer/pkg/secretsmanager" 13 | "github.com/go-logr/logr" 14 | "github.com/pkg/errors" 15 | 16 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 17 | corev1 "k8s.io/api/core/v1" 18 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 19 | ) 20 | 21 | func K8SSecretsEqual(secret1, secret2 corev1.Secret) bool { 22 | if !reflect.DeepEqual(secret1.Data, secret2.Data) { 23 | return false 24 | } 25 | 26 | if !reflect.DeepEqual(secret1.ObjectMeta.Annotations, secret2.ObjectMeta.Annotations) { 27 | return false 28 | } 29 | 30 | return true 31 | } 32 | 33 | func GenerateK8SSecret( 34 | cs secretsv1.SyncedSecret, 35 | secrets secretsmanager.Secrets, 36 | secretValueGetter func(string, string) (string, error), 37 | secretFilterByTagKey func(secretsmanager.Secrets, string) secretsmanager.Secrets, 38 | log logr.Logger, 39 | ) (*corev1.Secret, error) { 40 | annotations := map[string]string{} 41 | if cs.Spec.SecretMetadata.Annotations != nil { 42 | for key, val := range cs.Spec.SecretMetadata.Annotations { 43 | annotations[key] = val 44 | } 45 | } 46 | 47 | labels := map[string]string{} 48 | if cs.Spec.SecretMetadata.Labels != nil { 49 | for key, val := range cs.Spec.SecretMetadata.Labels { 50 | labels[key] = val 51 | } 52 | } 53 | 54 | secretMeta := metav1.ObjectMeta{ 55 | Name: cs.ObjectMeta.Name, 56 | Namespace: cs.ObjectMeta.Namespace, 57 | } 58 | if len(annotations) > 0 { 59 | secretMeta.Annotations = annotations 60 | } 61 | if len(labels) > 0 { 62 | secretMeta.Labels = labels 63 | } 64 | 65 | // Now to the data... 66 | data := make(map[string][]byte) 67 | if cs.Spec.DataFrom != nil { 68 | var secretRef *string // secretID of the secret in secret Manager 69 | if cs.Spec.DataFrom.SecretRef != nil { 70 | secretRef = cs.Spec.DataFrom.SecretRef.Name 71 | } 72 | 73 | if secretRef != nil { 74 | var iamrole string 75 | if cs.Spec.AWSAccountID != nil { 76 | iamrole = fmt.Sprintf("arn:aws:iam::%s:role/secret-syncer", *cs.Spec.AWSAccountID) 77 | } else { 78 | iamrole = *cs.Spec.IAMRole 79 | } 80 | AWSSecretValue, err := secretValueGetter(*secretRef, iamrole) 81 | if err != nil { 82 | return nil, err 83 | } 84 | var AWSSecretValuesMap map[string]interface{} 85 | err = json.Unmarshal([]byte(AWSSecretValue), &AWSSecretValuesMap) 86 | if err != nil { 87 | return nil, fmt.Errorf("secret %s is not a valid JSON", *secretRef) 88 | } 89 | for secretKey, secretValue := range AWSSecretValuesMap { 90 | data[secretKey] = []byte(fmt.Sprintf("%v", secretValue)) 91 | } 92 | } 93 | } 94 | 95 | if cs.Spec.Data != nil { 96 | var iamrole string 97 | if cs.Spec.AWSAccountID != nil { 98 | iamrole = fmt.Sprintf("arn:aws:iam::%s:role/secret-syncer", *cs.Spec.AWSAccountID) 99 | } else { 100 | iamrole = *cs.Spec.IAMRole 101 | } 102 | for _, field := range cs.Spec.Data { 103 | if field.Value != nil { 104 | data[*field.Name] = []byte(*field.Value) 105 | } 106 | 107 | if field.ValueFrom != nil { 108 | if field.ValueFrom.SecretRef != nil { 109 | AWSSecretValue, err := secretValueGetter(*field.ValueFrom.SecretRef.Name, iamrole) 110 | if err != nil { 111 | return nil, err 112 | } 113 | data[*field.Name] = []byte(AWSSecretValue) 114 | } 115 | 116 | if field.ValueFrom.SecretKeyRef != nil { 117 | AWSSecretValue, err := secretValueGetter(*field.ValueFrom.SecretKeyRef.Name, iamrole) 118 | if err != nil { 119 | return nil, err 120 | } 121 | AWSSecretValuesMap := map[string]interface{}{} 122 | if err := json.Unmarshal([]byte(AWSSecretValue), &AWSSecretValuesMap); err != nil { 123 | return nil, err 124 | } 125 | data[*field.Name] = []byte(fmt.Sprintf("%v", AWSSecretValuesMap[*field.ValueFrom.SecretKeyRef.Key])) 126 | } 127 | 128 | if field.ValueFrom.Template != nil { 129 | tpl := template.New(cs.Name) 130 | tpl = tpl.Funcs(template.FuncMap{ 131 | "getSecretValue": func(secretID string) (string, error) { 132 | return secretValueGetter(secretID, iamrole) 133 | }, 134 | "getSecretValueMap": func(secretID string) (map[string]interface{}, error) { 135 | raw, err := secretValueGetter(secretID, iamrole) 136 | if err != nil { 137 | return nil, fmt.Errorf("failed retrieving value for secret %s", secretID) 138 | } 139 | var asMap map[string]interface{} 140 | if err := json.Unmarshal([]byte(raw), &asMap); err != nil { 141 | return nil, fmt.Errorf("secret %s does not contain a valid JSON", secretID) 142 | } 143 | return asMap, err 144 | }, 145 | "filterByTagKey": secretFilterByTagKey, 146 | "base64": func(value interface{}) string { 147 | return base64.StdEncoding.EncodeToString([]byte(value.(string))) 148 | }, 149 | "indent": sprig.FuncMap()["indent"], 150 | }) 151 | 152 | var err error 153 | if tpl, err = tpl.Parse(*field.ValueFrom.Template); err != nil { 154 | return nil, errors.Wrap(err, "error parsing template from secret") 155 | } 156 | 157 | buf := new(bytes.Buffer) 158 | type templateParams struct { 159 | Secrets secretsmanager.Secrets 160 | } 161 | if err = tpl.Execute(buf, templateParams{Secrets: secrets}); err != nil { 162 | return nil, errors.Wrap(err, "error executing template from SyncedSecret") 163 | } 164 | 165 | data[*field.Name] = buf.Bytes() 166 | } 167 | } 168 | } 169 | } 170 | secret := &corev1.Secret{ 171 | TypeMeta: metav1.TypeMeta{ 172 | APIVersion: "v1", 173 | Kind: "Secret", 174 | }, 175 | ObjectMeta: secretMeta, 176 | Type: "Opaque", 177 | Data: data, 178 | } 179 | 180 | return secret, nil 181 | } 182 | 183 | func SecretLength(secret *corev1.Secret) int { 184 | length := 0 185 | 186 | for _, v := range secret.Data { 187 | length += len(v) 188 | } 189 | 190 | return length 191 | } 192 | -------------------------------------------------------------------------------- /pkg/secretsmanager/secrets_test.go: -------------------------------------------------------------------------------- 1 | package secretsmanager 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/secretsmanager" 10 | lru "github.com/hashicorp/golang-lru" 11 | ) 12 | 13 | func TestFetchCurrentSecret(t *testing.T) { 14 | type Want struct { 15 | resp *secretsmanager.GetSecretValueOutput 16 | found bool 17 | } 18 | type Have struct { 19 | poller *Poller 20 | secretID string 21 | secretVersion string 22 | lruElements map[string]map[string]secretsmanager.GetSecretValueOutput 23 | } 24 | for _, test := range []struct { 25 | name string 26 | have Have 27 | want Want 28 | }{ 29 | { 30 | name: "when the cache is dirty", 31 | have: Have{ 32 | poller: &Poller{ 33 | PolledSecrets: Secrets{ 34 | "cf/secret/test": PolledSecretMeta{ 35 | CurrentVersionID: "present", 36 | UpdatedAt: time.Now().AddDate(0, 0, -2), 37 | }, 38 | }, 39 | }, 40 | secretID: "cf/secret/test", 41 | lruElements: map[string]map[string]secretsmanager.GetSecretValueOutput{ 42 | "cf/secret/test": { 43 | "": { 44 | VersionId: _s("past"), 45 | }, 46 | }, 47 | }, 48 | }, 49 | want: Want{ 50 | resp: nil, 51 | found: false, 52 | }, 53 | }, 54 | { 55 | name: "when the cache is valid", 56 | have: Have{ 57 | poller: &Poller{ 58 | PolledSecrets: Secrets{ 59 | "cf/secret/test": PolledSecretMeta{ 60 | CurrentVersionID: "present", 61 | UpdatedAt: time.Now().AddDate(0, 0, -2), 62 | }, 63 | }, 64 | }, 65 | secretID: "cf/secret/test", 66 | lruElements: map[string]map[string]secretsmanager.GetSecretValueOutput{ 67 | "cf/secret/test": { 68 | "": { 69 | VersionId: _s("present"), 70 | }, 71 | }, 72 | }, 73 | }, 74 | want: Want{ 75 | resp: &secretsmanager.GetSecretValueOutput{ 76 | VersionId: _s("present"), 77 | }, 78 | found: true, 79 | }, 80 | }, 81 | { 82 | name: "when the polledcache is empty", 83 | have: Have{ 84 | poller: &Poller{ 85 | PolledSecrets: Secrets{}, 86 | }, 87 | secretID: "cf/secret/test", 88 | lruElements: map[string]map[string]secretsmanager.GetSecretValueOutput{ 89 | "cf/secret/test": { 90 | "": { 91 | VersionId: _s("present"), 92 | }, 93 | }, 94 | }, 95 | }, 96 | want: Want{ 97 | resp: nil, 98 | found: false, 99 | }, 100 | }, 101 | } { 102 | test.have.poller.cachedSecretValuesByRole, _ = lru.New2Q(10) 103 | for k, v := range test.have.lruElements { 104 | test.have.poller.cachedSecretValuesByRole.Add(k, v) 105 | } 106 | 107 | gotResp, gotFound := test.have.poller.fetchCurrentSecretCache(&test.have.secretID, "") 108 | if !reflect.DeepEqual(gotResp, test.want.resp) { 109 | t.Errorf("resp doesn't match %s. Wanted %v, got %v", test.name, test.want.resp, gotResp) 110 | } 111 | if gotFound != test.want.found { 112 | t.Errorf("found doesn't match %s. Wanted %v, got %v", test.name, test.want.found, gotFound) 113 | } 114 | } 115 | } 116 | 117 | func TestDescribeSecret(t *testing.T) { 118 | type Want struct { 119 | resp *secretsmanager.DescribeSecretOutput 120 | found bool 121 | } 122 | type Have struct { 123 | poller *Poller 124 | secretID string 125 | role string 126 | lruElements map[string]map[string]secretsmanager.DescribeSecretOutput 127 | } 128 | for _, test := range []struct { 129 | name string 130 | have Have 131 | want Want 132 | }{ 133 | { 134 | name: "when the cache is dirty", 135 | have: Have{ 136 | poller: &Poller{ 137 | PolledSecrets: Secrets{ 138 | "cf/secret/test": PolledSecretMeta{ 139 | CurrentVersionID: "present", 140 | UpdatedAt: time.Now().AddDate(0, 0, -2), 141 | }, 142 | }, 143 | }, 144 | secretID: "cf/secret/test", 145 | lruElements: map[string]map[string]secretsmanager.DescribeSecretOutput{ 146 | "cf/secret/test": { 147 | "": { 148 | ARN: aws.String("arn:aws:secretsmanager:us-west-2:123456789012:secret:cf/secret/test"), 149 | }, 150 | }, 151 | }, 152 | role: "my-role", 153 | }, 154 | want: Want{ 155 | resp: nil, 156 | found: false, 157 | }, 158 | }, 159 | { 160 | name: "when the cache is valid", 161 | have: Have{ 162 | poller: &Poller{ 163 | PolledSecrets: Secrets{ 164 | "cf/secret/test": PolledSecretMeta{ 165 | CurrentVersionID: "present", 166 | UpdatedAt: time.Now().AddDate(0, 0, -2), 167 | }, 168 | }, 169 | }, 170 | secretID: "cf/secret/test", 171 | lruElements: map[string]map[string]secretsmanager.DescribeSecretOutput{ 172 | "cf/secret/test": { 173 | "my-role": { 174 | ARN: aws.String("arn:aws:secretsmanager:us-west-2:123456789012:secret:cf/secret/test"), 175 | }, 176 | }, 177 | }, 178 | role: "my-role", 179 | }, 180 | want: Want{ 181 | resp: &secretsmanager.DescribeSecretOutput{ 182 | ARN: aws.String("arn:aws:secretsmanager:us-west-2:123456789012:secret:cf/secret/test"), 183 | }, 184 | found: true, 185 | }, 186 | }, 187 | { 188 | name: "when the polledcache is empty", 189 | have: Have{ 190 | poller: &Poller{ 191 | PolledSecrets: Secrets{}, 192 | }, 193 | secretID: "cf/secret/test", 194 | lruElements: map[string]map[string]secretsmanager.DescribeSecretOutput{ 195 | "cf/secret/test": { 196 | "my-role": { 197 | ARN: aws.String("arn:aws:secretsmanager:us-west-2:123456789012:secret:cf/secret/test"), 198 | }, 199 | }, 200 | }, 201 | role: "my-role", 202 | }, 203 | want: Want{ 204 | resp: nil, 205 | found: false, 206 | }, 207 | }, 208 | } { 209 | test.have.poller.cachedSecretsByRole, _ = lru.New2Q(10) 210 | for k, v := range test.have.lruElements { 211 | test.have.poller.cachedSecretsByRole.Add(k, v) 212 | } 213 | gotResp, gotFound := test.have.poller.fetchCurrentDescribedSecretCache(&test.have.secretID, test.have.role) 214 | if !reflect.DeepEqual(gotResp, test.want.resp) { 215 | t.Errorf("resp doesn't match %s. Wanted %v, got %v", test.name, test.want.resp, gotResp) 216 | } 217 | if gotFound != test.want.found { 218 | t.Errorf("found doesn't match %s. Wanted %v, got %v", test.name, test.want.found, gotFound) 219 | } 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 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 | 16 | package main 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "os" 22 | "strconv" 23 | "time" 24 | 25 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 26 | "github.com/contentful-labs/kube-secret-syncer/pkg/namespacevalidator" 27 | 28 | "github.com/aws/aws-sdk-go/aws" 29 | awsclient "github.com/aws/aws-sdk-go/aws/client" 30 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 31 | "github.com/aws/aws-sdk-go/aws/request" 32 | "github.com/aws/aws-sdk-go/aws/session" 33 | "github.com/aws/aws-sdk-go/service/secretsmanager" 34 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 35 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 36 | "github.com/contentful-labs/kube-secret-syncer/controllers" 37 | "github.com/contentful-labs/kube-secret-syncer/pkg/iam" 38 | "github.com/contentful-labs/kube-secret-syncer/pkg/rolevalidator" 39 | uzap "go.uber.org/zap" 40 | "go.uber.org/zap/zapcore" 41 | "k8s.io/apimachinery/pkg/runtime" 42 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 43 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 44 | ctrl "sigs.k8s.io/controller-runtime" 45 | "sigs.k8s.io/controller-runtime/pkg/cache" 46 | "sigs.k8s.io/controller-runtime/pkg/log" 47 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 48 | "sigs.k8s.io/controller-runtime/pkg/metrics/server" 49 | // +kubebuilder:scaffold:imports 50 | ) 51 | 52 | var ( 53 | scheme = runtime.NewScheme() 54 | setupLog = ctrl.Log.WithName("setup") 55 | ) 56 | 57 | func init() { 58 | _ = clientgoscheme.AddToScheme(scheme) 59 | 60 | _ = secretsv1.AddToScheme(scheme) 61 | // +kubebuilder:scaffold:scheme 62 | } 63 | 64 | type SMSVCFactory struct { 65 | session *session.Session 66 | arns iam.ARNGetter 67 | SMSVC secretsmanageriface.SecretsManagerAPI // Main, default SM service - used when no IAM Role is specified in the secret 68 | AssumedSMSVCs map[string]secretsmanageriface.SecretsManagerAPI // SM Service for each IAM Role 69 | } 70 | 71 | func getDurationFromEnv(envVar string, defaultDuration time.Duration) (time.Duration, error) { 72 | value, ok := os.LookupEnv(envVar) 73 | if ok { 74 | if value == "" { 75 | return defaultDuration, nil 76 | } 77 | 78 | valueInt, err := strconv.Atoi(value) 79 | if err == nil { 80 | interval := time.Second * time.Duration(valueInt) 81 | return interval, nil 82 | } 83 | return 0 * time.Second, fmt.Errorf("%s invalid: %s", envVar, value) 84 | } 85 | return defaultDuration, nil 86 | } 87 | 88 | func (s SMSVCFactory) getSMSVC(iamRole string) (secretsmanageriface.SecretsManagerAPI, error) { 89 | var smsvc secretsmanageriface.SecretsManagerAPI 90 | var err error 91 | 92 | // No iamRole specified, we use the default service 93 | if iamRole == "" { 94 | return s.SMSVC, nil 95 | } 96 | 97 | // ensure specified iamRole is an ARN 98 | iamGetARN, err := s.arns.GetARN(iamRole) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | var ok bool 104 | smsvc, ok = s.AssumedSMSVCs[iamGetARN] 105 | if !ok { 106 | creds := stscreds.NewCredentials(s.session, iamGetARN) 107 | smsvc = secretsmanager.New(s.session, &aws.Config{Credentials: creds}) 108 | s.AssumedSMSVCs[iamGetARN] = smsvc 109 | } 110 | 111 | return smsvc, nil 112 | } 113 | 114 | func newSMSVCFactory(sess *session.Session, arnGetter iam.ARNGetter) *SMSVCFactory { 115 | return &SMSVCFactory{ 116 | session: sess, 117 | arns: arnGetter, 118 | SMSVC: secretsmanager.New(sess), 119 | AssumedSMSVCs: map[string]secretsmanageriface.SecretsManagerAPI{}, 120 | } 121 | } 122 | 123 | func realMain() int { 124 | metricsAddr := os.Getenv("METRICS_LISTEN") 125 | if metricsAddr == "" { 126 | metricsAddr = ":8080" 127 | } 128 | 129 | // empty string default is what we want 130 | defaultSearchRole := os.Getenv("POLL_DEFAULT_SEARCH_ROLE") 131 | 132 | annotationName := os.Getenv("NS_ANNOTATION") 133 | if annotationName == "" { 134 | annotationName = "iam.amazonaws.com/allowed-roles" 135 | } 136 | 137 | syncPeriod, err := getDurationFromEnv("SYNC_INTERVAL_SEC", 120*time.Second) 138 | if err != nil { 139 | setupLog.Error(err, "failed parsing SYNC_INTERVAL_SEC: should be an integer") 140 | return 1 141 | } 142 | 143 | pollInterval, err := getDurationFromEnv("POLL_INTERVAL_SEC", 300*time.Second) 144 | if err != nil { 145 | setupLog.Error(err, "failed parsing POLL_INTERVAL_SEC: should be an integer") 146 | return 1 147 | } 148 | 149 | logCfg := zapcore.EncoderConfig{ 150 | TimeKey: "timestamp", 151 | LevelKey: "level", 152 | NameKey: "logger", 153 | CallerKey: "caller", 154 | MessageKey: "msg", 155 | StacktraceKey: "stacktrace", 156 | LineEnding: zapcore.DefaultLineEnding, 157 | EncodeLevel: zapcore.LowercaseLevelEncoder, 158 | EncodeTime: zapcore.ISO8601TimeEncoder, 159 | EncodeDuration: zapcore.SecondsDurationEncoder, 160 | EncodeCaller: zapcore.ShortCallerEncoder, 161 | } 162 | logger := zap.New(zap.Encoder(zapcore.NewJSONEncoder(logCfg)), zap.StacktraceLevel(uzap.PanicLevel)) 163 | log.SetLogger(logger) 164 | 165 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 166 | Scheme: scheme, 167 | Metrics: server.Options{ 168 | BindAddress: metricsAddr, 169 | }, 170 | LeaderElection: true, 171 | LeaderElectionID: "5a48bfe8.contentful.com", 172 | Cache: cache.Options{ 173 | SyncPeriod: &syncPeriod, 174 | }, 175 | Logger: logger, 176 | }) 177 | if err != nil { 178 | setupLog.Error(err, "unable to start manager") 179 | return 1 180 | } 181 | 182 | ctx := context.Background() 183 | 184 | Retry5Cfg := request.WithRetryer(aws.NewConfig(), awsclient.DefaultRetryer{NumMaxRetries: 5}) 185 | arnClient := iam.NewARNClientWithCache(iam.GetARN) 186 | smsvcfactory := newSMSVCFactory(session.Must(session.NewSession(Retry5Cfg)), arnClient) 187 | 188 | nsCache, err := k8snamespace.NewWatcher(ctx) 189 | if err != nil { 190 | setupLog.Error(err, "unable to start namespace watcher") 191 | return 1 192 | } 193 | 194 | roleValidator := rolevalidator.NewRoleValidator(arnClient, nsCache, annotationName) 195 | namespaceValidator := namespacevalidator.NewNamespaceValidator(nsCache) 196 | 197 | r := &controllers.SyncedSecretReconciler{ 198 | Client: mgr.GetClient(), 199 | Log: logger.WithName("controllers").WithName("SyncedSecret"), 200 | Sess: session.New(Retry5Cfg), 201 | GetSMClient: smsvcfactory.getSMSVC, 202 | DefaultSearchRole: defaultSearchRole, 203 | RoleValidator: roleValidator, 204 | NamespaceValidator: namespaceValidator, 205 | PollInterval: pollInterval, 206 | } 207 | 208 | if err = r.SetupWithManager(mgr); err != nil { 209 | setupLog.Error(err, "unable to create controller", "controller", "SyncedSecret") 210 | return 1 211 | } 212 | defer r.Quit() 213 | 214 | // +kubebuilder:scaffold:builder 215 | setupLog.Info("starting manager") 216 | if err = mgr.Start(ctrl.SetupSignalHandler()); err != nil { 217 | setupLog.Error(err, "problem running manager") 218 | return 1 219 | } 220 | 221 | return 0 222 | } 223 | 224 | func main() { 225 | // Call realMain so that defers work properly, since os.Exit won't 226 | // call defers. 227 | os.Exit(realMain()) 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kube-secret-syncer 2 | 3 | Kube-secret-syncer is a [Kubernetes operator](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) developed 4 | using [the Kubebuilder framework](https://github.com/kubernetes-sigs/kubebuilder) that keeps the values of Kubernetes 5 | Secrets synchronised to secrets in AWS Secrets Manager. 6 | 7 | This mapping is described in a Kubernetes custom resource called SyncedSecret. The operator polls AWS for changes in 8 | secret values at regular intervals, and upon detecting changes, updates the Kubernetes Secrets. 9 | 10 | __WARNING__: updating the value of a secret in AWS SecretsManager will override secrets in Kubernetes, therefore 11 | can be a destructive action. 12 | 13 | ## Comparison to existing projects 14 | 15 | Kube-secret-syncer is similar to other projects such as: 16 | * [Kubernetes external secrets](https://github.com/godaddy/kubernetes-external-secrets) 17 | * [AWS secret operator](https://github.com/mumoshu/aws-secret-operator) 18 | 19 | Kube-secret-syncer improves on this approach: 20 | * uses [caching](#caching) to only retrieve the value of secrets when they have changed, substantially reducing 21 | [costs](https://aws.amazon.com/secrets-manager/pricing/) when syncing a large number of secrets. 22 | * enables sophisticated access control to secrets in AWS SecretsManager using IAM roles - see our 23 | [security model](#security-model) 24 | * supports [templated fields](#templated-fields) for Kubernetes secrets - enabling the use of values from multiple AWS 25 | SecretsManager secrets in one Kubernetes Secret 26 | 27 | ## Defining mapping between an AWS SecretsManager secret and a Kubernetes Secret 28 | 29 | The following resource will map the AWS Secret `secretsyncer/secret/sample` to the Kubernetes Secret 30 | `demo-service-secret`, and copy all key-value pairs from the AWS SecretsManager secret to the Kubernetes secret For 31 | this example, the AWS SecretsManager secret needs to be a valid JSON consisting only of key-value pairs. 32 | 33 | To access the secrets, kube-secret-syncer will assume the role `iam_role` to poll the secret. Note: that role must be 34 | assumed by the Kubernetes cluster/node where the operator runs, eg part of the kube2iam annotation on the namespace. 35 | 36 | ```yaml 37 | apiVersion: secrets.contentful.com/v1 38 | kind: SyncedSecret 39 | metadata: 40 | name: demo-service-secret 41 | namespace: kube-secret-syncer 42 | spec: 43 | IAMRole: iam_role 44 | dataFrom: 45 | secretRef: 46 | name: secretsyncer/secret/sample 47 | ``` 48 | 49 | If you only need to retrieve select keys in a single AWS secret, or multiple keys from different AWS secrets, you 50 | can use the following syntax: 51 | 52 | ```yaml 53 | apiVersion: secrets.contentful.com/v1 54 | kind: SyncedSecret 55 | metadata: 56 | name: demo-service-secret 57 | namespace: kube-secret-syncer 58 | spec: 59 | IAMRole: iam_role 60 | data: 61 | # Sets the key mysql_user for the Kubernetes Secret "demo-service-secret" to "contentful" 62 | - name: mysql_user 63 | value: "contentful" 64 | # Takes the value for key "password" from the Secrets Manager secret "mysql", assign to the 65 | # key "mysql_pw" of the Kubernetes secret "demo-service-secret" 66 | - name: mysql_pw 67 | valueFrom: 68 | secretKeyRef: 69 | name: mysql 70 | key: password 71 | - name: datadog_access_key 72 | valueFrom: 73 | secretKeyRef: 74 | name: datadog 75 | key: access_key 76 | ``` 77 | 78 | You can also chose to store non-JSON values in AWS Secret Manager, which might be more convenient for data such 79 | as certificates. 80 | 81 | ```yaml 82 | apiVersion: secrets.contentful.com/v1 83 | kind: SyncedSecret 84 | metadata: 85 | name: demo-service-secret 86 | namespace: kube-secret-syncer 87 | spec: 88 | IAMRole: iam_role 89 | data: 90 | # Sets the key ssl-certificate for the Kubernetes Secret "demo-service-secret" 91 | # to the value of the secret "apache/ssl-cert" 92 | - name: ssl-certificate 93 | valueFrom: 94 | secretRef: 95 | name: apache/ssl-cert 96 | ``` 97 | 98 | ## [Templated fields](#templated-fields) 99 | 100 | Kube-secret-syncer supports templated fields. This allows, for example, to iterate over a list of secrets that 101 | share the same tag, to output a configuration file, such as in the following example: 102 | 103 | ```yaml 104 | apiVersion: secrets.contentful.com/v1 105 | kind: SyncedSecret 106 | metadata: 107 | name: pgbouncer.txt 108 | namespace: kube-secret-syncer 109 | spec: 110 | IAMRole: iam_role 111 | data: 112 | - name: pgbouncer-hosts 113 | valueFrom: 114 | template: | 115 | {{- $cfg := "" -}} 116 | {{- range $secretID, $_ := filterByTagKey .Secrets "tag1" -}} 117 | {{- $secretValue := getSecretValueMap $secretID -}} 118 | {{- $cfg = printf "%shost=%s user=%s password=%s\n" $cfg $secretValue.host $secretValue.user $secretValue.password -}} 119 | {{- end -}} 120 | {{- $cfg -}} 121 | ``` 122 | 123 | This iterates over all secrets kube-secret-syncer has access to, select those that have the tag "tag1" set, 124 | and for each of these, add a configuration line to $cfg. $cfg is then assigned to the key "pgbouncer-hosts" of 125 | the Kubernetes secret pgbouncer.txt. 126 | 127 | The template is a [Go template](https://golang.org/pkg/text/template/) with the following elements defined: 128 | * `.Secrets` - a map containing all listed secrets (without their value) 129 | * `filterByTagKey` - a helper function to filter the secrets by tag 130 | * `getSecretValue` - will retrieve the raw value of a Secret in SecretsManager, given its secret ID 131 | * `getSecretValueMap` - will retrieve the value of a Secret in SecretsManager that contains a JSON, given its secret ID - 132 | as a map 133 | 134 | ## [Caching](#caching) 135 | 136 | Kube-secret-syncer maintains both the list of AWS Secrets as well as their values in cache. The list is updated every 137 | `POLL_INTERVAL_SEC`, and values are retrieved whenever their VersionID changed. 138 | 139 | ## [Security model](#security-model) 140 | 141 | By default, kube-secret-syncer will use the Kubernetes node's IAM role to list and retrieve the secrets. However, when 142 | synced secrets have an IAMRole field defined, kube-secret-syncer will assume that role before retrieving the secret. This 143 | implies that the role specified by IAMRole can be assumed by the role of the Kubernetes node kube-secret-syncer runs on. 144 | 145 | To ensure a specific namespace only has access to the secrets it needs to, kube-secret-syncer will use the 146 | "iam.amazonaws.com/allowed-roles" annotation on the namespace (originally used by kube2iam) to validate that this 147 | role can be assumed for that namespace. 148 | 149 | The secret synchronisation will be allowed if: 150 | * the annotation is set on the namespace and contains the secrets IAMRole 151 | * no annotation is set on the namespace and the secret has a IAMRole set 152 | * no annotation is set on the namespace and the secret has no IAMRole set 153 | 154 | The secret sync will be denied if: 155 | * the annotation is set on the namespace and does not contains the secrets IAMRole 156 | * the annotation is set on the namespace and the secret has no IAMRole set 157 | 158 | ## Configuration 159 | 160 | Kube-secret-syncer supports the following environment variables: 161 | 162 | * `POLL_INTERVAL_SEC`: how often the list of secrets in cache is refreshed (default: `300`) 163 | * `SYNC_INTERVAL_SEC`: how often we will write to a Kubernetes secret (default: `120`) 164 | * `NS_ANNOTATION`: the annotation on the namespace that contains a list of IAM roles kube-secret-syncer is allowed 165 | to assume (default: `iam.amazonaws.com/allowed-roles`) 166 | * `METRICS_LISTEN`: what interface/port the metrics server shoult listen on (default: `:8080`) 167 | 168 | Note - when a secret in Secrets Manager is updated, the secret in Kubernetes will not be updated 169 | until both the list of secrets is refreshed AND the sync_interval expires - therefore it might take up 170 | to POLL_INTERVAL_SEC + SYNC_INTERVAL_SEC. 171 | 172 | ## Local development 173 | 174 | Please refer to the [local development documentation](docs/development.md). 175 | 176 | ## Examples 177 | 178 | See [sample configurations](config/samples) and [deployment example](examples). 179 | -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 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 | 16 | package controllers 17 | 18 | import ( 19 | "context" 20 | "os" 21 | "path/filepath" 22 | "testing" 23 | "time" 24 | 25 | "github.com/aws/aws-sdk-go/aws" 26 | awsclient "github.com/aws/aws-sdk-go/aws/client" 27 | "github.com/aws/aws-sdk-go/aws/request" 28 | "github.com/aws/aws-sdk-go/aws/session" 29 | 30 | . "github.com/onsi/ginkgo" 31 | . "github.com/onsi/gomega" 32 | 33 | "github.com/aws/aws-sdk-go/service/secretsmanager" 34 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 35 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 36 | "github.com/prometheus/client_golang/prometheus" 37 | corev1 "k8s.io/api/core/v1" 38 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 39 | "k8s.io/client-go/kubernetes/scheme" 40 | "k8s.io/client-go/rest" 41 | ctrl "sigs.k8s.io/controller-runtime" 42 | "sigs.k8s.io/controller-runtime/pkg/cache" 43 | "sigs.k8s.io/controller-runtime/pkg/client" 44 | "sigs.k8s.io/controller-runtime/pkg/envtest" 45 | logf "sigs.k8s.io/controller-runtime/pkg/log" 46 | zap "sigs.k8s.io/controller-runtime/pkg/log/zap" 47 | // +kubebuilder:scaffold:imports 48 | ) 49 | 50 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 51 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 52 | 53 | var cfg *rest.Config 54 | var k8sClient client.Client 55 | var k8sManager ctrl.Manager 56 | var testEnv *envtest.Environment 57 | 58 | const TEST_NAMESPACE = "secret-sync-test" 59 | const TEST_NAMESPACE2 = "secret-sync-test2" 60 | const TEST_NAMESPACE3 = "secret-sync-test3" 61 | 62 | var time_now = time.Now() 63 | 64 | var Secretsoutput *secretsmanager.ListSecretsOutput 65 | 66 | var MockSecretsOutput = mockSecretsOutput{} 67 | 68 | type mockSecretsOutput struct { 69 | SecretsPageOutput *secretsmanager.ListSecretsOutput 70 | SecretsValueOutput *secretsmanager.GetSecretValueOutput 71 | DescribeSecretOutput *secretsmanager.DescribeSecretOutput 72 | } 73 | 74 | type mockSecretsManagerClient struct { 75 | secretsmanageriface.SecretsManagerAPI 76 | } 77 | 78 | func _s(A string) *string { 79 | return &A 80 | } 81 | 82 | func _t(A time.Time) *time.Time { 83 | return &A 84 | } 85 | 86 | func keyValue(key, value string) *secretsmanager.Tag { 87 | return &secretsmanager.Tag{ 88 | Key: aws.String(key), 89 | Value: aws.String(value), 90 | } 91 | } 92 | 93 | type mockRoleValidator struct{} 94 | 95 | func (m *mockRoleValidator) IsWhitelisted(string, string) (bool, error) { 96 | return true, nil 97 | } 98 | 99 | type mockNamespaceValidator struct{} 100 | 101 | func (m *mockNamespaceValidator) HasNamespaceType(secretsmanager.DescribeSecretOutput, string) (bool, error) { 102 | return true, nil 103 | } 104 | 105 | // TODO this needs to be more dynamic when an update comes by 106 | func (m *mockSecretsManagerClient) ListSecretsPages(input *secretsmanager.ListSecretsInput, fn func(*secretsmanager.ListSecretsOutput, bool) bool) error { 107 | fn(MockSecretsOutput.SecretsPageOutput, true) 108 | return nil 109 | } 110 | 111 | func (m *mockSecretsManagerClient) GetSecretValue(*secretsmanager.GetSecretValueInput) (*secretsmanager.GetSecretValueOutput, error) { 112 | return MockSecretsOutput.SecretsValueOutput, nil 113 | } 114 | 115 | func (m *mockSecretsManagerClient) DescribeSecret(*secretsmanager.DescribeSecretInput) (*secretsmanager.DescribeSecretOutput, error) { 116 | return MockSecretsOutput.DescribeSecretOutput, nil 117 | } 118 | 119 | func TestAPIs(t *testing.T) { 120 | RegisterFailHandler(Fail) 121 | 122 | // This is deprecated, we need to replace it: https://onsi.github.io/ginkgo/MIGRATING_TO_V2#migration-strategy-2 123 | RunSpecsWithDefaultAndCustomReporters(t, 124 | "Controller Suite", 125 | []Reporter{}) 126 | } 127 | 128 | var _ = BeforeSuite(func(done Done) { 129 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter))) 130 | 131 | By("bootstrapping test environment") 132 | testEnv = &envtest.Environment{ 133 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 134 | } 135 | 136 | var err error 137 | cfg, err = testEnv.Start() 138 | Expect(err).ToNot(HaveOccurred()) 139 | Expect(cfg).ToNot(BeNil()) 140 | 141 | err = secretsv1.AddToScheme(scheme.Scheme) 142 | Expect(err).NotTo(HaveOccurred()) 143 | 144 | // +kubebuilder:scaffold:scheme 145 | 146 | syncPeriod := 2 * time.Second 147 | k8sManager, err = ctrl.NewManager(cfg, ctrl.Options{ 148 | Scheme: scheme.Scheme, 149 | Cache: cache.Options{SyncPeriod: &syncPeriod}, 150 | // SyncPeriod: &syncPeriod, 151 | }) 152 | Expect(err).ToNot(HaveOccurred()) 153 | Expect(k8sManager).ToNot(BeNil()) 154 | 155 | smSvc := mockSecretsManagerClient{} 156 | 157 | // create the secret for testing 158 | MockSecretsOutput.SecretsPageOutput = &secretsmanager.ListSecretsOutput{ 159 | SecretList: []*secretsmanager.SecretListEntry{ 160 | { 161 | Name: _s("random/aws/secret002"), 162 | LastChangedDate: _t(time_now.AddDate(0, 0, -2)), 163 | SecretVersionsToStages: map[string][]*string{ 164 | "002": { 165 | _s("AWSCURRENT"), 166 | }, 167 | }, 168 | }, { 169 | Name: _s("random/aws/secret003"), 170 | LastChangedDate: _t(time_now.AddDate(0, 0, -3)), 171 | SecretVersionsToStages: map[string][]*string{ 172 | "005": { 173 | _s("AWSCURRENT"), 174 | }, 175 | "003": { 176 | _s("AWSPREVIOUS"), 177 | }, 178 | }, 179 | }, { 180 | Name: _s("random/aws/secret004"), 181 | LastChangedDate: _t(time_now.AddDate(0, 0, -3)), 182 | SecretVersionsToStages: map[string][]*string{ 183 | "005": { 184 | _s("AWSCURRENT"), 185 | }, 186 | "004": { 187 | _s("AWSPREVIOUS"), 188 | }, 189 | }, 190 | }, { 191 | Name: _s("random/aws/secret005"), 192 | LastChangedDate: _t(time_now.AddDate(0, 0, -3)), 193 | SecretVersionsToStages: map[string][]*string{ 194 | "006": { 195 | _s("AWSCURRENT"), 196 | }, 197 | "005": { 198 | _s("AWSPREVIOUS"), 199 | }, 200 | }, 201 | }, 202 | }, 203 | } 204 | 205 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 206 | SecretString: _s(`{"database_name":"secretDB","database_pass":"cupofcoffee", "database_name1":"secretDB02"}`), 207 | VersionId: _s(`005`), 208 | } 209 | 210 | MockSecretsOutput.DescribeSecretOutput = &secretsmanager.DescribeSecretOutput{ 211 | ARN: _s("arn:aws:secretsmanager:us-west-2:123456789012:secret:random/aws/secret003-abc"), 212 | Tags: []*secretsmanager.Tag{ 213 | keyValue("k8s.contentful.com/namespace_type/secret-sync-test2", "1"), 214 | keyValue("k8s.contentful.com/namespace_type/secret-sync-test3", "1"), 215 | }, 216 | } 217 | 218 | // mock the manager setup 219 | Retry5Cfg := request.WithRetryer(aws.NewConfig(), awsclient.DefaultRetryer{NumMaxRetries: 5}) 220 | err = (&SyncedSecretReconciler{ 221 | Client: k8sManager.GetClient(), 222 | Log: ctrl.Log.WithName("controllers").WithName("SyncedSecret"), 223 | Sess: session.New(Retry5Cfg), 224 | GetSMClient: func(IAMRole string) (secretsmanageriface.SecretsManagerAPI, error) { 225 | return &smSvc, nil 226 | }, 227 | RoleValidator: &mockRoleValidator{}, 228 | NamespaceValidator: &mockNamespaceValidator{}, 229 | gauges: map[string]prometheus.Gauge{}, 230 | sync_state: map[string]bool{}, 231 | PollInterval: 3 * time.Second, 232 | }).SetupWithManager(k8sManager) 233 | Expect(err).ToNot(HaveOccurred()) 234 | 235 | // start the reconcilers 236 | go func() { 237 | err = k8sManager.Start(ctrl.SetupSignalHandler()) 238 | Expect(err).ToNot(HaveOccurred()) 239 | }() 240 | 241 | k8sClient = k8sManager.GetClient() 242 | Expect(k8sClient).ToNot(BeNil()) 243 | 244 | // create a namespace for running our tests in 245 | toCreate := &corev1.Namespace{ 246 | ObjectMeta: metav1.ObjectMeta{ 247 | Name: TEST_NAMESPACE, 248 | }, 249 | } 250 | toCreate2 := &corev1.Namespace{ 251 | ObjectMeta: metav1.ObjectMeta{ 252 | Name: TEST_NAMESPACE2, 253 | }, 254 | } 255 | toCreate3 := &corev1.Namespace{ 256 | ObjectMeta: metav1.ObjectMeta{ 257 | Name: TEST_NAMESPACE3, 258 | }, 259 | } 260 | 261 | err = k8sClient.Create(context.Background(), toCreate) 262 | Expect(err).To(BeNil()) 263 | err = k8sClient.Create(context.Background(), toCreate2) 264 | Expect(err).To(BeNil()) 265 | err = k8sClient.Create(context.Background(), toCreate3) 266 | Expect(err).To(BeNil()) 267 | Expect(err).To(BeNil()) 268 | 269 | close(done) 270 | }, 60) 271 | 272 | var _ = AfterSuite(func() { 273 | By("tearing down the test environment") 274 | err := testEnv.Stop() 275 | Expect(err).ToNot(HaveOccurred()) 276 | 277 | os.Unsetenv("POLL_INTERVAL_SEC") 278 | }) 279 | -------------------------------------------------------------------------------- /api/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | /* 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | 18 | // Code generated by controller-gen. DO NOT EDIT. 19 | 20 | package v1 21 | 22 | import ( 23 | runtime "k8s.io/apimachinery/pkg/runtime" 24 | ) 25 | 26 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 27 | func (in *DataFrom) DeepCopyInto(out *DataFrom) { 28 | *out = *in 29 | if in.SecretRef != nil { 30 | in, out := &in.SecretRef, &out.SecretRef 31 | *out = new(SecretRef) 32 | (*in).DeepCopyInto(*out) 33 | } 34 | } 35 | 36 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataFrom. 37 | func (in *DataFrom) DeepCopy() *DataFrom { 38 | if in == nil { 39 | return nil 40 | } 41 | out := new(DataFrom) 42 | in.DeepCopyInto(out) 43 | return out 44 | } 45 | 46 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 47 | func (in *SecretField) DeepCopyInto(out *SecretField) { 48 | *out = *in 49 | if in.Name != nil { 50 | in, out := &in.Name, &out.Name 51 | *out = new(string) 52 | **out = **in 53 | } 54 | if in.Value != nil { 55 | in, out := &in.Value, &out.Value 56 | *out = new(string) 57 | **out = **in 58 | } 59 | if in.ValueFrom != nil { 60 | in, out := &in.ValueFrom, &out.ValueFrom 61 | *out = new(ValueFrom) 62 | (*in).DeepCopyInto(*out) 63 | } 64 | } 65 | 66 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretField. 67 | func (in *SecretField) DeepCopy() *SecretField { 68 | if in == nil { 69 | return nil 70 | } 71 | out := new(SecretField) 72 | in.DeepCopyInto(out) 73 | return out 74 | } 75 | 76 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 77 | func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { 78 | *out = *in 79 | if in.Name != nil { 80 | in, out := &in.Name, &out.Name 81 | *out = new(string) 82 | **out = **in 83 | } 84 | if in.Key != nil { 85 | in, out := &in.Key, &out.Key 86 | *out = new(string) 87 | **out = **in 88 | } 89 | } 90 | 91 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. 92 | func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { 93 | if in == nil { 94 | return nil 95 | } 96 | out := new(SecretKeyRef) 97 | in.DeepCopyInto(out) 98 | return out 99 | } 100 | 101 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 102 | func (in *SecretMetadata) DeepCopyInto(out *SecretMetadata) { 103 | *out = *in 104 | if in.Labels != nil { 105 | in, out := &in.Labels, &out.Labels 106 | *out = make(map[string]string, len(*in)) 107 | for key, val := range *in { 108 | (*out)[key] = val 109 | } 110 | } 111 | if in.Annotations != nil { 112 | in, out := &in.Annotations, &out.Annotations 113 | *out = make(map[string]string, len(*in)) 114 | for key, val := range *in { 115 | (*out)[key] = val 116 | } 117 | } 118 | in.CreationTimestamp.DeepCopyInto(&out.CreationTimestamp) 119 | } 120 | 121 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretMetadata. 122 | func (in *SecretMetadata) DeepCopy() *SecretMetadata { 123 | if in == nil { 124 | return nil 125 | } 126 | out := new(SecretMetadata) 127 | in.DeepCopyInto(out) 128 | return out 129 | } 130 | 131 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 132 | func (in *SecretRef) DeepCopyInto(out *SecretRef) { 133 | *out = *in 134 | if in.Name != nil { 135 | in, out := &in.Name, &out.Name 136 | *out = new(string) 137 | **out = **in 138 | } 139 | } 140 | 141 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretRef. 142 | func (in *SecretRef) DeepCopy() *SecretRef { 143 | if in == nil { 144 | return nil 145 | } 146 | out := new(SecretRef) 147 | in.DeepCopyInto(out) 148 | return out 149 | } 150 | 151 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 152 | func (in *SyncedSecret) DeepCopyInto(out *SyncedSecret) { 153 | *out = *in 154 | out.TypeMeta = in.TypeMeta 155 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 156 | in.Spec.DeepCopyInto(&out.Spec) 157 | out.Status = in.Status 158 | } 159 | 160 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncedSecret. 161 | func (in *SyncedSecret) DeepCopy() *SyncedSecret { 162 | if in == nil { 163 | return nil 164 | } 165 | out := new(SyncedSecret) 166 | in.DeepCopyInto(out) 167 | return out 168 | } 169 | 170 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 171 | func (in *SyncedSecret) DeepCopyObject() runtime.Object { 172 | if c := in.DeepCopy(); c != nil { 173 | return c 174 | } 175 | return nil 176 | } 177 | 178 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 179 | func (in *SyncedSecretList) DeepCopyInto(out *SyncedSecretList) { 180 | *out = *in 181 | out.TypeMeta = in.TypeMeta 182 | in.ListMeta.DeepCopyInto(&out.ListMeta) 183 | if in.Items != nil { 184 | in, out := &in.Items, &out.Items 185 | *out = make([]SyncedSecret, len(*in)) 186 | for i := range *in { 187 | (*in)[i].DeepCopyInto(&(*out)[i]) 188 | } 189 | } 190 | } 191 | 192 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncedSecretList. 193 | func (in *SyncedSecretList) DeepCopy() *SyncedSecretList { 194 | if in == nil { 195 | return nil 196 | } 197 | out := new(SyncedSecretList) 198 | in.DeepCopyInto(out) 199 | return out 200 | } 201 | 202 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 203 | func (in *SyncedSecretList) DeepCopyObject() runtime.Object { 204 | if c := in.DeepCopy(); c != nil { 205 | return c 206 | } 207 | return nil 208 | } 209 | 210 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 211 | func (in *SyncedSecretSpec) DeepCopyInto(out *SyncedSecretSpec) { 212 | *out = *in 213 | in.SecretMetadata.DeepCopyInto(&out.SecretMetadata) 214 | if in.IAMRole != nil { 215 | in, out := &in.IAMRole, &out.IAMRole 216 | *out = new(string) 217 | **out = **in 218 | } 219 | if in.Data != nil { 220 | in, out := &in.Data, &out.Data 221 | *out = make([]*SecretField, len(*in)) 222 | for i := range *in { 223 | if (*in)[i] != nil { 224 | in, out := &(*in)[i], &(*out)[i] 225 | *out = new(SecretField) 226 | (*in).DeepCopyInto(*out) 227 | } 228 | } 229 | } 230 | if in.DataFrom != nil { 231 | in, out := &in.DataFrom, &out.DataFrom 232 | *out = new(DataFrom) 233 | (*in).DeepCopyInto(*out) 234 | } 235 | if in.AWSAccountID != nil { 236 | in, out := &in.AWSAccountID, &out.AWSAccountID 237 | *out = new(string) 238 | **out = **in 239 | } 240 | } 241 | 242 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncedSecretSpec. 243 | func (in *SyncedSecretSpec) DeepCopy() *SyncedSecretSpec { 244 | if in == nil { 245 | return nil 246 | } 247 | out := new(SyncedSecretSpec) 248 | in.DeepCopyInto(out) 249 | return out 250 | } 251 | 252 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 253 | func (in *SyncedSecretStatus) DeepCopyInto(out *SyncedSecretStatus) { 254 | *out = *in 255 | } 256 | 257 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncedSecretStatus. 258 | func (in *SyncedSecretStatus) DeepCopy() *SyncedSecretStatus { 259 | if in == nil { 260 | return nil 261 | } 262 | out := new(SyncedSecretStatus) 263 | in.DeepCopyInto(out) 264 | return out 265 | } 266 | 267 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 268 | func (in *ValueFrom) DeepCopyInto(out *ValueFrom) { 269 | *out = *in 270 | if in.SecretRef != nil { 271 | in, out := &in.SecretRef, &out.SecretRef 272 | *out = new(SecretRef) 273 | (*in).DeepCopyInto(*out) 274 | } 275 | if in.SecretKeyRef != nil { 276 | in, out := &in.SecretKeyRef, &out.SecretKeyRef 277 | *out = new(SecretKeyRef) 278 | (*in).DeepCopyInto(*out) 279 | } 280 | if in.Template != nil { 281 | in, out := &in.Template, &out.Template 282 | *out = new(string) 283 | **out = **in 284 | } 285 | } 286 | 287 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValueFrom. 288 | func (in *ValueFrom) DeepCopy() *ValueFrom { 289 | if in == nil { 290 | return nil 291 | } 292 | out := new(ValueFrom) 293 | in.DeepCopyInto(out) 294 | return out 295 | } 296 | -------------------------------------------------------------------------------- /pkg/secretsmanager/poller_test.go: -------------------------------------------------------------------------------- 1 | package secretsmanager 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/service/secretsmanager" 11 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 12 | testr "github.com/go-logr/logr/testr" 13 | ) 14 | 15 | func TestGetCurrentVersion(t *testing.T) { 16 | currV := "AWSCURRENT" 17 | prevV := "AWSPREVIOUS" 18 | 19 | testCases := []struct { 20 | have map[string][]*string 21 | want string 22 | err error 23 | }{ 24 | { 25 | have: map[string][]*string{ 26 | "currentuuid": {&currV}, 27 | }, 28 | want: "currentuuid", 29 | err: nil, 30 | }, 31 | { 32 | have: map[string][]*string{ 33 | "prevuuid": {&prevV}, 34 | }, 35 | want: "", 36 | err: errors.New("version with stage AWSCURRENT not found"), 37 | }, 38 | { 39 | have: map[string][]*string{ 40 | "oldversionuuid": {&prevV}, 41 | "newversionuuid": {&currV}, 42 | }, 43 | want: "newversionuuid", 44 | err: nil, 45 | }, 46 | } 47 | 48 | for _, test := range testCases { 49 | versionID, err := getCurrentVersion(test.have) 50 | if test.err != nil { 51 | if err.Error() != test.err.Error() { 52 | t.Errorf("error: wanted %s got %s", test.err, err) 53 | } 54 | } 55 | if test.want != versionID { 56 | t.Errorf("versionId: wanted %s got %s", test.want, versionID) 57 | } 58 | } 59 | } 60 | 61 | type mockSecretsManagerClient struct { 62 | secretsmanageriface.SecretsManagerAPI 63 | Resp secretsmanager.ListSecretsOutput 64 | } 65 | 66 | func (m *mockSecretsManagerClient) ListSecretsPages(input *secretsmanager.ListSecretsInput, fn func(*secretsmanager.ListSecretsOutput, bool) bool) error { 67 | fn(&m.Resp, true) 68 | return nil 69 | } 70 | 71 | func _s(A string) *string { 72 | return &A 73 | } 74 | 75 | func _t(A time.Time) *time.Time { 76 | return &A 77 | } 78 | 79 | func TestFetchSecret(t *testing.T) { 80 | var now = time.Now() 81 | 82 | for _, test := range []struct { 83 | name string 84 | have mockSecretsManagerClient 85 | want Secrets 86 | }{ 87 | { 88 | name: "test 1", 89 | have: mockSecretsManagerClient{ 90 | Resp: secretsmanager.ListSecretsOutput{ 91 | SecretList: []*secretsmanager.SecretListEntry{ 92 | { 93 | Name: _s("random/aws/secret002"), 94 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 95 | SecretVersionsToStages: map[string][]*string{ 96 | "002": { 97 | _s("AWSCURRENT"), 98 | }, 99 | }, 100 | }, { 101 | Name: _s("random/aws/secret003"), 102 | LastChangedDate: _t(now.AddDate(0, 0, -3)), 103 | SecretVersionsToStages: map[string][]*string{ 104 | "005": { 105 | _s("AWSCURRENT"), 106 | }, 107 | "003": { 108 | _s("AWSPREVIOUS"), 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | want: Secrets{ 116 | "random/aws/secret002": PolledSecretMeta{ 117 | CurrentVersionID: "002", 118 | UpdatedAt: now.AddDate(0, 0, -2), 119 | Tags: map[string]string{}, 120 | }, 121 | "random/aws/secret003": PolledSecretMeta{ 122 | CurrentVersionID: "005", 123 | UpdatedAt: now.AddDate(0, 0, -3), 124 | Tags: map[string]string{}, 125 | }, 126 | }, 127 | }, { 128 | name: "test 2", 129 | have: mockSecretsManagerClient{ 130 | Resp: secretsmanager.ListSecretsOutput{ 131 | SecretList: []*secretsmanager.SecretListEntry{ 132 | { 133 | Name: _s("random/aws/secret"), 134 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 135 | SecretVersionsToStages: map[string][]*string{ 136 | "randomuuid": { 137 | _s("AWSCURRENT"), 138 | }, 139 | }, 140 | }, { 141 | Name: _s("random/aws/secretnocurrent"), 142 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 143 | SecretVersionsToStages: map[string][]*string{ 144 | "randomuuid_hidden": { 145 | _s("AWSPREVIOUS"), 146 | }, 147 | }, 148 | }, 149 | }, 150 | }, 151 | }, 152 | want: Secrets{ 153 | "random/aws/secret": PolledSecretMeta{ 154 | CurrentVersionID: "randomuuid", 155 | UpdatedAt: now.AddDate(0, 0, -2), 156 | Tags: map[string]string{}, 157 | }, 158 | }, 159 | }, { 160 | name: "test 3", 161 | have: mockSecretsManagerClient{ 162 | Resp: secretsmanager.ListSecretsOutput{ 163 | SecretList: []*secretsmanager.SecretListEntry{ 164 | { 165 | Name: _s("random/aws/secret"), 166 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 167 | SecretVersionsToStages: map[string][]*string{ 168 | "randomuuid": { 169 | _s("AWSSTAGE"), 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | want: Secrets{}, 177 | }, 178 | } { 179 | p := Poller{ 180 | getSMClient: func(string) (secretsmanageriface.SecretsManagerAPI, error) { 181 | return &test.have, nil 182 | }, 183 | Log: testr.New(t), 184 | } 185 | got, err := p.fetchSecrets() 186 | if err != nil { 187 | t.Errorf("test %s returned error %s", test.name, err) 188 | } 189 | if !reflect.DeepEqual(got, test.want) { 190 | t.Errorf("test %s, wanted %s got %s", test.name, test.want, got) 191 | } 192 | } 193 | } 194 | 195 | type mockFailingSecretsManagerClient struct { 196 | secretsmanageriface.SecretsManagerAPI 197 | Resp secretsmanager.ListSecretsOutput 198 | } 199 | 200 | func (m *mockFailingSecretsManagerClient) ListSecretsPages(input *secretsmanager.ListSecretsInput, fn func(*secretsmanager.ListSecretsOutput, bool) bool) error { 201 | return fmt.Errorf("an error occured") 202 | } 203 | 204 | func TestFetchSecretError(t *testing.T) { 205 | for _, test := range []struct { 206 | name string 207 | have mockFailingSecretsManagerClient 208 | want Secrets 209 | }{ 210 | { 211 | name: "fetchSecrets should return an error when listing secret in AWS fails", 212 | have: mockFailingSecretsManagerClient{ 213 | Resp: secretsmanager.ListSecretsOutput{ 214 | SecretList: nil, 215 | }, 216 | }, 217 | }, 218 | } { 219 | p := Poller{ 220 | getSMClient: func(string) (secretsmanageriface.SecretsManagerAPI, error) { 221 | return &test.have, nil 222 | }, 223 | } 224 | got, err := p.fetchSecrets() 225 | if err == nil { 226 | t.Errorf("test %s should have returned an error, did not", test.name) 227 | } 228 | if got != nil { 229 | t.Errorf("test %s, wanted %s got %s", test.name, test.want, got) 230 | } 231 | } 232 | } 233 | 234 | type mockWorkingThenFailingSecretsManagerClient struct { 235 | count int 236 | secretsmanageriface.SecretsManagerAPI 237 | Resp secretsmanager.ListSecretsOutput 238 | } 239 | 240 | func (m *mockWorkingThenFailingSecretsManagerClient) ListSecretsPages(input *secretsmanager.ListSecretsInput, fn func(*secretsmanager.ListSecretsOutput, bool) bool) error { 241 | if m.count < 2 { 242 | m.count = m.count + 1 243 | fn(&m.Resp, true) 244 | return nil 245 | } else { 246 | return fmt.Errorf("an error occured") 247 | } 248 | } 249 | 250 | func TestPoll(t *testing.T) { 251 | now := time.Now() 252 | 253 | for _, test := range []struct { 254 | name string 255 | have mockWorkingThenFailingSecretsManagerClient 256 | want Secrets 257 | }{ 258 | { 259 | name: "test 2", 260 | have: mockWorkingThenFailingSecretsManagerClient{ 261 | Resp: secretsmanager.ListSecretsOutput{ 262 | SecretList: []*secretsmanager.SecretListEntry{ 263 | { 264 | Name: _s("random/aws/secret"), 265 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 266 | SecretVersionsToStages: map[string][]*string{ 267 | "randomuuid": { 268 | _s("AWSCURRENT"), 269 | }, 270 | }, 271 | }, { 272 | Name: _s("random/aws/secretnocurrent"), 273 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 274 | SecretVersionsToStages: map[string][]*string{ 275 | "randomuuid_hidden": { 276 | _s("AWSPREVIOUS"), 277 | }, 278 | }, 279 | }, 280 | }, 281 | }, 282 | }, 283 | want: Secrets{ 284 | "random/aws/secret": PolledSecretMeta{ 285 | CurrentVersionID: "randomuuid", 286 | UpdatedAt: now.AddDate(0, 0, -2), 287 | Tags: map[string]string{}, 288 | }, 289 | }, 290 | }, 291 | { 292 | name: "test 3", 293 | have: mockWorkingThenFailingSecretsManagerClient{ 294 | Resp: secretsmanager.ListSecretsOutput{ 295 | SecretList: []*secretsmanager.SecretListEntry{ 296 | { 297 | Name: _s("random/aws/secret"), 298 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 299 | SecretVersionsToStages: map[string][]*string{ 300 | "randomuuid": { 301 | _s("AWSCURRENT"), 302 | }, 303 | }, 304 | }, { 305 | Name: _s("random/aws/deletedsecret"), 306 | LastChangedDate: _t(now.AddDate(0, 0, -2)), 307 | DeletedDate: _t(now.AddDate(0, 0, -1)), 308 | SecretVersionsToStages: map[string][]*string{ 309 | "randomuuid": { 310 | _s("AWSCURRENT"), 311 | }, 312 | }, 313 | }, 314 | }, 315 | }, 316 | }, 317 | want: Secrets{ 318 | "random/aws/secret": PolledSecretMeta{ 319 | CurrentVersionID: "randomuuid", 320 | UpdatedAt: now.AddDate(0, 0, -2), 321 | Tags: map[string]string{}, 322 | }, 323 | }, 324 | }, 325 | } { 326 | errs := make(chan error) 327 | p := Poller{ 328 | getSMClient: func(string) (secretsmanageriface.SecretsManagerAPI, error) { 329 | return &test.have, nil 330 | }, 331 | errs: errs, 332 | quit: make(chan bool), 333 | Log: testr.NewWithOptions(t, testr.Options{Verbosity: 0}), 334 | } 335 | 336 | go func() { 337 | p.wg.Add(1) 338 | ticker := time.NewTicker(time.Duration(100) * time.Millisecond) 339 | p.poll(ticker) 340 | ticker.Stop() 341 | p.wg.Done() 342 | }() 343 | 344 | nErrs := 0 345 | go func() { 346 | for range errs { 347 | nErrs = nErrs + 1 348 | } 349 | }() 350 | 351 | time.Sleep(500 * time.Millisecond) 352 | 353 | p.quit <- true 354 | p.wg.Wait() 355 | 356 | if nErrs == 0 { 357 | t.Errorf("there was no error listing secret - there should have been") 358 | } 359 | 360 | if len(p.PolledSecrets) != len(test.want) { 361 | t.Errorf("failing to list secrets seems to have removed the list of PolledSecrets") 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /controllers/syncedsecret_controller.go: -------------------------------------------------------------------------------- 1 | /* 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 | 16 | package controllers 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | "sync" 22 | "time" 23 | 24 | "github.com/prometheus/client_golang/prometheus" 25 | "sigs.k8s.io/controller-runtime/pkg/metrics" 26 | 27 | "github.com/aws/aws-sdk-go/aws" 28 | "github.com/aws/aws-sdk-go/aws/session" 29 | "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" 30 | 31 | "github.com/go-logr/logr" 32 | "github.com/pkg/errors" 33 | corev1 "k8s.io/api/core/v1" 34 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 35 | "k8s.io/apimachinery/pkg/types" 36 | ctrl "sigs.k8s.io/controller-runtime" 37 | "sigs.k8s.io/controller-runtime/pkg/client" 38 | 39 | awssecretsmanager "github.com/aws/aws-sdk-go/service/secretsmanager" 40 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 41 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8snamespace" 42 | "github.com/contentful-labs/kube-secret-syncer/pkg/k8ssecret" 43 | "github.com/contentful-labs/kube-secret-syncer/pkg/secretsmanager" 44 | ) 45 | 46 | type RoleValidator interface { 47 | IsWhitelisted(role, namespace string) (bool, error) 48 | } 49 | 50 | type NamespaceValidator interface { 51 | HasNamespaceType(secret awssecretsmanager.DescribeSecretOutput, namespace string) (bool, error) 52 | } 53 | 54 | // SyncedSecretReconciler reconciles a SyncedSecret object 55 | type SyncedSecretReconciler struct { 56 | client.Client 57 | Sess *session.Session 58 | GetSMClient func(string) (secretsmanageriface.SecretsManagerAPI, error) 59 | poller *secretsmanager.Poller 60 | getNamespace k8snamespace.NamespaceGetter 61 | RoleValidator RoleValidator 62 | NamespaceValidator NamespaceValidator 63 | PollInterval time.Duration 64 | Log logr.Logger 65 | wg sync.WaitGroup 66 | 67 | DefaultSearchRole string 68 | 69 | gauges map[string]prometheus.Gauge 70 | sync_state map[string]bool 71 | } 72 | 73 | const ( 74 | LogFieldSyncedSecret = "SyncedSecret" 75 | LogFieldK8SSecret = "KubernetesSecret" 76 | ) 77 | 78 | // +kubebuilder:rbac:groups=secrets.contentful.com,resources=syncedsecrets,verbs=get;list;watch;create;update;patch;delete 79 | // +kubebuilder:rbac:groups=secrets.contentful.com,resources=syncedsecrets/status,verbs=get;update;patch 80 | // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch 81 | // +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch 82 | 83 | func (r *SyncedSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 84 | var err error 85 | var cs secretsv1.SyncedSecret 86 | 87 | defer r.updatePrometheus(r.sync_state) 88 | 89 | log := r.Log.WithValues(LogFieldSyncedSecret, req.NamespacedName.String()) 90 | if err = r.Get(ctx, req.NamespacedName, &cs); err != nil { 91 | log.Info("unable to fetch SyncedSecret, was maybe deleted") 92 | return ctrl.Result{}, nil 93 | } 94 | 95 | // even though the SyncedSecret can contain name and namespace for the k8s secret to be created, we are disregarding it. 96 | // the generated secret will have the same name/namesapce as the CRD 97 | K8SSecretName := types.NamespacedName{ 98 | Name: cs.ObjectMeta.Name, 99 | Namespace: cs.ObjectMeta.Namespace, 100 | } 101 | log = log.WithValues(LogFieldK8SSecret, K8SSecretName.String()) 102 | 103 | if cs.Spec.AWSAccountID != nil { 104 | IAMRole := fmt.Sprintf("arn:aws:iam::%s:role/secret-syncer", *cs.Spec.AWSAccountID) 105 | var secretRef *string // secretID of the secret in secret Manager 106 | 107 | // We need to check each secret in Data and DataFrom to see if they are allowed in the namespace 108 | if cs.Spec.DataFrom != nil { 109 | if cs.Spec.DataFrom.SecretRef != nil { 110 | secretRef = cs.Spec.DataFrom.SecretRef.Name 111 | if secretRef == nil { 112 | return ctrl.Result{}, errors.WithMessagef(err, "secretRef name is invalid %s", *secretRef) 113 | } 114 | 115 | allowed, err := r.secretAllowedInNamespace(*secretRef, IAMRole, cs.Namespace, cs.Name) 116 | 117 | if !allowed || err != nil { 118 | return ctrl.Result{}, errors.WithMessagef(err, "failed to validate if secret %s with role %s is allowed in namespace %s", *secretRef, IAMRole, cs.Namespace) 119 | } 120 | } 121 | 122 | } 123 | 124 | if cs.Spec.Data != nil { 125 | for _, field := range cs.Spec.Data { 126 | if field.ValueFrom.SecretRef != nil { 127 | secretRef = field.ValueFrom.SecretKeyRef.Name 128 | if secretRef == nil { 129 | return ctrl.Result{}, errors.WithMessagef(err, "secretRef name is invalid %s", *secretRef) 130 | } 131 | 132 | allowed, err := r.secretAllowedInNamespace(*secretRef, IAMRole, cs.Namespace, cs.Name) 133 | 134 | if !allowed || err != nil { 135 | return ctrl.Result{}, errors.WithMessagef(err, "failed to validate if secret %s with role %s is allowed in namespace %s", *secretRef, IAMRole, cs.Namespace) 136 | } 137 | } 138 | } 139 | } 140 | 141 | } else { 142 | allowed, err := r.RoleValidator.IsWhitelisted(*cs.Spec.IAMRole, cs.Namespace) 143 | if !allowed { 144 | r.sync_state[cs.Name] = false 145 | log.Error(err, "role not allowed by namespace", "role", *cs.Spec.IAMRole, "namespace", cs.Namespace) 146 | return ctrl.Result{}, errors.WithMessagef(err, "role %s not allowed in namespace %s", *cs.Spec.IAMRole, cs.Namespace) 147 | } 148 | if err != nil { 149 | r.sync_state[cs.Name] = false 150 | log.Error(err, "failed verifying if IAMRole is whitelisted", "role", *cs.Spec.IAMRole, "namespace", cs.Namespace) 151 | return ctrl.Result{}, errors.WithMessagef(err, "failed verifying role %s: %s", *cs.Spec.IAMRole, err) 152 | } 153 | } 154 | 155 | var k8sSecret corev1.Secret = corev1.Secret{} 156 | err = r.Get(ctx, K8SSecretName, &k8sSecret) 157 | if err != nil { 158 | if !k8serrors.IsNotFound(err) { 159 | r.sync_state[cs.Name] = false 160 | return ctrl.Result{}, errors.WithMessagef(err, "error retrieving k8s secret %s", K8SSecretName) 161 | } 162 | 163 | // Create the k8S secret if it was not found 164 | createdSecret, err := r.createK8SSecret(ctx, &cs) 165 | if err != nil { 166 | r.sync_state[cs.Name] = false 167 | return ctrl.Result{}, errors.WithMessagef(err, "failed creating K8S Secret %s", K8SSecretName) 168 | } 169 | log.Info("created k8s secret", "K8SSecret", createdSecret) 170 | } else { 171 | // Update the K8S Secret if it already exists 172 | updatedSecret, err := r.updateK8SSecret(ctx, &cs) 173 | if err != nil { 174 | r.sync_state[cs.Name] = false 175 | return ctrl.Result{}, errors.WithMessagef(err, "failed updating k8s secret %s", K8SSecretName) 176 | } 177 | if !k8ssecret.K8SSecretsEqual(k8sSecret, *updatedSecret) { 178 | log.Info("updated secret", "K8SSecret", updatedSecret.ObjectMeta, "secretSize", k8ssecret.SecretLength(updatedSecret)) 179 | } 180 | } 181 | 182 | if err = r.updateCSStatus(ctx, &cs); err != nil { 183 | r.sync_state[cs.Name] = false 184 | log.Error(err, "failed to update SyncedSecret status") 185 | return ctrl.Result{}, errors.WithMessagef(err, "failed to update SyncedSecret status for %s", K8SSecretName) 186 | } 187 | 188 | r.sync_state[cs.Name] = true 189 | 190 | return ctrl.Result{}, nil 191 | } 192 | 193 | func (r *SyncedSecretReconciler) secretAllowedInNamespace(secretID string, IAMRole string, namespace string, name string) (bool, error) { 194 | log := r.Log.WithValues(LogFieldSyncedSecret, namespace) 195 | secret, err := r.poller.DescribeSecret(aws.String(secretID), IAMRole) 196 | if err != nil { 197 | log.Error(err, "failed to describe secret", "role", IAMRole, "namespace", namespace) 198 | return false, errors.WithMessagef(err, "failed to fetch secret %s with role %s in namespace %s", secretID, IAMRole, namespace) 199 | } 200 | 201 | allowed, err := r.NamespaceValidator.HasNamespaceType(secret, namespace) 202 | if !allowed { 203 | r.sync_state[name] = false 204 | log.Error(err, "namespace not allowed in secret", "namespace", namespace, "secret", secretID) 205 | return false, errors.WithMessagef(err, "namespace %s not allowed in secret %s", namespace, secretID) 206 | } 207 | if err != nil { 208 | r.sync_state[name] = false 209 | log.Error(err, "failed verifying if namespace is allowed in secret", "namespace", namespace, "secret", secretID) 210 | return false, errors.WithMessagef(err, "failed verifying secret %s: %s", secretID, err) 211 | } 212 | return true, nil 213 | } 214 | 215 | func (r *SyncedSecretReconciler) templateSecretGetter(secretID string, IAMRole string) (string, error) { 216 | secretString, _, err := r.poller.GetSecret(aws.String(secretID), IAMRole) 217 | if err != nil { 218 | return "", errors.WithMessage(err, fmt.Sprintf("error retrieving secret %s", secretID)) 219 | } 220 | 221 | return secretString, err 222 | } 223 | 224 | // createSecret creates a k8s Secret from a SyncedSecret 225 | func (r *SyncedSecretReconciler) createK8SSecret(ctx context.Context, cs *secretsv1.SyncedSecret) (*corev1.Secret, error) { 226 | 227 | secret, err := k8ssecret.GenerateK8SSecret(*cs, r.poller.PolledSecrets, r.templateSecretGetter, secretsmanager.FilterByTagKey, r.Log) 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | if err = r.Create(ctx, secret); err != nil { 233 | return nil, err 234 | } 235 | 236 | r.Log.Info("Created K8S Secret", "K8SSecret", secret.ObjectMeta, "secretSize", k8ssecret.SecretLength(secret)) 237 | 238 | return secret, nil 239 | } 240 | 241 | func (r *SyncedSecretReconciler) updateK8SSecret(ctx context.Context, cs *secretsv1.SyncedSecret) (*corev1.Secret, error) { 242 | var secret *corev1.Secret 243 | var err error 244 | 245 | secret, err = k8ssecret.GenerateK8SSecret(*cs, r.poller.PolledSecrets, r.templateSecretGetter, secretsmanager.FilterByTagKey, r.Log) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | if err = r.Update(ctx, secret); err != nil { 251 | return nil, err 252 | } 253 | 254 | return secret, nil 255 | } 256 | 257 | // updateCSStatus updates the SyncedSecret.Status versionId (from aws SSM) seen 258 | func (r *SyncedSecretReconciler) updateCSStatus(ctx context.Context, cs *secretsv1.SyncedSecret) error { 259 | //cs.Status.CurrentVersionID = r.poller.PolledSecrets[cs.Spec.SecretID].CurrentVersionID 260 | return r.Status().Update(ctx, cs) 261 | } 262 | 263 | func (r *SyncedSecretReconciler) Quit() { 264 | r.poller.Stop() 265 | r.wg.Wait() 266 | } 267 | 268 | func (r *SyncedSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { 269 | var err error 270 | 271 | errs := make(chan error) 272 | go func() { 273 | r.wg.Add(1) 274 | defer r.wg.Done() 275 | 276 | for err := range errs { 277 | r.Log.Error(err, "polling error") 278 | } 279 | }() 280 | 281 | r.sync_state = map[string]bool{} 282 | r.gauges = map[string]prometheus.Gauge{ 283 | "secret_sync_success": prometheus.NewGauge( 284 | prometheus.GaugeOpts{ 285 | Name: "secret_sync_success", 286 | Help: "Number of SyncedSecrets successfully syncing", 287 | }, 288 | ), 289 | "secret_sync_failures": prometheus.NewGauge( 290 | prometheus.GaugeOpts{ 291 | Name: "secret_sync_failures", 292 | Help: "Number of SyncedSecrers failing to sync", 293 | }, 294 | ), 295 | } 296 | 297 | for _, metric := range r.gauges { 298 | metrics.Registry.MustRegister(metric) 299 | } 300 | 301 | if r.poller, err = secretsmanager.New(r.PollInterval, errs, r.GetSMClient, r.DefaultSearchRole, r.Log); err != nil { 302 | return err 303 | } 304 | 305 | return ctrl.NewControllerManagedBy(mgr). 306 | For(&secretsv1.SyncedSecret{}). 307 | Complete(r) 308 | } 309 | 310 | func (r *SyncedSecretReconciler) updatePrometheus(syncState map[string]bool) { 311 | success := 0 312 | failures := 0 313 | 314 | for _, state := range syncState { 315 | if state == true { 316 | success++ 317 | } else { 318 | failures++ 319 | } 320 | } 321 | 322 | if _, ok := r.gauges["secret_sync_success"]; ok { 323 | r.gauges["secret_sync_success"].Set(float64(success)) 324 | } 325 | if _, ok := r.gauges["secret_sync_failures"]; ok { 326 | r.gauges["secret_sync_failures"].Set(float64(success)) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /controllers/syncedsecret_controller_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/service/secretsmanager" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | 13 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 14 | corev1 "k8s.io/api/core/v1" 15 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/types" 18 | ) 19 | 20 | var _ = Describe("SyncedSecret Controller", func() { 21 | const timeout = time.Minute * 3 22 | const interval = time.Second * 2 23 | 24 | BeforeEach(func() { 25 | }) 26 | 27 | AfterEach(func() { 28 | }) 29 | 30 | // Add Tests for OpenAPI validation (or additonal CRD features) specified in 31 | // your API definition. 32 | // Avoid adding tests for vanilla CRUD operations because they would 33 | // test Kubernetes API server, which isn't the goal here. 34 | Context("For a single SyncedSecret", func() { 35 | secretKey := types.NamespacedName{ 36 | Name: "secret-name", 37 | Namespace: TEST_NAMESPACE, 38 | } 39 | 40 | resourceVersion := "" 41 | It("Should Create K8S Secrets for SyncedSecret CRD", func() { 42 | toCreate := &secretsv1.SyncedSecret{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: secretKey.Name, 45 | Namespace: secretKey.Namespace, 46 | ResourceVersion: resourceVersion, 47 | }, 48 | Spec: secretsv1.SyncedSecretSpec{ 49 | SecretMetadata: secretsv1.SecretMetadata{ 50 | Name: secretKey.Name, 51 | Namespace: secretKey.Namespace, 52 | CreationTimestamp: metav1.Time{ 53 | Time: time_now, 54 | }, 55 | }, 56 | IAMRole: _s("test"), 57 | Data: []*secretsv1.SecretField{ 58 | { 59 | Name: _s("DB_NAME"), 60 | ValueFrom: &secretsv1.ValueFrom{ 61 | SecretKeyRef: &secretsv1.SecretKeyRef{ 62 | Name: _s("random/aws/secret003"), 63 | Key: _s("database_name"), 64 | }, 65 | }, 66 | }, 67 | { 68 | Name: _s("DB_PASS"), 69 | ValueFrom: &secretsv1.ValueFrom{ 70 | SecretKeyRef: &secretsv1.SecretKeyRef{ 71 | Name: _s("random/aws/secret003"), 72 | Key: _s("database_pass"), 73 | }, 74 | }, 75 | }, 76 | }, 77 | }, 78 | } 79 | 80 | secretExpect := &corev1.Secret{ 81 | ObjectMeta: metav1.ObjectMeta{ 82 | Name: secretKey.Name, 83 | Namespace: secretKey.Namespace, 84 | }, 85 | Type: "Opaque", 86 | Data: map[string][]byte{ 87 | "DB_NAME": []byte("secretDB"), 88 | "DB_PASS": []byte("cupofcoffee"), 89 | }, 90 | } 91 | 92 | Expect(k8sClient.Create(context.Background(), toCreate)).Should(Succeed()) 93 | 94 | fetchedSecret := &corev1.Secret{} 95 | Eventually(func() bool { 96 | err := k8sClient.Get(context.Background(), secretKey, fetchedSecret) 97 | return k8serrors.IsNotFound(err) 98 | }, timeout, interval).Should(BeFalse()) 99 | 100 | // we need to ensure that that secretExpect.Data is a subset of fetchedSecret.Data 101 | // the kubernetes client.go doesn't base64 values this is something that kubectl maybe does 102 | Expect(reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data)).To(BeTrue()) 103 | 104 | fetchedCfSecret := &secretsv1.SyncedSecret{} 105 | err := k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 106 | Expect(err).ToNot(HaveOccurred()) 107 | resourceVersion = fetchedCfSecret.ResourceVersion 108 | }) 109 | 110 | It("Should update k8s secret object if there is change in AwsSecret CRD", func() { 111 | toUpdate := &secretsv1.SyncedSecret{ 112 | ObjectMeta: metav1.ObjectMeta{ 113 | Name: secretKey.Name, 114 | Namespace: secretKey.Namespace, 115 | ResourceVersion: resourceVersion, 116 | }, 117 | Spec: secretsv1.SyncedSecretSpec{ 118 | SecretMetadata: secretsv1.SecretMetadata{ 119 | Name: secretKey.Name, 120 | Namespace: secretKey.Namespace, 121 | CreationTimestamp: metav1.Time{ 122 | Time: time_now, 123 | }, 124 | }, 125 | IAMRole: _s("test"), 126 | Data: []*secretsv1.SecretField{ 127 | { 128 | Name: _s("DB_NAME"), 129 | ValueFrom: &secretsv1.ValueFrom{ 130 | SecretKeyRef: &secretsv1.SecretKeyRef{ 131 | Name: _s("random/aws/secret003"), 132 | Key: _s("database_name1"), 133 | }, 134 | }, 135 | }, 136 | { 137 | Name: _s("DB_PASS"), 138 | ValueFrom: &secretsv1.ValueFrom{ 139 | SecretKeyRef: &secretsv1.SecretKeyRef{ 140 | Name: _s("random/aws/secret003"), 141 | Key: _s("database_pass"), 142 | }, 143 | }, 144 | }, 145 | }, 146 | }, 147 | } 148 | 149 | secretExpect := &corev1.Secret{ 150 | ObjectMeta: metav1.ObjectMeta{ 151 | Name: secretKey.Name, 152 | Namespace: secretKey.Namespace, 153 | }, 154 | Type: "Opaque", 155 | Data: map[string][]byte{ 156 | "DB_NAME": []byte("secretDB02"), 157 | "DB_PASS": []byte("cupofcoffee"), 158 | }, 159 | } 160 | 161 | Expect(k8sClient.Update(context.Background(), toUpdate)).Should(Succeed()) 162 | 163 | fetchedSecret := &corev1.Secret{} 164 | Eventually(func() bool { 165 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 166 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 167 | }, timeout, interval).Should(BeTrue()) 168 | 169 | fetchedCfSecret := &secretsv1.SyncedSecret{} 170 | err := k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 171 | Expect(err).ToNot(HaveOccurred()) 172 | resourceVersion = fetchedCfSecret.ResourceVersion 173 | }) 174 | 175 | It("Should update the k8s secret object if the mapped AWS Secret changes", func() { 176 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 177 | SecretString: _s(`{"database_pass":"cupoftea", "database_name1":"secretDB02"}`), 178 | VersionId: _s(`006`), 179 | } 180 | 181 | MockSecretsOutput.SecretsPageOutput = &secretsmanager.ListSecretsOutput{ 182 | SecretList: []*secretsmanager.SecretListEntry{ 183 | { 184 | Name: _s("random/aws/secret003"), 185 | LastChangedDate: _t(time_now.AddDate(0, 0, -2)), 186 | SecretVersionsToStages: map[string][]*string{ 187 | "002": []*string{ 188 | _s("AWSCURRENT"), 189 | }, 190 | }, 191 | }, { 192 | Name: _s("random/aws/secret003"), 193 | LastChangedDate: _t(time_now.AddDate(0, 0, -1)), 194 | SecretVersionsToStages: map[string][]*string{ 195 | "005": { 196 | _s("AWSPREVIOUS"), 197 | }, 198 | "006": { 199 | _s("AWSCURRENT"), 200 | }, 201 | }, 202 | }, 203 | }, 204 | } 205 | 206 | secretExpect := &corev1.Secret{ 207 | ObjectMeta: metav1.ObjectMeta{ 208 | Name: secretKey.Name, 209 | Namespace: secretKey.Namespace, 210 | }, 211 | Type: "Opaque", 212 | Data: map[string][]byte{ 213 | "DB_NAME": []byte("secretDB02"), 214 | "DB_PASS": []byte("cupoftea"), 215 | }, 216 | } 217 | 218 | fetchedSecret := &corev1.Secret{} 219 | Eventually(func() bool { 220 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 221 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 222 | }, timeout, interval).Should(BeTrue()) 223 | }) 224 | }) 225 | 226 | Context("For a single SyncedSecret (using Data) with AWSAccountID", func() { 227 | secretKey := types.NamespacedName{ 228 | Name: "another-secret-name", 229 | Namespace: TEST_NAMESPACE2, 230 | } 231 | 232 | resourceVersion := "" 233 | 234 | It("Should Create K8S Secrets for SyncedSecret CRD with AWSAccountID", func() { 235 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 236 | SecretString: _s(`{"database_name":"secretDB","database_pass":"cupofcoffee", "database_name1":"secretDB02"}`), 237 | VersionId: _s(`005`), 238 | } 239 | 240 | toCreate := &secretsv1.SyncedSecret{ 241 | ObjectMeta: metav1.ObjectMeta{ 242 | Name: secretKey.Name, 243 | Namespace: secretKey.Namespace, 244 | ResourceVersion: resourceVersion, 245 | }, 246 | Spec: secretsv1.SyncedSecretSpec{ 247 | SecretMetadata: secretsv1.SecretMetadata{ 248 | Name: secretKey.Name, 249 | Namespace: secretKey.Namespace, 250 | CreationTimestamp: metav1.Time{ 251 | Time: time_now, 252 | }, 253 | }, 254 | AWSAccountID: _s("12345678910"), 255 | IAMRole: _s("test"), 256 | Data: []*secretsv1.SecretField{ 257 | { 258 | Name: _s("DB_NAME"), 259 | ValueFrom: &secretsv1.ValueFrom{ 260 | SecretKeyRef: &secretsv1.SecretKeyRef{ 261 | Name: _s("random/aws/secret004"), 262 | Key: _s("database_name"), 263 | }, 264 | }, 265 | }, 266 | { 267 | Name: _s("DB_PASS"), 268 | ValueFrom: &secretsv1.ValueFrom{ 269 | SecretKeyRef: &secretsv1.SecretKeyRef{ 270 | Name: _s("random/aws/secret004"), 271 | Key: _s("database_pass"), 272 | }, 273 | }, 274 | }, 275 | }, 276 | }, 277 | } 278 | secretExpect := &corev1.Secret{ 279 | ObjectMeta: metav1.ObjectMeta{ 280 | Name: secretKey.Name, 281 | Namespace: secretKey.Namespace, 282 | }, 283 | Type: "Opaque", 284 | Data: map[string][]byte{ 285 | "DB_NAME": []byte("secretDB"), 286 | "DB_PASS": []byte("cupofcoffee"), 287 | }, 288 | } 289 | err := k8sClient.Create(context.Background(), toCreate) 290 | Expect(err).ToNot(HaveOccurred()) 291 | 292 | fetchedSecret := &corev1.Secret{} 293 | Eventually(func() bool { 294 | err := k8sClient.Get(context.Background(), secretKey, fetchedSecret) 295 | return k8serrors.IsNotFound(err) 296 | }, timeout, interval).Should(BeFalse()) 297 | 298 | // we need to ensure that that secretExpect.Data is a subset of fetchedSecret.Data 299 | // the kubernetes client.go doesn't base64 values this is something that kubectl maybe does 300 | Expect(reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data)).To(BeTrue()) 301 | 302 | fetchedCfSecret := &secretsv1.SyncedSecret{} 303 | err = k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 304 | Expect(err).ToNot(HaveOccurred()) 305 | resourceVersion = fetchedCfSecret.ResourceVersion 306 | 307 | }) 308 | 309 | It("Should update k8s secret object if there is change in AwsSecret CRD with AWSAccountID", func() { 310 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 311 | SecretString: _s(`{"database_name":"secretDB","database_pass":"cupofcoffee", "database_name1":"secretDB02"}`), 312 | VersionId: _s(`005`), 313 | } 314 | toUpdate := &secretsv1.SyncedSecret{ 315 | ObjectMeta: metav1.ObjectMeta{ 316 | Name: secretKey.Name, 317 | Namespace: secretKey.Namespace, 318 | ResourceVersion: resourceVersion, 319 | }, 320 | Spec: secretsv1.SyncedSecretSpec{ 321 | SecretMetadata: secretsv1.SecretMetadata{ 322 | Name: secretKey.Name, 323 | Namespace: secretKey.Namespace, 324 | CreationTimestamp: metav1.Time{ 325 | Time: time_now, 326 | }, 327 | }, 328 | IAMRole: _s("test"), 329 | AWSAccountID: _s("12345678910"), 330 | Data: []*secretsv1.SecretField{ 331 | { 332 | Name: _s("DB_NAME"), 333 | ValueFrom: &secretsv1.ValueFrom{ 334 | SecretKeyRef: &secretsv1.SecretKeyRef{ 335 | Name: _s("random/aws/secret003"), 336 | Key: _s("database_name1"), 337 | }, 338 | }, 339 | }, 340 | { 341 | Name: _s("DB_PASS"), 342 | ValueFrom: &secretsv1.ValueFrom{ 343 | SecretKeyRef: &secretsv1.SecretKeyRef{ 344 | Name: _s("random/aws/secret003"), 345 | Key: _s("database_pass"), 346 | }, 347 | }, 348 | }, 349 | }, 350 | }, 351 | } 352 | 353 | secretExpect := &corev1.Secret{ 354 | ObjectMeta: metav1.ObjectMeta{ 355 | Name: secretKey.Name, 356 | Namespace: secretKey.Namespace, 357 | }, 358 | Type: "Opaque", 359 | Data: map[string][]byte{ 360 | "DB_NAME": []byte("secretDB02"), 361 | "DB_PASS": []byte("cupofcoffee"), 362 | }, 363 | } 364 | 365 | Expect(k8sClient.Update(context.Background(), toUpdate)).Should(Succeed()) 366 | 367 | fetchedSecret := &corev1.Secret{} 368 | Eventually(func() bool { 369 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 370 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 371 | }, timeout, interval).Should(BeTrue()) 372 | 373 | fetchedCfSecret := &secretsv1.SyncedSecret{} 374 | err := k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 375 | Expect(err).ToNot(HaveOccurred()) 376 | resourceVersion = fetchedCfSecret.ResourceVersion 377 | }) 378 | 379 | It("Should update the k8s secret object if the mapped AWS Secret changes with AWSAccountID", func() { 380 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 381 | SecretString: _s(`{"database_pass":"cupoftea", "database_name1":"secretDB02"}`), 382 | VersionId: _s(`006`), 383 | } 384 | 385 | MockSecretsOutput.SecretsPageOutput = &secretsmanager.ListSecretsOutput{ 386 | SecretList: []*secretsmanager.SecretListEntry{ 387 | { 388 | Name: _s("random/aws/secret003"), 389 | LastChangedDate: _t(time_now.AddDate(0, 0, -2)), 390 | SecretVersionsToStages: map[string][]*string{ 391 | "002": []*string{ 392 | _s("AWSCURRENT"), 393 | }, 394 | }, 395 | }, { 396 | Name: _s("random/aws/secret003"), 397 | LastChangedDate: _t(time_now.AddDate(0, 0, -1)), 398 | SecretVersionsToStages: map[string][]*string{ 399 | "005": { 400 | _s("AWSPREVIOUS"), 401 | }, 402 | "006": { 403 | _s("AWSCURRENT"), 404 | }, 405 | }, 406 | }, 407 | }, 408 | } 409 | 410 | secretExpect := &corev1.Secret{ 411 | ObjectMeta: metav1.ObjectMeta{ 412 | Name: secretKey.Name, 413 | Namespace: secretKey.Namespace, 414 | }, 415 | Type: "Opaque", 416 | Data: map[string][]byte{ 417 | "DB_NAME": []byte("secretDB02"), 418 | "DB_PASS": []byte("cupoftea"), 419 | }, 420 | } 421 | 422 | fetchedSecret := &corev1.Secret{} 423 | Eventually(func() bool { 424 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 425 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 426 | }, timeout, interval).Should(BeTrue()) 427 | }) 428 | }) 429 | 430 | Context("For a single SyncedSecret (using DataFrom) with AWSAccountID", func() { 431 | secretKey := types.NamespacedName{ 432 | Name: "secret-name-from-data", 433 | Namespace: TEST_NAMESPACE3, 434 | } 435 | 436 | resourceVersion := "" 437 | 438 | It("Should Create K8S Secrets for SyncedSecret (using Data) CRD with AWSAccountID", func() { 439 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 440 | SecretString: _s(`{"DB_NAME":"secretDB","DB_PASS":"cupofcoffee"}`), 441 | VersionId: _s(`006`), 442 | } 443 | 444 | toCreate := &secretsv1.SyncedSecret{ 445 | ObjectMeta: metav1.ObjectMeta{ 446 | Name: secretKey.Name, 447 | Namespace: secretKey.Namespace, 448 | ResourceVersion: resourceVersion, 449 | }, 450 | Spec: secretsv1.SyncedSecretSpec{ 451 | SecretMetadata: secretsv1.SecretMetadata{ 452 | Name: secretKey.Name, 453 | Namespace: secretKey.Namespace, 454 | CreationTimestamp: metav1.Time{ 455 | Time: time_now, 456 | }, 457 | }, 458 | AWSAccountID: _s("12345678910"), 459 | IAMRole: _s("test"), 460 | DataFrom: &secretsv1.DataFrom{ 461 | SecretRef: &secretsv1.SecretRef{ 462 | Name: _s("random/aws/secret005"), 463 | }, 464 | }, 465 | }, 466 | } 467 | secretExpect := &corev1.Secret{ 468 | ObjectMeta: metav1.ObjectMeta{ 469 | Name: secretKey.Name, 470 | Namespace: secretKey.Namespace, 471 | }, 472 | Type: "Opaque", 473 | Data: map[string][]byte{ 474 | "DB_NAME": []byte("secretDB"), 475 | "DB_PASS": []byte("cupofcoffee"), 476 | }, 477 | } 478 | err := k8sClient.Create(context.Background(), toCreate) 479 | Expect(err).ToNot(HaveOccurred()) 480 | 481 | fetchedSecret := &corev1.Secret{} 482 | Eventually(func() bool { 483 | err := k8sClient.Get(context.Background(), secretKey, fetchedSecret) 484 | return k8serrors.IsNotFound(err) 485 | }, timeout, interval).Should(BeFalse()) 486 | 487 | // we need to ensure that that secretExpect.Data is a subset of fetchedSecret.Data 488 | // the kubernetes client.go doesn't base64 values this is something that kubectl maybe does 489 | Expect(reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data)).To(BeTrue()) 490 | 491 | fetchedCfSecret := &secretsv1.SyncedSecret{} 492 | err = k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 493 | Expect(err).ToNot(HaveOccurred()) 494 | resourceVersion = fetchedCfSecret.ResourceVersion 495 | 496 | }) 497 | 498 | It("Should update k8s secret object if there is change in AwsSecret CRD with AWSAccountID", func() { 499 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 500 | SecretString: _s(`{"DB_NAME":"secretDB02","DB_PASS":"cupofcoffee"}`), 501 | VersionId: _s(`006`), 502 | } 503 | 504 | toUpdate := &secretsv1.SyncedSecret{ 505 | ObjectMeta: metav1.ObjectMeta{ 506 | Name: secretKey.Name, 507 | Namespace: secretKey.Namespace, 508 | ResourceVersion: resourceVersion, 509 | }, 510 | Spec: secretsv1.SyncedSecretSpec{ 511 | SecretMetadata: secretsv1.SecretMetadata{ 512 | Name: secretKey.Name, 513 | Namespace: secretKey.Namespace, 514 | CreationTimestamp: metav1.Time{ 515 | Time: time_now, 516 | }, 517 | }, 518 | IAMRole: _s("test"), 519 | AWSAccountID: _s("12345678910"), 520 | DataFrom: &secretsv1.DataFrom{ 521 | SecretRef: &secretsv1.SecretRef{ 522 | Name: _s("random/aws/secret006"), 523 | }, 524 | }, 525 | }, 526 | } 527 | 528 | secretExpect := &corev1.Secret{ 529 | ObjectMeta: metav1.ObjectMeta{ 530 | Name: secretKey.Name, 531 | Namespace: secretKey.Namespace, 532 | }, 533 | Type: "Opaque", 534 | Data: map[string][]byte{ 535 | "DB_NAME": []byte("secretDB02"), 536 | "DB_PASS": []byte("cupofcoffee"), 537 | }, 538 | } 539 | 540 | Expect(k8sClient.Update(context.Background(), toUpdate)).Should(Succeed()) 541 | 542 | fetchedSecret := &corev1.Secret{} 543 | Eventually(func() bool { 544 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 545 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 546 | }, timeout, interval).Should(BeTrue()) 547 | 548 | fetchedCfSecret := &secretsv1.SyncedSecret{} 549 | err := k8sClient.Get(context.Background(), secretKey, fetchedCfSecret) 550 | Expect(err).ToNot(HaveOccurred()) 551 | resourceVersion = fetchedCfSecret.ResourceVersion 552 | }) 553 | 554 | It("Should update the k8s secret object if the mapped AWS Secret changes with AWSAccountID", func() { 555 | MockSecretsOutput.SecretsValueOutput = &secretsmanager.GetSecretValueOutput{ 556 | SecretString: _s(`{"DB_PASS":"cupoftea3", "DB_NAME":"secretDB03"}`), 557 | VersionId: _s(`007`), 558 | } 559 | 560 | MockSecretsOutput.SecretsPageOutput = &secretsmanager.ListSecretsOutput{ 561 | SecretList: []*secretsmanager.SecretListEntry{ 562 | { 563 | Name: _s("random/aws/secret006"), 564 | LastChangedDate: _t(time_now.AddDate(0, 0, -1)), 565 | SecretVersionsToStages: map[string][]*string{ 566 | "006": { 567 | _s("AWSPREVIOUS"), 568 | }, 569 | "007": { 570 | _s("AWSCURRENT"), 571 | }, 572 | }, 573 | }, 574 | }, 575 | } 576 | 577 | secretExpect := &corev1.Secret{ 578 | ObjectMeta: metav1.ObjectMeta{ 579 | Name: secretKey.Name, 580 | Namespace: secretKey.Namespace, 581 | }, 582 | Type: "Opaque", 583 | Data: map[string][]byte{ 584 | "DB_NAME": []byte("secretDB03"), 585 | "DB_PASS": []byte("cupoftea3"), 586 | }, 587 | } 588 | 589 | fetchedSecret := &corev1.Secret{} 590 | Eventually(func() bool { 591 | k8sClient.Get(context.Background(), secretKey, fetchedSecret) 592 | return reflect.DeepEqual(fetchedSecret.Data, secretExpect.Data) 593 | }, timeout, interval).Should(BeTrue()) 594 | }) 595 | }) 596 | }) 597 | -------------------------------------------------------------------------------- /pkg/k8ssecret/secret_test.go: -------------------------------------------------------------------------------- 1 | package k8ssecret 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/contentful-labs/kube-secret-syncer/pkg/secretsmanager" 12 | "github.com/go-logr/logr" 13 | 14 | secretsv1 "github.com/contentful-labs/kube-secret-syncer/api/v1" 15 | corev1 "k8s.io/api/core/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | ) 18 | 19 | func _s(A string) *string { 20 | return &A 21 | } 22 | 23 | func mockgetSecretValue(string, string) (string, error) { 24 | return `{ 25 | "key1": "value1", 26 | "key2": "value2" 27 | }`, nil 28 | } 29 | 30 | func mockgetNonJSONSecretValue(string, string) (string, error) { 31 | return `not a json`, nil 32 | } 33 | 34 | func mockgetDBSecretValue(secretID string, role string) (string, error) { 35 | user := "contentful" 36 | if strings.Contains(secretID, "graphapi") { 37 | user = "graphapi" 38 | } 39 | 40 | secret := map[string]interface{}{ 41 | "shardid": secretID, 42 | "host": fmt.Sprintf("%s-host", secretID), 43 | "user": user, 44 | "password": fmt.Sprintf("%s-password", secretID), 45 | } 46 | 47 | asJson, err := json.Marshal(secret) 48 | if err != nil { 49 | return "", err 50 | } 51 | return string(asJson), nil 52 | } 53 | 54 | func mockFailinggetSecretValue(string, string) (string, error) { 55 | return "", fmt.Errorf("failed getting secret value") 56 | } 57 | 58 | func TestGenerateSecret(t *testing.T) { 59 | type have struct { 60 | secretsv1.SyncedSecret 61 | secretVersion string 62 | err error 63 | cachedSecrets secretsmanager.Secrets 64 | secretValueGetter func(string, string) (string, error) 65 | } 66 | testCases := []struct { 67 | name string 68 | have have 69 | want *corev1.Secret 70 | }{ 71 | { 72 | name: "it should copy all fields from a K8S Secret given a DataFrom field", 73 | have: have{ 74 | SyncedSecret: secretsv1.SyncedSecret{ 75 | ObjectMeta: metav1.ObjectMeta{ 76 | Name: "secret-name", 77 | Namespace: "secret-namespace", 78 | }, 79 | Spec: secretsv1.SyncedSecretSpec{ 80 | SecretMetadata: secretsv1.SecretMetadata{ 81 | Name: "secret-name", 82 | Namespace: "secret-namespace", 83 | Annotations: map[string]string{ 84 | "randomkey": "random/string", 85 | }, 86 | }, 87 | DataFrom: &secretsv1.DataFrom{SecretRef: &secretsv1.SecretRef{Name: aws.String("cf/secret/test")}}, 88 | IAMRole: _s("iam_role"), 89 | }, 90 | }, 91 | err: nil, 92 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 93 | secretValueGetter: mockgetSecretValue, 94 | }, 95 | want: &corev1.Secret{ 96 | TypeMeta: metav1.TypeMeta{ 97 | Kind: "Secret", 98 | APIVersion: "v1", 99 | }, 100 | ObjectMeta: metav1.ObjectMeta{ 101 | Name: "secret-name", 102 | Namespace: "secret-namespace", 103 | Annotations: map[string]string{ 104 | "randomkey": "random/string", 105 | }, 106 | }, 107 | Type: "Opaque", 108 | Data: map[string][]byte{ 109 | "key1": []byte("value1"), 110 | "key2": []byte("value2"), 111 | }, 112 | }, 113 | }, 114 | { 115 | name: "it should support fields with a hardcoded value", 116 | have: have{ 117 | SyncedSecret: secretsv1.SyncedSecret{ 118 | ObjectMeta: metav1.ObjectMeta{ 119 | Name: "secret-name", 120 | Namespace: "secret-namespace", 121 | }, 122 | Spec: secretsv1.SyncedSecretSpec{ 123 | SecretMetadata: secretsv1.SecretMetadata{ 124 | Name: "secret-name", 125 | Namespace: "secret-namespace", 126 | Annotations: map[string]string{ 127 | "randomkey": "random/string", 128 | }, 129 | }, 130 | Data: []*secretsv1.SecretField{ 131 | { 132 | Name: _s("foo"), 133 | Value: _s("bar"), 134 | }, 135 | { 136 | Name: _s("field2"), 137 | Value: _s("value2"), 138 | }, 139 | }, 140 | IAMRole: _s("iam_role"), 141 | }, 142 | }, 143 | err: nil, 144 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 145 | secretValueGetter: mockgetSecretValue, 146 | }, 147 | want: &corev1.Secret{ 148 | TypeMeta: metav1.TypeMeta{ 149 | Kind: "Secret", 150 | APIVersion: "v1", 151 | }, 152 | ObjectMeta: metav1.ObjectMeta{ 153 | Name: "secret-name", 154 | Namespace: "secret-namespace", 155 | Annotations: map[string]string{ 156 | "randomkey": "random/string", 157 | }, 158 | }, 159 | Type: "Opaque", 160 | Data: map[string][]byte{ 161 | "foo": []byte("bar"), 162 | "field2": []byte("value2"), 163 | }, 164 | }, 165 | }, 166 | { 167 | name: "it should support references to a single field in an AWS Secret", 168 | have: have{ 169 | SyncedSecret: secretsv1.SyncedSecret{ 170 | ObjectMeta: metav1.ObjectMeta{ 171 | Name: "secret-name", 172 | Namespace: "secret-namespace", 173 | }, 174 | Spec: secretsv1.SyncedSecretSpec{ 175 | SecretMetadata: secretsv1.SecretMetadata{ 176 | Name: "secret-name", 177 | Namespace: "secret-namespace", 178 | Annotations: map[string]string{ 179 | "randomkey": "random/string", 180 | }, 181 | }, 182 | Data: []*secretsv1.SecretField{ 183 | { 184 | Name: _s("foo"), 185 | ValueFrom: &secretsv1.ValueFrom{ 186 | SecretKeyRef: &secretsv1.SecretKeyRef{ 187 | Name: _s("cf/secret/test"), 188 | Key: _s("key2"), 189 | }, 190 | }, 191 | }, 192 | }, 193 | IAMRole: _s("iam_role"), 194 | }, 195 | }, 196 | err: nil, 197 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 198 | secretValueGetter: mockgetSecretValue, 199 | }, 200 | want: &corev1.Secret{ 201 | TypeMeta: metav1.TypeMeta{ 202 | Kind: "Secret", 203 | APIVersion: "v1", 204 | }, 205 | ObjectMeta: metav1.ObjectMeta{ 206 | Name: "secret-name", 207 | Namespace: "secret-namespace", 208 | Annotations: map[string]string{ 209 | "randomkey": "random/string", 210 | }, 211 | }, 212 | Type: "Opaque", 213 | Data: map[string][]byte{ 214 | "foo": []byte("value2"), 215 | }, 216 | }, 217 | }, 218 | { 219 | name: "it should fail to references a field in a non-JSON secret", 220 | have: have{ 221 | SyncedSecret: secretsv1.SyncedSecret{ 222 | ObjectMeta: metav1.ObjectMeta{ 223 | Name: "secret-name", 224 | Namespace: "secret-namespace", 225 | }, 226 | Spec: secretsv1.SyncedSecretSpec{ 227 | SecretMetadata: secretsv1.SecretMetadata{ 228 | Name: "secret-name", 229 | Namespace: "secret-namespace", 230 | Annotations: map[string]string{ 231 | "randomkey": "random/string", 232 | }, 233 | }, 234 | Data: []*secretsv1.SecretField{ 235 | { 236 | Name: _s("foo"), 237 | ValueFrom: &secretsv1.ValueFrom{ 238 | SecretKeyRef: &secretsv1.SecretKeyRef{ 239 | Name: _s("cf/secret/test"), 240 | Key: _s("key2"), 241 | }, 242 | }, 243 | }, 244 | }, 245 | IAMRole: _s("iam_role"), 246 | }, 247 | }, 248 | err: nil, 249 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 250 | secretValueGetter: mockgetNonJSONSecretValue, 251 | }, 252 | want: nil, 253 | }, 254 | { 255 | name: "it should support retrieving raw secret Value", 256 | have: have{ 257 | SyncedSecret: secretsv1.SyncedSecret{ 258 | ObjectMeta: metav1.ObjectMeta{ 259 | Name: "secret-name", 260 | Namespace: "secret-namespace", 261 | }, 262 | Spec: secretsv1.SyncedSecretSpec{ 263 | SecretMetadata: secretsv1.SecretMetadata{ 264 | Name: "secret-name", 265 | Namespace: "secret-namespace", 266 | Annotations: map[string]string{ 267 | "randomkey": "random/string", 268 | }, 269 | }, 270 | Data: []*secretsv1.SecretField{ 271 | { 272 | Name: _s("key1"), 273 | ValueFrom: &secretsv1.ValueFrom{ 274 | SecretRef: &secretsv1.SecretRef{Name: _s("cf/secret/test")}, 275 | }, 276 | }, 277 | }, 278 | IAMRole: _s("iam_role"), 279 | }, 280 | }, 281 | err: nil, 282 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 283 | secretValueGetter: mockgetSecretValue, 284 | }, 285 | want: &corev1.Secret{ 286 | TypeMeta: metav1.TypeMeta{ 287 | Kind: "Secret", 288 | APIVersion: "v1", 289 | }, 290 | ObjectMeta: metav1.ObjectMeta{ 291 | Name: "secret-name", 292 | Namespace: "secret-namespace", 293 | Annotations: map[string]string{ 294 | "randomkey": "random/string", 295 | }, 296 | }, 297 | Type: "Opaque", 298 | Data: map[string][]byte{ 299 | "key1": []byte(`{ 300 | "key1": "value1", 301 | "key2": "value2" 302 | }`), 303 | }, 304 | }, 305 | }, 306 | { 307 | name: "it should support templated fields & getSecretValue", 308 | have: have{ 309 | SyncedSecret: secretsv1.SyncedSecret{ 310 | ObjectMeta: metav1.ObjectMeta{ 311 | Name: "secret-name", 312 | Namespace: "secret-namespace", 313 | }, 314 | Spec: secretsv1.SyncedSecretSpec{ 315 | SecretMetadata: secretsv1.SecretMetadata{ 316 | Name: "secret-name", 317 | Namespace: "secret-namespace", 318 | Annotations: map[string]string{ 319 | "randomkey": "random/string", 320 | }, 321 | }, 322 | Data: []*secretsv1.SecretField{ 323 | { 324 | Name: _s("foo"), 325 | ValueFrom: &secretsv1.ValueFrom{ 326 | Template: _s(`{{- getSecretValue "cachedSecret1" -}}`), 327 | }, 328 | }, 329 | }, 330 | IAMRole: _s("iam_role"), 331 | }, 332 | }, 333 | err: nil, 334 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 335 | secretValueGetter: mockgetSecretValue, 336 | }, 337 | want: &corev1.Secret{ 338 | TypeMeta: metav1.TypeMeta{ 339 | Kind: "Secret", 340 | APIVersion: "v1", 341 | }, 342 | ObjectMeta: metav1.ObjectMeta{ 343 | Name: "secret-name", 344 | Namespace: "secret-namespace", 345 | Annotations: map[string]string{ 346 | "randomkey": "random/string", 347 | }, 348 | }, 349 | Type: "Opaque", 350 | Data: map[string][]byte{ 351 | "foo": []byte(`{ 352 | "key1": "value1", 353 | "key2": "value2" 354 | }`), 355 | }, 356 | }, 357 | }, 358 | { 359 | name: "it should support templated fields & getSecretValueMap", 360 | have: have{ 361 | SyncedSecret: secretsv1.SyncedSecret{ 362 | ObjectMeta: metav1.ObjectMeta{ 363 | Name: "secret-name", 364 | Namespace: "secret-namespace", 365 | }, 366 | Spec: secretsv1.SyncedSecretSpec{ 367 | SecretMetadata: secretsv1.SecretMetadata{ 368 | Name: "secret-name", 369 | Namespace: "secret-namespace", 370 | Annotations: map[string]string{ 371 | "randomkey": "random/string", 372 | }, 373 | }, 374 | Data: []*secretsv1.SecretField{ 375 | { 376 | Name: _s("foo"), 377 | ValueFrom: &secretsv1.ValueFrom{ 378 | Template: _s(`{{- with getSecretValueMap "cachedSecret1" }}{{ .key2 }}{{ end -}}`), 379 | }, 380 | }, 381 | }, 382 | IAMRole: _s("iam_role"), 383 | }, 384 | }, 385 | err: nil, 386 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 387 | secretValueGetter: mockgetSecretValue, 388 | }, 389 | want: &corev1.Secret{ 390 | TypeMeta: metav1.TypeMeta{ 391 | Kind: "Secret", 392 | APIVersion: "v1", 393 | }, 394 | ObjectMeta: metav1.ObjectMeta{ 395 | Name: "secret-name", 396 | Namespace: "secret-namespace", 397 | Annotations: map[string]string{ 398 | "randomkey": "random/string", 399 | }, 400 | }, 401 | Type: "Opaque", 402 | Data: map[string][]byte{ 403 | "foo": []byte("value2"), 404 | }, 405 | }, 406 | }, 407 | { 408 | name: "getSecretValueMap should fail if secret is not JSON", 409 | have: have{ 410 | SyncedSecret: secretsv1.SyncedSecret{ 411 | ObjectMeta: metav1.ObjectMeta{ 412 | Name: "secret-name", 413 | Namespace: "secret-namespace", 414 | }, 415 | Spec: secretsv1.SyncedSecretSpec{ 416 | SecretMetadata: secretsv1.SecretMetadata{ 417 | Name: "secret-name", 418 | Namespace: "secret-namespace", 419 | Annotations: map[string]string{ 420 | "randomkey": "random/string", 421 | }, 422 | }, 423 | Data: []*secretsv1.SecretField{ 424 | { 425 | Name: _s("foo"), 426 | ValueFrom: &secretsv1.ValueFrom{ 427 | Template: _s(`{{- with getSecretValueMap "cachedSecret1" }}{{ .key2 }}{{ end -}}`), 428 | }, 429 | }, 430 | }, 431 | IAMRole: _s("iam_role"), 432 | }, 433 | }, 434 | err: nil, 435 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {}, "cachedSecret2": {}}, 436 | secretValueGetter: mockgetNonJSONSecretValue, 437 | }, 438 | want: nil, 439 | }, 440 | { 441 | name: "it should be able to iterate through the available secrets", 442 | have: have{ 443 | SyncedSecret: secretsv1.SyncedSecret{ 444 | ObjectMeta: metav1.ObjectMeta{ 445 | Name: "secret-name", 446 | Namespace: "secret-namespace", 447 | }, 448 | Spec: secretsv1.SyncedSecretSpec{ 449 | SecretMetadata: secretsv1.SecretMetadata{ 450 | Name: "secret-name", 451 | Namespace: "secret-namespace", 452 | Annotations: map[string]string{ 453 | "randomkey": "random/string", 454 | }, 455 | }, 456 | Data: []*secretsv1.SecretField{ 457 | { 458 | Name: _s("foo"), 459 | ValueFrom: &secretsv1.ValueFrom{ 460 | Template: _s(` 461 | {{- $cfg := "" -}} 462 | {{- range $secretName, $_ := filterByTagKey .Secrets "tag1" -}} 463 | {{- $secretValue := getSecretValueMap $secretName -}} 464 | {{- $cfg = printf "%shost=%s user=%s password=%s\n" $cfg $secretValue.host $secretValue.user $secretValue.password -}} 465 | {{- end -}} 466 | {{- $cfg -}} 467 | `), 468 | }, 469 | }, 470 | }, 471 | IAMRole: _s("iam_role"), 472 | }, 473 | }, 474 | err: nil, 475 | cachedSecrets: secretsmanager.Secrets{ 476 | "cachedSecret1": { 477 | Tags: map[string]string{ 478 | "unknownTag": "true", 479 | }, 480 | }, 481 | "cachedSecret2": { 482 | Tags: map[string]string{ 483 | "tag1": "true", 484 | }, 485 | }, 486 | "cachedSecret3": { 487 | Tags: map[string]string{ 488 | "tag1": "true", 489 | }, 490 | }, 491 | }, 492 | secretValueGetter: mockgetDBSecretValue, 493 | }, 494 | want: &corev1.Secret{ 495 | TypeMeta: metav1.TypeMeta{ 496 | Kind: "Secret", 497 | APIVersion: "v1", 498 | }, 499 | ObjectMeta: metav1.ObjectMeta{ 500 | Name: "secret-name", 501 | Namespace: "secret-namespace", 502 | Annotations: map[string]string{ 503 | "randomkey": "random/string", 504 | }, 505 | }, 506 | Type: "Opaque", 507 | Data: map[string][]byte{ 508 | "foo": []byte("host=cachedSecret2-host user=contentful password=cachedSecret2-password\nhost=cachedSecret3-host user=contentful password=cachedSecret3-password\n"), 509 | }, 510 | }, 511 | }, 512 | { 513 | name: "AwsSecret should fail if getSecretvalue Fails", 514 | have: have{ 515 | SyncedSecret: secretsv1.SyncedSecret{ 516 | ObjectMeta: metav1.ObjectMeta{ 517 | Name: "secret-name", 518 | Namespace: "secret-namespace", 519 | }, 520 | Spec: secretsv1.SyncedSecretSpec{ 521 | Data: []*secretsv1.SecretField{ 522 | { 523 | Name: _s("foo"), 524 | ValueFrom: &secretsv1.ValueFrom{ 525 | Template: _s(` 526 | {{- $cfg := "" -}} 527 | {{- range $secretName, $_ := filterByTagKey .Secrets "tag1" -}} 528 | {{- $secretValue := getSecretValueMap $secretName -}} 529 | {{- $cfg = printf "%shost=%s user=%s password=%s\n" $cfg $secretValue.host $secretValue.user $secretValue.password -}} 530 | {{- end -}} 531 | {{- $cfg -}} 532 | `), 533 | }, 534 | }, 535 | }, 536 | IAMRole: _s("iam_role"), 537 | }, 538 | }, 539 | err: nil, 540 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {Tags: map[string]string{"tag1": ""}}, "cachedSecret2": {}}, 541 | secretValueGetter: mockFailinggetSecretValue, 542 | }, 543 | want: nil, 544 | }, 545 | { 546 | name: "AwsSecret should fail if getSecretvalue Fails", 547 | have: have{ 548 | SyncedSecret: secretsv1.SyncedSecret{ 549 | ObjectMeta: metav1.ObjectMeta{ 550 | Name: "secret-name", 551 | Namespace: "secret-namespace", 552 | }, 553 | Spec: secretsv1.SyncedSecretSpec{ 554 | Data: []*secretsv1.SecretField{ 555 | { 556 | Name: _s("foo"), 557 | ValueFrom: &secretsv1.ValueFrom{ 558 | Template: _s(` 559 | {{- $cfg := "" # INVALID 560 | {{- range $secretName, $_ := filterByTagKey .Secrets "tag1" -}} 561 | {{- $secretValue := getSecretValueMap $secretName -}} 562 | {{- $cfg = printf "%shost=%s user=%s password=%s\n" $cfg $secretValue.host $secretValue.user $secretValue.password -}} 563 | {{- end -}} 564 | {{- $cfg -}} 565 | `), 566 | }, 567 | }, 568 | }, 569 | IAMRole: _s("iam_role"), 570 | }, 571 | }, 572 | err: nil, 573 | cachedSecrets: secretsmanager.Secrets{"cachedSecret1": {Tags: map[string]string{"tag1": ""}}, "cachedSecret2": {}}, 574 | secretValueGetter: mockgetSecretValue, 575 | }, 576 | want: nil, 577 | }, 578 | } 579 | 580 | for _, test := range testCases { 581 | k8sSecret, err := GenerateK8SSecret(test.have.SyncedSecret, test.have.cachedSecrets, test.have.secretValueGetter, secretsmanager.FilterByTagKey, logr.Logger{}) 582 | if !reflect.DeepEqual(k8sSecret, test.want) { 583 | if k8sSecret != nil && k8sSecret.Data != nil { 584 | for k, v := range k8sSecret.Data { 585 | fmt.Printf("%s: %s\n", k, string(v)) 586 | } 587 | } 588 | want, _ := json.MarshalIndent(test.want, "", " ") 589 | got, _ := json.MarshalIndent(k8sSecret, "", " ") 590 | t.Errorf("Failed: %s\nwanted:\t%s\ngenerated:\t%s \nerror: %s", test.name, want, got, err) 591 | } 592 | } 593 | } 594 | func TestK8SSecretsEqual(t *testing.T) { 595 | testEqualCases := []struct { 596 | secret1, secret2 corev1.Secret 597 | }{ 598 | { 599 | corev1.Secret{ 600 | TypeMeta: metav1.TypeMeta{ 601 | APIVersion: "v1", 602 | Kind: "Secret", 603 | }, 604 | ObjectMeta: metav1.ObjectMeta{ 605 | Name: "testName", 606 | Namespace: "testNamespace", 607 | Annotations: make(map[string]string), 608 | Labels: make(map[string]string), 609 | }, 610 | Type: "Opaque", 611 | Data: map[string][]byte{}, 612 | }, 613 | corev1.Secret{ 614 | TypeMeta: metav1.TypeMeta{ 615 | APIVersion: "v1", 616 | Kind: "Secret", 617 | }, 618 | ObjectMeta: metav1.ObjectMeta{ 619 | Name: "testName", 620 | Namespace: "testNamespace", 621 | Annotations: make(map[string]string), 622 | Labels: make(map[string]string), 623 | }, 624 | Type: "Opaque", 625 | Data: map[string][]byte{}, 626 | }, 627 | }, 628 | } 629 | 630 | for _, testCase := range testEqualCases { 631 | if !K8SSecretsEqual(testCase.secret1, testCase.secret2) { 632 | t.Errorf("secrets not equal, but should be") 633 | } 634 | } 635 | } 636 | 637 | func TestSecretlength(t *testing.T) { 638 | emptySecret := corev1.Secret{ 639 | TypeMeta: metav1.TypeMeta{ 640 | APIVersion: "v1", 641 | Kind: "Secret", 642 | }, 643 | ObjectMeta: metav1.ObjectMeta{ 644 | Name: "testName", 645 | Namespace: "testNamespace", 646 | Annotations: make(map[string]string), 647 | Labels: make(map[string]string), 648 | }, 649 | Type: "Opaque", 650 | Data: map[string][]byte{}, 651 | } 652 | secret := corev1.Secret{ 653 | TypeMeta: metav1.TypeMeta{ 654 | APIVersion: "v1", 655 | Kind: "Secret", 656 | }, 657 | ObjectMeta: metav1.ObjectMeta{ 658 | Name: "testName", 659 | Namespace: "testNamespace", 660 | Annotations: make(map[string]string), 661 | Labels: make(map[string]string), 662 | }, 663 | Type: "Opaque", 664 | Data: map[string][]byte{"password": []byte("alma")}, 665 | } 666 | 667 | if SecretLength(&emptySecret) > 0 { 668 | t.Errorf("secret length should be 0 but it is not") 669 | } 670 | 671 | if SecretLength(&secret) <= SecretLength(&emptySecret) { 672 | t.Errorf("longer secrets length should be bigger than empty secrets, but it isn't") 673 | } 674 | } 675 | --------------------------------------------------------------------------------