├── .github ├── CODEOWNERS ├── renovate.json5 └── workflows │ └── ci.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── go.mod ├── go.sum ├── internal ├── cmd │ ├── murmur.go │ ├── murmur_exec.go │ └── murmur_run.go ├── environ │ ├── environ.go │ ├── environ_test.go │ └── example_test.go ├── murmur │ ├── filter.go │ ├── filters │ │ └── jsonpath │ │ │ ├── filter.go │ │ │ └── filter_test.go │ ├── provider.go │ ├── providers │ │ ├── awssm │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── e2e_test.go │ │ ├── azkv │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── e2e_test.go │ │ ├── gcpsm │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── e2e_test.go │ │ ├── jsonmock │ │ │ └── client.go │ │ ├── mock │ │ │ └── client.go │ │ ├── passthrough │ │ │ ├── client.go │ │ │ └── client_test.go │ │ └── scwsm │ │ │ ├── client.go │ │ │ ├── client_test.go │ │ │ └── e2e_test.go │ ├── query.go │ ├── query_test.go │ ├── resolve.go │ ├── resolve_e2e_test.go │ ├── resolve_test.go │ ├── run.go │ ├── run_test.go │ └── testdata │ │ └── signal.sh └── slices │ ├── slices.go │ └── slices_test.go ├── main.go └── terraform ├── README.md └── layers ├── aws-secrets-manager ├── _providers.tf ├── _settings.tf ├── github_actions.tf └── secret.tf ├── azure-keyvault ├── _providers.tf ├── _settings.tf ├── client_configs.tf ├── github_actions.tf └── keyvault_secrets.tf ├── bootstrap ├── README.md ├── _providers.tf ├── _settings.tf ├── state-bucket.tf └── terraform.tfstate ├── gcp-secret-manager ├── _providers.tf ├── _settings.tf ├── github_actions.tf ├── google.tf ├── project_services.tf └── secret.tf └── scw-secret-manager ├── _providers.tf ├── _settings.tf ├── github_actions.tf └── secret.tf /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @busser 2 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: ["config:base"], 4 | 5 | // Renovate uses plugins called "managers" to handle different languages and 6 | // frameworks. The full list of managers and their behaviour is documented 7 | // here: https://docs.renovatebot.com/modules/manager/ 8 | enabledManagers: ["gomod", "terraform-version", "terraform"], 9 | 10 | // Allow Renovate to update this file when new features require it. 11 | configMigration: true, 12 | 13 | // Default to a generic prefix, if a more specific prefix is not set in the 14 | // packageRules below. 15 | commitMessagePrefix: "⬆️ ", 16 | 17 | // Assign pull requests automatically. 18 | additionalReviewers: ["busser"], 19 | 20 | // Use packageRules to customize Renovate's behavior. Be specific with the 21 | // match* fields in each rule, to avoid impacting unexpected managers or 22 | // packages. 23 | packageRules: [ 24 | // Set a prefix that makes reading the changelog easier. 25 | { 26 | matchManagers: ["gomod"], 27 | commitMessagePrefix: "⬆️[Go] ", 28 | }, 29 | { 30 | matchManagers: ["terraform-version", "terraform"], 31 | commitMessagePrefix: "⬆️[Terraform] ", 32 | }, 33 | 34 | // Group dependencies into single pull requests to make reviews easier. 35 | { 36 | matchManagers: ["gomod"], 37 | groupName: "Go packages", 38 | }, 39 | { 40 | matchManagers: ["terraform"], 41 | groupName: "Terraform providers", 42 | }, 43 | 44 | // Tidy up lock files after updates. 45 | { 46 | matchManagers: ["gomod"], 47 | postUpdateOptions: ["gomodTidy"], 48 | }, 49 | 50 | // Handle strange exceptions. Please explain why for future readers. 51 | { 52 | // This package changed their versioning scheme from X.Y.Z to 0.X.Y a few 53 | // years ago. This config stops Renovate from suggesting we upgrade from 54 | // v0.27 to v11. 55 | matchManagers: ["gomod"], 56 | matchPackageNames: ["k8s.io/client-go"], 57 | allowedVersions: "<1.0", 58 | }, 59 | ], 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build-and-unit-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-go@v4 11 | with: 12 | go-version-file: go.mod 13 | - run: make build test 14 | 15 | end-to-end-tests: 16 | runs-on: ubuntu-latest 17 | 18 | # Required for workload identity, which end-to-end tests use to authenticate 19 | # to Google Cloud and AWS. 20 | permissions: 21 | contents: read 22 | id-token: write 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-go@v4 27 | with: 28 | go-version-file: go.mod 29 | 30 | # This service account and the workload identity setup required for 31 | # Github Actions to use the service account are managed by Terraform. 32 | # The relevant code is in the terraform/layers/gcp-secret-manager 33 | # directory of this repository. 34 | - uses: google-github-actions/auth@v1 35 | with: 36 | workload_identity_provider: "projects/221642914929/locations/global/workloadIdentityPools/default/providers/github-oidc" 37 | service_account: "github-actions@murmur-tests.iam.gserviceaccount.com" 38 | token_format: "access_token" 39 | access_token_lifetime: "300s" 40 | 41 | # This role and the workload identity setup required for Github Actions to 42 | # use the role are managed by Terraform. 43 | # The relevant code is in the terraform/layers/aws-secrets-manager 44 | # directory of this repository. 45 | - uses: aws-actions/configure-aws-credentials@v2 46 | timeout-minutes: 1 47 | with: 48 | aws-region: eu-west-3 49 | role-to-assume: arn:aws:iam::531255069405:role/GithubActions 50 | 51 | - run: make test-e2e 52 | env: 53 | # These secrets are managed by Terraform. The relevant code is in the 54 | # terraform/layers/azure-keyvault directory of this repository. 55 | AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} 56 | AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} 57 | AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} 58 | 59 | # These secrets are managed by Terraform. The relevant code is in the 60 | # terraform/layers/scw-secret-manager directory of this repository. 61 | SCW_DEFAULT_REGION: fr-par 62 | SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }} 63 | SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore developer-specific workflow configuration files. 2 | .envrc 3 | 4 | # Ignore Azure client configuration. 5 | .azure/ 6 | 7 | # Ignore AWS client configuration. 8 | .aws/ 9 | 10 | # Ignore Google Cloud client configuration. 11 | .gcloud/ 12 | 13 | # Ignore Scaleway configuration. 14 | .scw/ 15 | 16 | # Compiled binaries 17 | bin/ 18 | 19 | # Release artifacts 20 | dist/ 21 | 22 | # Terraform artifacts 23 | **/.terraform 24 | **/.terraform.lock.hcl 25 | **/terraform.tfstate 26 | **/terraform.tfstate.backup 27 | 28 | # The bootstrap layer's state is stored in this repository. 29 | !terraform/layers/bootstrap/terraform.tfstate -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - id: murmur 7 | binary: murmur 8 | env: [CGO_ENABLED=0] 9 | goos: [linux, windows, darwin] 10 | main: ./ 11 | # The project was renamed "murmur" in May 2023. For backwards compatibility, 12 | # we continue to publish artifacts named "whisper". 13 | - id: whisper 14 | binary: whisper 15 | env: [CGO_ENABLED=0] 16 | goos: [linux, windows, darwin] 17 | main: ./ 18 | 19 | archives: 20 | - id: murmur 21 | builds: [murmur] 22 | name_template: >- 23 | {{- .ProjectName }}_ 24 | {{- .Version }}_ 25 | {{- title .Os }}_ 26 | {{- if eq .Arch "amd64" }}x86_64 27 | {{- else if eq .Arch "386" }}i386 28 | {{- else }}{{ .Arch }}{{ end }} 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | # The project was renamed "murmur" in May 2023. For backwards compatibility, 33 | # we continue to publish artifacts named "whisper". 34 | - id: whisper 35 | builds: [whisper] 36 | name_template: >- 37 | whisper_ 38 | {{- .Version }}_ 39 | {{- title .Os }}_ 40 | {{- if eq .Arch "amd64" }}x86_64 41 | {{- else if eq .Arch "386" }}i386 42 | {{- else }}{{ .Arch }}{{ end }} 43 | format_overrides: 44 | - goos: windows 45 | format: zip 46 | 47 | dockers: 48 | - id: murmur 49 | ids: [murmur] 50 | build_flag_templates: 51 | - --build-arg=BINARY=murmur 52 | - --platform=linux/amd64 53 | image_templates: 54 | - ghcr.io/busser/murmur:{{ .Tag }} 55 | - ghcr.io/busser/murmur:v{{ .Major }}.{{ .Minor }} 56 | - ghcr.io/busser/murmur:v{{ .Major }} 57 | - ghcr.io/busser/murmur:latest 58 | # The project was renamed "murmur" in May 2023. For backwards compatibility, 59 | # we continue to publish artifacts named "whisper". 60 | - id: whisper 61 | ids: [whisper] 62 | build_flag_templates: 63 | - --build-arg=BINARY=whisper 64 | - --platform=linux/amd64 65 | image_templates: 66 | - ghcr.io/busser/whisper:{{ .Tag }} 67 | - ghcr.io/busser/whisper:v{{ .Major }}.{{ .Minor }} 68 | - ghcr.io/busser/whisper:v{{ .Major }} 69 | - ghcr.io/busser/whisper:latest 70 | 71 | checksum: 72 | name_template: "checksums.txt" 73 | snapshot: 74 | name_template: "{{ .Tag }}-next" 75 | changelog: 76 | sort: asc 77 | filters: 78 | exclude: 79 | - "^chore:" 80 | - '^chore\(deps\):' 81 | - "^docs:" 82 | - '^fix\(deps\):' 83 | - "^refactor:" 84 | - "^test:" 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox 2 | 3 | ARG BINARY=murmur 4 | 5 | LABEL org.opencontainers.image.source=https://github.com/busser/murmur 6 | 7 | # The binary is built beforehand. 8 | COPY ${BINARY} / 9 | 10 | ENTRYPOINT ["/${BINARY}"] 11 | -------------------------------------------------------------------------------- /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 2022 Arthur Busser 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_TARGET=help 2 | VERSION:=$(shell cat VERSION) 3 | 4 | # Image URL to use all building/pushing image targets 5 | IMG ?= ghcr.io/busser/murmur:$(VERSION) 6 | 7 | # Setting SHELL to bash allows bash commands to be executed by recipes. 8 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 9 | SHELL = /usr/bin/env bash -o pipefail 10 | .SHELLFLAGS = -ec 11 | 12 | ##@ General 13 | 14 | # The help target prints out all targets with their descriptions organized 15 | # beneath their categories. The categories are represented by '##@' and the 16 | # target descriptions by '##'. The awk commands is responsible for reading the 17 | # entire set of makefiles included in this invocation, looking for lines of the 18 | # file as xyz: ## something, and then pretty-format the target and help. Then, 19 | # if there's a line with ##@ something, that gets pretty-printed as a category. 20 | # More info on the usage of ANSI control characters for terminal formatting: 21 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 22 | # More info on the awk command: 23 | # http://linuxcommand.org/lc3_adv_awk.php 24 | 25 | .PHONY: help 26 | help: ## Display this help. 27 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 28 | 29 | ##@ Development 30 | 31 | .PHONY: fmt 32 | fmt: ## Format source code. 33 | go fmt ./... 34 | 35 | .PHONY: vet 36 | vet: ## Vet source code. 37 | go vet ./... 38 | 39 | .PHONY: test 40 | test: ## Run unit tests. 41 | go test ./... 42 | 43 | .PHONY: test-e2e 44 | test-e2e: ## Run all tests, including end-to-end tests. 45 | go test -tags=e2e ./... 46 | 47 | ##@ Build 48 | 49 | .PHONY: build 50 | build: fmt vet ## Build murmur binary. 51 | go build -o bin/murmur 52 | 53 | ##@ Release 54 | 55 | .PHONY: release 56 | release: test ## Release a new version. 57 | git tag -a "$(VERSION)" -m "$(VERSION)" 58 | git push origin "$(VERSION)" 59 | goreleaser release --clean 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤫 Murmur 2 | 3 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/busser/murmur)](https://goreportcard.com/report/github.com/busser/murmur) 5 | ![tests-passing](https://github.com/busser/murmur/actions/workflows/ci.yml/badge.svg) 6 | 7 | Plug-and-play executable to pass secrets as environment variables to a process. 8 | 9 | Murmur is a small binary that reads its environment variables, replaces 10 | references to secrets with the secrets' values, and passes the resulting 11 | variables to your application. Variables that do not reference secrets are 12 | passed as-is. 13 | 14 | Several tools like Murmur exist, each supporting a different secret provider. 15 | Murmur aims to support as many providers as possible, so you can use Murmur no 16 | matter which provider you use. 17 | 18 | | | Scaleway | AWS | Azure | GCP | Vault | 1Password | Doppler | 19 | | ---------------------------------------------------------- | -------- | --- | ----- | --- | ----- | --------- | ------- | 20 | | 🤫 Murmur | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | 21 | | [Berglas](https://github.com/GoogleCloudPlatform/berglas) | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | 22 | | [Bank Vaults](https://github.com/banzaicloud/bank-vaults) | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | 23 | | [1Password CLI](https://developer.1password.com/docs/cli/) | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | 24 | | [Doppler CLI](https://github.com/DopplerHQ/cli) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | 25 | 26 | _If you know of a similar tool that is not listed here, please open an issue so 27 | that we can add it to the list._ 28 | 29 | _If you use a secret provider that is not supported by Murmur, please open an 30 | issue so that we can track demand for it._ 31 | 32 | - [Fetching a database password](#fetching-a-database-password) 33 | - [Adding Murmur to a container image](#adding-murmur-to-a-container-image) 34 | - [Adding Murmur to a Kubernetes pod](#adding-murmur-to-a-kubernetes-pod) 35 | - [Parsing JSON secrets](#parsing-json-secrets) 36 | - [Providers and filters](#providers-and-filters) 37 | - [`scwsm` provider: Scaleway Secret Manager](#scwsm-provider-scaleway-secret-manager) 38 | - [`awssm` provider: AWS Secrets Manager](#awssm-provider-aws-secrets-manager) 39 | - [`azkv` provider: Azure Key Vault](#azkv-provider-azure-key-vault) 40 | - [`gcpsm` provider: GCP Secret Manager](#gcpsm-provider-gcp-secret-manager) 41 | - [`passthrough` provider: no-op](#passthrough-provider-no-op) 42 | - [`jsonpath` filter: JSON parsing and templating](#jsonpath-filter-json-parsing-and-templating) 43 | - [Changes from v0.4 to v0.5](#changes-from-v04-to-v05) 44 | 45 | ## Fetching a database password 46 | 47 | Murmur runs as a wrapper around any command. For example, if you want to connect 48 | to a PostgreSQL database, instead of running this command: 49 | 50 | ```bash 51 | export PGPASSWORD="Q-gVzyDPmvsX6rRAPVjVjvfvR@KGzPJzCEg2" 52 | psql -h 10.1.12.34 -U my-user -d my-database 53 | ``` 54 | 55 | You run this instead: 56 | 57 | ```bash 58 | export PGPASSWORD="scwsm:database-password" 59 | murmur run -- psql -h 10.1.12.34 -U my-user -d my-database 60 | ``` 61 | 62 | Murmur will fetch the value of the `database-password` secret from Scaleway 63 | Secret Manager, set the `PGPASSWORD` environment variable to that value, and 64 | then run `psql`. 65 | 66 | ## Adding Murmur to a container image 67 | 68 | Murmur is a static binary, so you can simply copy it into your container image 69 | and use it as your entrypoint. For convenience, the murmur binary is released as 70 | a container image you can copy from in your Dockerfile: 71 | 72 | ```dockerfile 73 | COPY --from=ghcr.io/busser/murmur:latest /murmur /bin/murmur 74 | ``` 75 | 76 | Then you can change your image's entrypoint: 77 | 78 | ```dockerfile 79 | # from this: 80 | ENTRYPOINT ["/bin/run-my-app"] 81 | # to this: 82 | ENTRYPOINT ["/bin/murmur", "run", "--", "/bin/run-my-app"] 83 | ``` 84 | 85 | ## Adding Murmur to a Kubernetes pod 86 | 87 | You can use Murmur in a Kubernetes pod even if your application's container 88 | image does not include Murmur. To do so, you can use an init container that 89 | copies Murmur into an emptyDir volume, and then use that volume in your 90 | application's container. 91 | 92 | Here is an example: 93 | 94 | ```yaml 95 | apiVersion: v1 96 | kind: Pod 97 | metadata: 98 | name: my-app 99 | spec: 100 | initContainers: 101 | - name: copy-murmur 102 | image: ghcr.io/busser/murmur:latest 103 | command: ["cp", "/murmur", "/shared/murmur"] 104 | volumeMounts: 105 | - name: shared 106 | mountPath: /shared 107 | containers: 108 | - name: my-app 109 | image: my-app:latest 110 | command: ["/shared/murmur", "run", "--", "/bin/run-my-app"] 111 | volumeMounts: 112 | - name: shared 113 | mountPath: /shared 114 | volumes: 115 | - name: shared 116 | emptyDir: {} 117 | ``` 118 | 119 | ## Parsing JSON secrets 120 | 121 | Storing secrets as JSON is a common pattern. For example, a secret might contain 122 | a JSON object with multiple fields: 123 | 124 | ```json 125 | { 126 | "host": "10.1.12.34", 127 | "port": 5432, 128 | "database": "my-database", 129 | "username": "my-user", 130 | "password": "Q-gVzyDPmvsX6rRAPVjVjvfvR@KGzPJzCEg2" 131 | } 132 | ``` 133 | 134 | Murmur can parse that JSON and set environment variables for each field by using 135 | the `jsonpath` filter: 136 | 137 | ```bash 138 | export PGHOST="scwsm:database-credentials|jsonpath:{.host}" 139 | export PGPORT="scwsm:database-credentials|jsonpath:{.port}" 140 | export PGDATABASE="scwsm:database-credentials|jsonpath:{.database}" 141 | export PGUSER="scwsm:database-credentials|jsonpath:{.username}" 142 | export PGPASSWORD="scwsm:database-credentials|jsonpath:{.password}" 143 | murmur run -- psql 144 | ``` 145 | 146 | If you have multiple references to the same secret, Murmur will fetch the secret 147 | only once to avoid unnecessary API calls. 148 | 149 | Alternatively, you can use the `jsonpath` filter to set a single environment 150 | variable with the entire JSON object: 151 | 152 | ```bash 153 | # psql supports connection strings, so we can use a single variable 154 | export PGDATABASE="scwsm:database-credentials|jsonpath:postgres://{.username}:{password}@{.host}:{.port}/{.database}" 155 | murmur run -- psql 156 | ``` 157 | 158 | Murmur uses the Kubernetes JSONPath syntax for the `jsonpath` filter. See the 159 | [Kubernetes documentation](https://kubernetes.io/docs/reference/kubectl/jsonpath/) 160 | for a full list of capabilities. 161 | 162 | ## Providers and filters 163 | 164 | Murmur's architecture is built around providers and filters. Providers fetch 165 | secrets from a secret manager, and filters parse and transform the secrets. 166 | 167 | Murmur only edits environment variables which contain valid queries. A valid 168 | query is structured as follows: 169 | 170 | ```plaintext 171 | provider_id:secret_ref|filter_id:filter_rule 172 | ``` 173 | 174 | Using a filter is optional, so this is also a valid query: 175 | 176 | ```plaintext 177 | provider_id:secret_ref 178 | ``` 179 | 180 | Murmur does not support chaining multiple filters yet. 181 | 182 | ### `scwsm` provider: Scaleway Secret Manager 183 | 184 | To fetch a secret from [Scaleway Secret Manager](https://www.scaleway.com/en/secret-manager/), 185 | the query must be structured as follows: 186 | 187 | ```plaintext 188 | scwsm:[region/]{name|id}[#version] 189 | ``` 190 | 191 | If `region` is not specified, Murmur will delegate region selection to the 192 | Scaleway SDK. The SDK determines the region based on the environment, by looking 193 | at environment variables and configuration files. 194 | 195 | One of `name` or `id` must be specified. Murmur guesses whether the string is a 196 | name or an ID depending on whether it is a valid UUID. UUIDs are treated as IDs, 197 | and other strings are treated as names. 198 | 199 | The `version` must either be a positive integer or the "latest" string. If 200 | `version` is not specified, Murmur defaults to "latest". 201 | 202 | Examples: 203 | 204 | ```plaintext 205 | scwsm:my-secret 206 | scwsm:my-secret#123 207 | scwsm:my-secret#latest 208 | 209 | scwsm:fr-par/my-secret 210 | scwsm:fr-par/my-secret#123 211 | scwsm:fr-par/my-secret#latest 212 | 213 | scwsm:3f34b83f-47a6-4344-bcd4-b63721481cd3 214 | scwsm:3f34b83f-47a6-4344-bcd4-b63721481cd3#123 215 | scwsm:3f34b83f-47a6-4344-bcd4-b63721481cd3#latest 216 | 217 | scwsm:fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3 218 | scwsm:fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3#123 219 | scwsm:fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3#latest 220 | ``` 221 | 222 | Murmur uses the environment's default credentials to authenticate to Scaleway. 223 | You can configure Murmur the same way you can [configure the `scw` CLI](https://github.com/scaleway/scaleway-cli/blob/master/docs/commands/config.md). 224 | 225 | ### `awssm` provider: AWS Secrets Manager 226 | 227 | To fetch a secret from [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/), 228 | the query must be structured as follows: 229 | 230 | ```plaintext 231 | awssm:{name|arn}[#{version_id|version_stage}] 232 | ``` 233 | 234 | One of `name` or `arn` must be specified. You can use a full or partial ARN. 235 | However, if your secret's name ends with a hyphen followed by six characters, 236 | you should not use a partial ARN. See [these AWS docs](https://docs.aws.amazon.com/secretsmanager/latest/userguide/troubleshoot.html#ARN_secretnamehyphen) 237 | for more information. 238 | 239 | You can optionally specify one of `version_id` or `version_stage`. Murmur 240 | guesses whether the string is an ID or a stage depending on whether it is a 241 | valid UUID. UUIDs are treated as version IDs, and other strings are treated as 242 | version stages. If neither `version_id` or `version_stage` are specified, Murmur 243 | defaults to "AWSCURRENT". 244 | 245 | Examples: 246 | 247 | ```plaintext 248 | awssm:my-secret 249 | awssm:my-secret#MY_VERSION_STAGE 250 | awssm:my-secret#9517cc59-646a-4393-81d7-5e6f2d43cbe7 251 | 252 | awssm:arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret 253 | awssm:arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret#MY_VERSION_STAGE 254 | awssm:arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret#9517cc59-646a-4393-81d7-5e6f2d43cbe7 255 | ``` 256 | 257 | Murmur uses the environment's default credentials to authenticate to AWS. 258 | You can configure Murmur the same way you can [configure the `aws` CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html). 259 | 260 | ### `azkv` provider: Azure Key Vault 261 | 262 | To fetch a secret from [Azure Key Vault](https://azure.microsoft.com/en-us/services/key-vault/), 263 | the query must be structured as follows: 264 | 265 | ```plaintext 266 | azkv:keyvault_hostname/name[#version] 267 | ``` 268 | 269 | The `keyvault_hostname` must be the fully qualified domain name of the Key 270 | Vault. For example, if your Key Vault's URL is `https://example.vault.azure.net/`, 271 | then the `keyvault_hostname` is `example.vault.azure.net`. 272 | 273 | The `name` is the name of the secret. 274 | 275 | The `version` must be a valid version ID. If `version` is not specified, Murmur 276 | defaults to the latest version of the secret. 277 | 278 | Examples: 279 | 280 | ```plaintext 281 | azkv:example.vault.azure.net/my-secret 282 | azkv:example.vault.azure.net/my-secret#5ddc29704c1c4429a4c53605b7949100 283 | ``` 284 | 285 | Murmur uses the environment's default credentials to authenticate to Azure. You 286 | can set these credentials with the [environment variables listed here](https://github.com/Azure/azure-sdk-for-go/wiki/Set-up-Your-Environment-for-Authentication#configure-defaultazurecredential), 287 | or with workload identity. 288 | 289 | ### `gcpsm` provider: GCP Secret Manager 290 | 291 | To fetch a secret from [GCP Secret Manager](https://cloud.google.com/secret-manager), 292 | the query must be structured as follows: 293 | 294 | ```plaintext 295 | gcpsm:project/name[#version] 296 | ``` 297 | 298 | The `project` must be either a project ID or a project number. 299 | 300 | The `name` is the name of the secret. 301 | 302 | The `version` must be a valid version number. If `version` is not specified, 303 | Murmur defaults to the latest version of the secret. 304 | 305 | ### `passthrough` provider: no-op 306 | 307 | This provider is meant for demo and testing purposes. It does not fetch any 308 | secrets and simply returns the secret reference as the secret's value. 309 | 310 | This provider, like all other providers, is fully tested. It is safe to use in 311 | production, although why would you? 312 | 313 | Examples: 314 | 315 | ```plaintext 316 | passthrough:my-not-so-secret-value 317 | ``` 318 | 319 | ### `jsonpath` filter: JSON parsing and templating 320 | 321 | To parse a JSON secret and extract a value from it, or to use a secret value in 322 | a template, the query must be stuctured as follows: 323 | 324 | ```plaintext 325 | provider_id:secret_ref|jsonpath:template 326 | ``` 327 | 328 | The `provider_id` and `secret_ref` can be any valid secret reference. 329 | 330 | The `template` is a [JSONPath template](https://kubernetes.io/docs/reference/kubectl/jsonpath/). 331 | Murmur uses the Kubernetes JSONPath implementation, so you can use any feature 332 | described in the Kubernetes docs. 333 | 334 | If the secret's value is not valid JSON, Murmur will treat it as a string and 335 | execute the template anyway. This means that you can use JSONPath templates with 336 | non-JSON secrets. 337 | 338 | Examples: 339 | 340 | ```plaintext 341 | scwsm:my-secret|jsonpath:{.password} 342 | scwsm:my-secret|jsonpath:postgres://{.username}:{.password}@{.hostname}:{.port}/{.database} 343 | scwsm:my-secret|jsonpath:the secret is {@} 344 | ``` 345 | 346 | ## Changes from v0.4 to v0.5 347 | 348 | Following community feedback, we have made two significant changes in v0.5: 349 | 350 | 1. We have renamed the project from "Whisper" to "Murmur", to make the project 351 | documentation easier to find on search engines. 352 | 2. We have renamed the `exec` command to `run`, to make it clear that we are not 353 | executing the command directly, but rather running it as a subprocess. 354 | 355 | We have made it so that none of these changes are breaking. You can upgrade to 356 | v0.5 without changing anything in how you use Whisper/Murmur. 357 | 358 | We now publish binaries and container images with both names. The `exec` command 359 | is still available, but it will log a warning message telling you to use the new 360 | `run` command instead. 361 | 362 | We recommend that you update your scripts to use the new name and command, but 363 | you have all the time you need to do so. 364 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.6.1 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/busser/murmur 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | cloud.google.com/go/secretmanager v1.13.1 7 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 8 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 9 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 10 | github.com/aws/aws-sdk-go-v2 v1.27.0 11 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.29.1 12 | github.com/google/go-cmp v0.6.0 13 | github.com/google/uuid v1.6.0 14 | github.com/hashicorp/go-multierror v1.1.1 15 | github.com/scaleway/scaleway-sdk-go v1.0.0-beta.27 16 | github.com/spf13/cobra v1.8.0 17 | k8s.io/client-go v0.30.1 18 | ) 19 | 20 | require ( 21 | cloud.google.com/go/auth v0.4.1 // indirect 22 | cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect 24 | github.com/felixge/httpsnoop v1.0.4 // indirect 25 | github.com/go-logr/logr v1.4.1 // indirect 26 | github.com/go-logr/stdr v1.2.2 // indirect 27 | github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 28 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect 29 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 30 | go.opentelemetry.io/otel v1.24.0 // indirect 31 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 32 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 33 | golang.org/x/net v0.25.0 // indirect 34 | golang.org/x/sync v0.7.0 // indirect 35 | golang.org/x/time v0.5.0 // indirect 36 | google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect 37 | google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect 38 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240509183442-62759503f434 // indirect 39 | ) 40 | 41 | require ( 42 | cloud.google.com/go/compute/metadata v0.3.0 // indirect 43 | cloud.google.com/go/iam v1.1.8 // indirect 44 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect 45 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect 46 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect 47 | github.com/aws/aws-sdk-go-v2/config v1.27.16 48 | github.com/aws/aws-sdk-go-v2/credentials v1.17.16 // indirect 49 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 // indirect 50 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 // indirect 51 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 // indirect 52 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect 53 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 // indirect 54 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.9 // indirect 55 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3 // indirect 56 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.10 // indirect 57 | github.com/aws/smithy-go v1.20.2 // indirect 58 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 59 | github.com/golang/protobuf v1.5.4 // indirect 60 | github.com/google/s2a-go v0.1.7 // indirect 61 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 62 | github.com/googleapis/gax-go/v2 v2.12.4 // indirect 63 | github.com/hashicorp/errwrap v1.1.0 // indirect 64 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 65 | github.com/kylelemons/godebug v1.1.0 // indirect 66 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 67 | github.com/spf13/pflag v1.0.5 // indirect 68 | go.opencensus.io v0.24.0 // indirect 69 | golang.org/x/crypto v0.23.0 // indirect 70 | golang.org/x/oauth2 v0.20.0 // indirect 71 | golang.org/x/sys v0.20.0 // indirect 72 | golang.org/x/text v0.15.0 // indirect 73 | google.golang.org/api v0.180.0 // indirect 74 | google.golang.org/grpc v1.63.2 // indirect 75 | google.golang.org/protobuf v1.34.1 // indirect 76 | gopkg.in/yaml.v2 v2.4.0 // indirect 77 | ) 78 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw= 3 | cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= 4 | cloud.google.com/go/auth v0.4.1 h1:Z7YNIhlWRtrnKlZke7z3GMqzvuYzdc2z98F9D1NV5Hg= 5 | cloud.google.com/go/auth v0.4.1/go.mod h1:QVBuVEKpCn4Zp58hzRGvL0tjRGU0YqdRTdCHM1IHnro= 6 | cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= 7 | cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= 8 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 9 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 10 | cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0= 11 | cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE= 12 | cloud.google.com/go/secretmanager v1.13.1 h1:TTGo2Vz7ZxYn2QbmuFP7Zo4lDm5VsbzBjDReo3SA5h4= 13 | cloud.google.com/go/secretmanager v1.13.1/go.mod h1:y9Ioh7EHp1aqEKGYXk3BOC+vkhlHm9ujL7bURT4oI/4= 14 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= 15 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= 16 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 h1:FDif4R1+UUR+00q6wquyX90K7A8dN+R5E8GEadoP7sU= 17 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2/go.mod h1:aiYBYui4BJ/BJCAIKs92XiPyQfTaBWqvHujDwKb6CBU= 18 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= 19 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= 20 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 h1:xnO4sFyG8UH2fElBkcqLTOZsAajvKfnSlgBBW8dXYjw= 21 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0/go.mod h1:XD3DIOOVgBCO03OleB1fHjgktVRFxlT++KwKgIOewdM= 22 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= 23 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= 24 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= 25 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 26 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 27 | github.com/aws/aws-sdk-go-v2 v1.27.0 h1:7bZWKoXhzI+mMR/HjdMx8ZCC5+6fY0lS5tr0bbgiLlo= 28 | github.com/aws/aws-sdk-go-v2 v1.27.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= 29 | github.com/aws/aws-sdk-go-v2/config v1.27.16 h1:knpCuH7laFVGYTNd99Ns5t+8PuRjDn4HnnZK48csipM= 30 | github.com/aws/aws-sdk-go-v2/config v1.27.16/go.mod h1:vutqgRhDUktwSge3hrC3nkuirzkJ4E/mLj5GvI0BQas= 31 | github.com/aws/aws-sdk-go-v2/credentials v1.17.16 h1:7d2QxY83uYl0l58ceyiSpxg9bSbStqBC6BeEeHEchwo= 32 | github.com/aws/aws-sdk-go-v2/credentials v1.17.16/go.mod h1:Ae6li/6Yc6eMzysRL2BXlPYvnrLLBg3D11/AmOjw50k= 33 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 h1:dQLK4TjtnlRGb0czOht2CevZ5l6RSyRWAnKeGd7VAFE= 34 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3/go.mod h1:TL79f2P6+8Q7dTsILpiVST+AL9lkF6PPGI167Ny0Cjw= 35 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 h1:lf/8VTF2cM+N4SLzaYJERKEWAXq8MOMpZfU6wEPWsPk= 36 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7/go.mod h1:4SjkU7QiqK2M9oozyMzfZ/23LmUY+h3oFqhdeP5OMiI= 37 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 h1:4OYVp0705xu8yjdyoWix0r9wPIRXnIzzOoUpQVHIJ/g= 38 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7/go.mod h1:vd7ESTEvI76T2Na050gODNmNU7+OyKrIKroYTu4ABiI= 39 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= 40 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= 41 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= 42 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= 43 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 h1:Wx0rlZoEJR7JwlSZcHnEa7CNjrSIyVxMFWGAaXy4fJY= 44 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9/go.mod h1:aVMHdE0aHO3v+f/iw01fmXV/5DbfQ3Bi9nN7nd9bE9Y= 45 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.29.1 h1:NSWsFzdHN41mJ5I/DOFzxgkKSYNHQADHn7Mu+lU/AKw= 46 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.29.1/go.mod h1:5mMk0DgUgaHlcqtN65fNyZI0ZDX3i9Cw+nwq75HKB3U= 47 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.9 h1:aD7AGQhvPuAxlSUfo0CWU7s6FpkbyykMhGYMvlqTjVs= 48 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.9/go.mod h1:c1qtZUWtygI6ZdvKppzCSXsDOq5I4luJPZ0Ud3juFCA= 49 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3 h1:Pav5q3cA260Zqez42T9UhIlsd9QeypszRPwC9LdSSsQ= 50 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3/go.mod h1:9lmoVDVLz/yUZwLaQ676TK02fhCu4+PgRSmMaKR1ozk= 51 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.10 h1:69tpbPED7jKPyzMcrwSvhWcJ9bPnZsZs18NT40JwM0g= 52 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.10/go.mod h1:0Aqn1MnEuitqfsCNyKsdKLhDUOr4txD/g19EfiUqgws= 53 | github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= 54 | github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= 55 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 56 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 57 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 58 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 59 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 60 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 61 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 62 | github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= 63 | github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= 64 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 65 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 66 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 67 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 68 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 69 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 70 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 71 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 72 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 73 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 74 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 75 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 76 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 77 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 78 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 79 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 80 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 81 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 82 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 83 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 84 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 85 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 86 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 87 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 88 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 89 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 90 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 91 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 92 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 93 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 94 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 95 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 96 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 97 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 98 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 99 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 100 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 101 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 102 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 103 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 104 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 105 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 106 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= 107 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 108 | github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= 109 | github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= 110 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 111 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 112 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 113 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 114 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 115 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 116 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 117 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 118 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 119 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 120 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 121 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 122 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 123 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 124 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 125 | github.com/scaleway/scaleway-sdk-go v1.0.0-beta.27 h1:yGAraK1uUjlhSXgNMIy8o/J4LFNcy7yeipBqt9N9mVg= 126 | github.com/scaleway/scaleway-sdk-go v1.0.0-beta.27/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= 127 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 128 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 129 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 130 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 131 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 132 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 133 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 134 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 135 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 136 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 137 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 138 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 139 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 140 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 141 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= 142 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= 143 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 144 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 145 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 146 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 147 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 148 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 149 | go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= 150 | go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= 151 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 152 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 153 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 154 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 155 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 156 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 157 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 158 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 159 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 160 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 161 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 162 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 163 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 164 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 165 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 166 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 167 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 168 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 169 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 170 | golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= 171 | golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 172 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 173 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 174 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 175 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 176 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 177 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 178 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 179 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 180 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 181 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 183 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 184 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 185 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 186 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 187 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 188 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 189 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 190 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 191 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 192 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 193 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 194 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 195 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 196 | google.golang.org/api v0.180.0 h1:M2D87Yo0rGBPWpo1orwfCLehUUL6E7/TYe5gvMQWDh4= 197 | google.golang.org/api v0.180.0/go.mod h1:51AiyoEg1MJPSZ9zvklA8VnRILPXxn1iVen9v25XHAE= 198 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 199 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 200 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 201 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 202 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 203 | google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= 204 | google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda/go.mod h1:g2LLCvCeCSir/JJSWosk19BR4NVxGqHUC6rxIRsd7Aw= 205 | google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No= 206 | google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8/go.mod h1:vPrPUTsDCYxXWjP7clS81mZ6/803D8K4iM9Ma27VKas= 207 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240509183442-62759503f434 h1:umK/Ey0QEzurTNlsV3R+MfxHAb78HCEX/IkuR+zH4WQ= 208 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240509183442-62759503f434/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM= 209 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 210 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 211 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 212 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 213 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 214 | google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= 215 | google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= 216 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 217 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 218 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 219 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 220 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 221 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 222 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 223 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 224 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 225 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 226 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 227 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 228 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 229 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 230 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 231 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 232 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 233 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 234 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 235 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 236 | k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= 237 | k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= 238 | -------------------------------------------------------------------------------- /internal/cmd/murmur.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func Execute() { 10 | if err := rootCmd().Execute(); err != nil { 11 | os.Exit(1) 12 | } 13 | } 14 | 15 | func rootCmd() *cobra.Command { 16 | cmd := &cobra.Command{ 17 | Use: "murmur", 18 | Short: "Murmur passes secrets as environment variables to a process", 19 | Long: `A plug-and-play shim that fetches secrets from a secure 20 | location and passes them to your application as environment variables.`, 21 | SilenceUsage: true, 22 | } 23 | 24 | cmd.AddCommand(runCmd()) 25 | cmd.AddCommand(execCmd()) // Deprecated 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /internal/cmd/murmur_exec.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func execCmd() *cobra.Command { 8 | cmd := &cobra.Command{ 9 | Use: "exec -- command [args...]", 10 | Args: cobra.MinimumNArgs(1), 11 | 12 | DisableFlagsInUseLine: true, 13 | 14 | Deprecated: "command \"run\" has the same behavior.", 15 | 16 | RunE: runCmd().RunE, 17 | } 18 | 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /internal/cmd/murmur_run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/busser/murmur/internal/murmur" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func runCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "run -- command [args...]", 13 | Args: cobra.MinimumNArgs(1), 14 | 15 | DisableFlagsInUseLine: true, 16 | 17 | Short: "Run a command with secrets injected into its environment variables", 18 | 19 | Example: ` # Fetch a database password from Scaleway Secret Manager: 20 | export PGPASSWORD="scwsm:database-password" 21 | murmur run -- psql -h 10.1.12.34 -U my-user -d my-database 22 | 23 | # Build a connection string from a JSON secret: 24 | export PGDATABASE="scwsm:database-credentials|jsonpath:{.username}:{password}@{.host}:{.port}/{.database}" 25 | murmur run -- psql`, 26 | 27 | RunE: func(cmd *cobra.Command, args []string) error { 28 | exitCode, err := murmur.Run(args[0], args[1:]...) 29 | if err != nil { 30 | return err 31 | } 32 | os.Exit(exitCode) 33 | return nil 34 | }, 35 | } 36 | 37 | return cmd 38 | } 39 | -------------------------------------------------------------------------------- /internal/environ/environ.go: -------------------------------------------------------------------------------- 1 | package environ 2 | 3 | import "strings" 4 | 5 | // ToMap takes strings in the form "key=value", as output by os.Environ, and 6 | // returns a corresponding map. It panics if any of the strings are in the wrong 7 | // format. 8 | func ToMap(env []string) map[string]string { 9 | m := make(map[string]string) 10 | 11 | for _, e := range env { 12 | pair := strings.SplitN(e, "=", 2) 13 | if len(pair) < 2 { 14 | panic("strings not in the form \"key=value\"") 15 | } 16 | k, v := pair[0], pair[1] 17 | m[k] = v 18 | } 19 | 20 | return m 21 | } 22 | 23 | // ToSlice returns a list of strings in the form "key=value", just like 24 | // os.Environ. 25 | func ToSlice(env map[string]string) []string { 26 | var s []string 27 | 28 | for k, v := range env { 29 | s = append(s, k+"="+v) 30 | } 31 | 32 | return s 33 | } 34 | -------------------------------------------------------------------------------- /internal/environ/environ_test.go: -------------------------------------------------------------------------------- 1 | package environ 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | func TestToMap(t *testing.T) { 11 | tt := []struct { 12 | env []string 13 | want map[string]string 14 | }{ 15 | { 16 | env: []string{"a=b", "c=d"}, 17 | want: map[string]string{"a": "b", "c": "d"}, 18 | }, 19 | } 20 | 21 | for _, tc := range tt { 22 | actual := ToMap(tc.env) 23 | if diff := cmp.Diff(tc.want, actual); diff != "" { 24 | t.Errorf("ToMap() mismatch (-want +got):\n%s", diff) 25 | } 26 | } 27 | } 28 | 29 | func TestToSlice(t *testing.T) { 30 | tt := []struct { 31 | env map[string]string 32 | want []string 33 | }{ 34 | { 35 | env: map[string]string{"a": "b", "c": "d"}, 36 | want: []string{"a=b", "c=d"}, 37 | }, 38 | } 39 | 40 | for _, tc := range tt { 41 | actual := ToSlice(tc.env) 42 | sort.Strings(tc.want) 43 | sort.Strings(actual) 44 | if diff := cmp.Diff(tc.want, actual); diff != "" { 45 | t.Errorf("ToMap() mismatch (-want +got):\n%s", diff) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/environ/example_test.go: -------------------------------------------------------------------------------- 1 | package environ_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/busser/murmur/internal/environ" 8 | ) 9 | 10 | func Example() { 11 | os.Setenv("foo", "bar") 12 | 13 | envMap := environ.ToMap(os.Environ()) 14 | 15 | fmt.Println(envMap["foo"]) 16 | 17 | // Output: 18 | // bar 19 | } 20 | -------------------------------------------------------------------------------- /internal/murmur/filter.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import "github.com/busser/murmur/internal/murmur/filters/jsonpath" 4 | 5 | // A Filter transforms a value obtained from a secret store into another value 6 | // based on the given rule. 7 | type Filter func(value, rule string) (string, error) 8 | 9 | var Filters = map[string]Filter{ 10 | // Kubernetes JSONPath templating. 11 | "jsonpath": jsonpath.Filter, 12 | } 13 | -------------------------------------------------------------------------------- /internal/murmur/filters/jsonpath/filter.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "k8s.io/client-go/util/jsonpath" 9 | ) 10 | 11 | // Filter renders the given template with the given value. Filter returns an 12 | // error if the value is not valid JSON or if the template is invalid. 13 | // This function uses Kubernetes' JSONPath syntax as documented here: 14 | // https://kubernetes.io/docs/reference/kubectl/jsonpath/. 15 | func Filter(value, template string) (string, error) { 16 | tmpl := jsonpath.New("filter") 17 | 18 | if err := tmpl.Parse(template); err != nil { 19 | return "", fmt.Errorf("invalid jsonpath template: %w", err) 20 | } 21 | 22 | var parsedValue any 23 | if err := json.Unmarshal([]byte(value), &parsedValue); err != nil { 24 | // If the value is not valid JSON, we can still use it as a string. 25 | parsedValue = value 26 | } 27 | 28 | var buf bytes.Buffer 29 | if err := tmpl.Execute(&buf, parsedValue); err != nil { 30 | return "", fmt.Errorf("could not render template: %w", err) 31 | } 32 | 33 | return buf.String(), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/murmur/filters/jsonpath/filter_test.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFilter(t *testing.T) { 8 | tt := []struct { 9 | name string 10 | value string 11 | template string 12 | want string 13 | wantErr bool 14 | }{ 15 | { 16 | name: "static template", 17 | value: `{"foo": "bar"}`, 18 | template: "hello", 19 | want: "hello", 20 | }, 21 | { 22 | name: "single value", 23 | value: `{"foo": "bar"}`, 24 | template: "foo={ .foo }", 25 | want: "foo=bar", 26 | }, 27 | { 28 | name: "nested value", 29 | value: `{"foo": {"bar": "baz"}}`, 30 | template: "foobar={ .foo.bar }", 31 | want: "foobar=baz", 32 | }, 33 | { 34 | name: "integer value", 35 | value: `{"port": 5432}`, 36 | template: "port={ .port }", 37 | want: "port=5432", 38 | }, 39 | { 40 | name: "not json", 41 | value: "hello", 42 | template: "the value is { @ }", 43 | want: "the value is hello", 44 | }, 45 | { 46 | name: "missing value", 47 | value: `{"foo": "bar"}`, 48 | template: "{ .missing }", 49 | wantErr: true, 50 | }, 51 | { 52 | name: "empty template", 53 | value: `{"foo": "bar"}`, 54 | template: "", 55 | want: "", 56 | }, 57 | { 58 | name: "invalid template", 59 | value: `{"foo": "bar"}`, 60 | template: "{ .not_closed", 61 | wantErr: true, 62 | }, 63 | } 64 | 65 | for _, tc := range tt { 66 | t.Run(tc.name, func(t *testing.T) { 67 | 68 | actual, err := Filter(tc.value, tc.template) 69 | 70 | if err != nil && !tc.wantErr { 71 | t.Errorf("Filter() returned an error: %v", err) 72 | } 73 | if err == nil && tc.wantErr { 74 | t.Error("Filter() did not return an error") 75 | } 76 | 77 | if actual != tc.want { 78 | t.Errorf("Filter() returned %q, want %q", actual, tc.want) 79 | } 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/murmur/provider.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/busser/murmur/internal/murmur/providers/awssm" 7 | "github.com/busser/murmur/internal/murmur/providers/azkv" 8 | "github.com/busser/murmur/internal/murmur/providers/gcpsm" 9 | "github.com/busser/murmur/internal/murmur/providers/passthrough" 10 | "github.com/busser/murmur/internal/murmur/providers/scwsm" 11 | ) 12 | 13 | // A Provider fetches values from a secret store. 14 | type Provider interface { 15 | // Resolve returns the value of the secret with the given ref. Resolve never 16 | // gets called after Close. 17 | Resolve(ctx context.Context, ref string) (string, error) 18 | 19 | // Close signals to the provider that it can release any resources it has 20 | // allocated, like network connections. Close should return once those 21 | // resources are released. 22 | Close() error 23 | } 24 | 25 | // A ProviderFactory returns a new Provider. 26 | type ProviderFactory func() (Provider, error) 27 | 28 | // ProviderFactories contains a ProviderFactory for each prefix known to 29 | // murmur. 30 | var ProviderFactories = map[string]ProviderFactory{ 31 | // Passthrough 32 | "passthrough": func() (Provider, error) { return passthrough.New() }, 33 | // Azure Key Vault 34 | "azkv": func() (Provider, error) { return azkv.New() }, 35 | // Google Cloud Secret Manager 36 | "gcpsm": func() (Provider, error) { return gcpsm.New() }, 37 | // AWS Secrets Manager 38 | "awssm": func() (Provider, error) { return awssm.New() }, 39 | // Scaleway Secret Manager 40 | "scwsm": func() (Provider, error) { return scwsm.New() }, 41 | } 42 | -------------------------------------------------------------------------------- /internal/murmur/providers/awssm/client.go: -------------------------------------------------------------------------------- 1 | package awssm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/service/secretsmanager" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | type client struct { 16 | awsClient *secretsmanager.Client 17 | } 18 | 19 | // New returns a client that fetches secrets from AWS Secrets Manager. 20 | func New() (*client, error) { 21 | cfg, err := config.LoadDefaultConfig(context.TODO()) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to load AWS config: %w", err) 24 | } 25 | 26 | c := secretsmanager.NewFromConfig(cfg) 27 | 28 | return &client{ 29 | awsClient: c, 30 | }, nil 31 | } 32 | 33 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 34 | secretID, versionID, versionStage, err := parseRef(ref) 35 | if err != nil { 36 | return "", fmt.Errorf("invalid reference: %w", err) 37 | } 38 | 39 | req := &secretsmanager.GetSecretValueInput{ 40 | SecretId: aws.String(secretID), 41 | } 42 | if versionID != "" { 43 | req.VersionId = aws.String(versionID) 44 | } 45 | if versionStage != "" { 46 | req.VersionStage = aws.String(versionStage) 47 | } 48 | 49 | resp, err := c.awsClient.GetSecretValue(ctx, req) 50 | if err != nil { 51 | return "", fmt.Errorf("failed to get secret %q version ID: %q version stage: %q): %w", secretID, versionID, versionStage, err) 52 | } 53 | 54 | if resp.SecretString != nil { 55 | return *resp.SecretString, nil 56 | } 57 | return string(resp.SecretBinary), nil 58 | } 59 | 60 | func (c *client) Close() error { 61 | // The client does not need to close its underlying AWS client. 62 | // ?(busser): are we sure about this? do any connections need to be closed? 63 | return nil 64 | } 65 | 66 | func parseRef(ref string) (secretID, versionID, versionStage string, err error) { 67 | refParts := strings.SplitN(ref, "#", 2) 68 | if len(refParts) < 1 { 69 | return "", "", "", errors.New("invalid syntax") 70 | } 71 | secretID = refParts[0] 72 | 73 | if len(refParts) < 2 { 74 | return secretID, "", "AWSCURRENT", nil 75 | } 76 | 77 | rawVersion := refParts[1] 78 | switch { 79 | case rawVersion == "": 80 | return secretID, "", "AWSCURRENT", nil 81 | case isUUID(rawVersion): 82 | return secretID, rawVersion, "", nil 83 | default: 84 | return secretID, "", rawVersion, nil 85 | } 86 | } 87 | 88 | func isUUID(s string) bool { 89 | _, err := uuid.Parse(s) 90 | return err == nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/murmur/providers/awssm/client_test.go: -------------------------------------------------------------------------------- 1 | package awssm_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/busser/murmur/internal/murmur/providers/awssm" 9 | ) 10 | 11 | func Example() { 12 | c, err := awssm.New() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | ref := "secret-sauce" 18 | val, err := c.Resolve(context.Background(), ref) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | fmt.Println("The secret sauce is", val) 24 | } 25 | -------------------------------------------------------------------------------- /internal/murmur/providers/awssm/e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package awssm_test 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/busser/murmur/internal/murmur/providers/awssm" 11 | ) 12 | 13 | func TestClient(t *testing.T) { 14 | 15 | // The secrets this test reads were created with Terraform. The code is in 16 | // the terraform/layers/aws-secrets-manager directory of this repository. 17 | 18 | client, err := awssm.New() 19 | if err != nil { 20 | t.Fatalf("New() returned an error: %v", err) 21 | } 22 | 23 | tt := []struct { 24 | ref string 25 | wantVal string 26 | wantErr bool 27 | }{ 28 | // References by name. 29 | { 30 | ref: "secret-sauce", 31 | wantVal: "szechuan", 32 | wantErr: false, 33 | }, 34 | { 35 | ref: "secret-sauce#AWSCURRENT", 36 | wantVal: "szechuan", 37 | wantErr: false, 38 | }, 39 | { 40 | ref: "secret-sauce#9AF93B18-59D6-4C19-92AC-3F69A115D404", 41 | wantVal: "szechuan", 42 | wantErr: false, 43 | }, 44 | { 45 | ref: "secret-sauce#v2", 46 | wantVal: "szechuan", 47 | wantErr: false, 48 | }, 49 | { 50 | ref: "secret-sauce#97DD35A4-DD9B-4E4B-B371-9F2CA4673A41", 51 | wantVal: "ketchup", 52 | wantErr: false, 53 | }, 54 | { 55 | ref: "secret-sauce#v1", 56 | wantVal: "ketchup", 57 | wantErr: false, 58 | }, 59 | { 60 | ref: "does-not-exist", 61 | wantVal: "", 62 | wantErr: true, 63 | }, 64 | 65 | // References by ARN. 66 | { 67 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ", 68 | wantVal: "szechuan", 69 | wantErr: false, 70 | }, 71 | { 72 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ#AWSCURRENT", 73 | wantVal: "szechuan", 74 | wantErr: false, 75 | }, 76 | { 77 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ#9AF93B18-59D6-4C19-92AC-3F69A115D404", 78 | wantVal: "szechuan", 79 | wantErr: false, 80 | }, 81 | { 82 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ#v2", 83 | wantVal: "szechuan", 84 | wantErr: false, 85 | }, 86 | { 87 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ#97DD35A4-DD9B-4E4B-B371-9F2CA4673A41", 88 | wantVal: "ketchup", 89 | wantErr: false, 90 | }, 91 | { 92 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:secret-sauce-sWcbiZ#v1", 93 | wantVal: "ketchup", 94 | wantErr: false, 95 | }, 96 | { 97 | ref: "arn:aws:secretsmanager:eu-west-3:531255069405:secret:does-not-exist", 98 | wantVal: "", 99 | wantErr: true, 100 | }, 101 | } 102 | 103 | // Test cases are grouped such that they run in parallel and we can perform 104 | // cleanup once they are done. 105 | t.Run("group", func(t *testing.T) { 106 | 107 | for _, tc := range tt { 108 | tc := tc // capture range variable 109 | t.Run(tc.ref, func(t *testing.T) { 110 | t.Parallel() 111 | 112 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 113 | defer cancel() 114 | 115 | actualVal, err := client.Resolve(ctx, tc.ref) 116 | if err != nil && !tc.wantErr { 117 | t.Errorf("Resolve() returned an error: %v", err) 118 | } 119 | if err == nil && tc.wantErr { 120 | t.Error("Resolve() did not return an error") 121 | } 122 | if actualVal != tc.wantVal { 123 | t.Errorf("Resolve() == %#v, want %#v", actualVal, tc.wantVal) 124 | } 125 | }) 126 | } 127 | 128 | }) 129 | 130 | if err := client.Close(); err != nil { 131 | t.Fatalf("Close() returned an error: %v", err) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /internal/murmur/providers/azkv/client.go: -------------------------------------------------------------------------------- 1 | package azkv 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/Azure/azure-sdk-for-go/sdk/azcore" 11 | "github.com/Azure/azure-sdk-for-go/sdk/azidentity" 12 | "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" 13 | ) 14 | 15 | type client struct { 16 | credential azcore.TokenCredential 17 | 18 | mu sync.RWMutex // Protects keyvaultClients 19 | vaultClients map[string]*azsecrets.Client 20 | } 21 | 22 | // New returns a client that fetches secrets from Azure Key Vault. 23 | func New() (*client, error) { 24 | cred, err := azidentity.NewDefaultAzureCredential(nil) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to obtain a credential: %w", err) 27 | } 28 | 29 | return &client{ 30 | credential: cred, 31 | vaultClients: make(map[string]*azsecrets.Client), 32 | }, nil 33 | } 34 | 35 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 36 | vault, name, version, err := parseRef(ref) 37 | if err != nil { 38 | return "", fmt.Errorf("invalid reference: %w", err) 39 | } 40 | 41 | if err := c.createClientIfMissing(vault); err != nil { 42 | return "", fmt.Errorf("failed to create client for vault %q: %w", vault, err) 43 | } 44 | 45 | c.mu.RLock() 46 | defer c.mu.RUnlock() 47 | 48 | // An empty string version gets the latest version of the secret. 49 | resp, err := c.vaultClients[vault].GetSecret(ctx, name, version, nil) 50 | if err != nil { 51 | return "", fmt.Errorf("failed to get secret %q version %q: %w", name, version, err) 52 | } 53 | 54 | return *resp.Value, nil 55 | } 56 | 57 | func (c *client) Close() error { 58 | // The client does not need to close its underlying Azure clients. 59 | // ?(busser): are we sure about this? do any connections need to be closed? 60 | return nil 61 | } 62 | 63 | func (c *client) createClientIfMissing(vault string) error { 64 | c.mu.Lock() 65 | defer c.mu.Unlock() 66 | 67 | if c.vaultClients[vault] != nil { 68 | return nil 69 | } 70 | 71 | vaultURL := fmt.Sprintf("https://%s/", vault) 72 | azClient, err := azsecrets.NewClient(vaultURL, c.credential, nil) 73 | if err != nil { 74 | return fmt.Errorf("client init: %w", err) 75 | } 76 | 77 | c.vaultClients[vault] = azClient 78 | return nil 79 | } 80 | 81 | func parseRef(ref string) (vaultURL, name, version string, err error) { 82 | refParts := strings.SplitN(ref, "#", 2) 83 | if len(refParts) < 1 { 84 | return "", "", "", errors.New("invalid syntax") 85 | } 86 | fullname := refParts[0] 87 | version = "" 88 | if len(refParts) == 2 { 89 | version = refParts[1] 90 | } 91 | 92 | fullnameParts := strings.SplitN(fullname, "/", 2) 93 | if len(fullnameParts) < 2 { 94 | return "", "", "", errors.New("invalid syntax") 95 | } 96 | vaultURL = fullnameParts[0] 97 | name = fullnameParts[1] 98 | 99 | return vaultURL, name, version, nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/murmur/providers/azkv/client_test.go: -------------------------------------------------------------------------------- 1 | package azkv_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/busser/murmur/internal/murmur/providers/azkv" 9 | ) 10 | 11 | func Example() { 12 | c, err := azkv.New() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | ref := "example.vault.azure.net/secret-sauce" 18 | val, err := c.Resolve(context.Background(), ref) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | fmt.Println("The secret sauce is", val) 24 | } 25 | -------------------------------------------------------------------------------- /internal/murmur/providers/azkv/e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package azkv_test 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/busser/murmur/internal/murmur/providers/azkv" 11 | ) 12 | 13 | func TestClient(t *testing.T) { 14 | 15 | // The secrets this test reads were created with Terraform. The code is in 16 | // the terraform/layers/azure-keyvault directory of this repository. 17 | 18 | client, err := azkv.New() 19 | if err != nil { 20 | t.Fatalf("New() returned an error: %v", err) 21 | } 22 | 23 | tt := []struct { 24 | ref string 25 | wantVal string 26 | wantErr bool 27 | }{ 28 | // References to the "alpha" vault. 29 | { 30 | ref: "murmur-alpha.vault.azure.net/secret-sauce", 31 | wantVal: "szechuan", 32 | wantErr: false, 33 | }, 34 | { 35 | ref: "murmur-alpha.vault.azure.net/secret-sauce#788ffd5cd2224f67b98e12f6fc0cd720", 36 | wantVal: "szechuan", 37 | wantErr: false, 38 | }, 39 | { 40 | ref: "murmur-alpha.vault.azure.net/secret-sauce#02fc2105c6b34f8385a2ee8531e4900f", 41 | wantVal: "ketchup", 42 | wantErr: false, 43 | }, 44 | { 45 | ref: "murmur-alpha.vault.azure.net/does-not-exist", 46 | wantVal: "", 47 | wantErr: true, 48 | }, 49 | 50 | // References to the "bravo" vault. 51 | { 52 | ref: "murmur-bravo.vault.azure.net/secret-sauce", 53 | wantVal: "szechuan", 54 | wantErr: false, 55 | }, 56 | { 57 | ref: "murmur-bravo.vault.azure.net/secret-sauce#48b0d307869b4cf9a0141a062ecdc648", 58 | wantVal: "szechuan", 59 | wantErr: false, 60 | }, 61 | { 62 | ref: "murmur-bravo.vault.azure.net/secret-sauce#e34b3d09f61f4ed1a1812b88834bcb3e", 63 | wantVal: "ketchup", 64 | wantErr: false, 65 | }, 66 | { 67 | ref: "murmur-bravo.vault.azure.net/does-not-exist", 68 | wantVal: "", 69 | wantErr: true, 70 | }, 71 | 72 | // Other references. 73 | { 74 | ref: "invalid-ref", 75 | wantVal: "", 76 | wantErr: true, 77 | }, 78 | } 79 | 80 | // Test cases are grouped such that they run in parallel and we can perform 81 | // cleanup once they are done. 82 | t.Run("group", func(t *testing.T) { 83 | 84 | for _, tc := range tt { 85 | tc := tc // capture range variable 86 | t.Run(tc.ref, func(t *testing.T) { 87 | t.Parallel() 88 | 89 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 90 | defer cancel() 91 | 92 | actualVal, err := client.Resolve(ctx, tc.ref) 93 | if err != nil && !tc.wantErr { 94 | t.Errorf("Resolve() returned an error: %v", err) 95 | } 96 | if err == nil && tc.wantErr { 97 | t.Error("Resolve() did not return an error") 98 | } 99 | if actualVal != tc.wantVal { 100 | t.Errorf("Resolve() == %#v, want %#v", actualVal, tc.wantVal) 101 | } 102 | }) 103 | } 104 | 105 | }) 106 | 107 | if err := client.Close(); err != nil { 108 | t.Fatalf("Close() returned an error: %v", err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/murmur/providers/gcpsm/client.go: -------------------------------------------------------------------------------- 1 | package gcpsm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | secretmanager "cloud.google.com/go/secretmanager/apiv1" 10 | "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" 11 | ) 12 | 13 | type client struct { 14 | gcpClient *secretmanager.Client 15 | } 16 | 17 | // New returns a client that fetches secrets from Google Secret Manager. 18 | func New() (*client, error) { 19 | c, err := secretmanager.NewClient(context.TODO()) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to setup client: %w", err) 22 | } 23 | 24 | return &client{ 25 | gcpClient: c, 26 | }, nil 27 | } 28 | 29 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 30 | project, name, version, err := parseRef(ref) 31 | if err != nil { 32 | return "", fmt.Errorf("invalid reference: %w", err) 33 | } 34 | 35 | req := &secretmanagerpb.AccessSecretVersionRequest{ 36 | Name: "projects/" + project + "/secrets/" + name + "/versions/" + version, 37 | } 38 | resp, err := c.gcpClient.AccessSecretVersion(ctx, req) 39 | if err != nil { 40 | return "", fmt.Errorf("failed to access secret %q version %q: %v", name, version, err) 41 | } 42 | 43 | return string(resp.Payload.Data), nil 44 | } 45 | 46 | func (c *client) Close() error { 47 | return c.gcpClient.Close() 48 | } 49 | 50 | func parseRef(ref string) (project, name, version string, err error) { 51 | refParts := strings.SplitN(ref, "#", 2) 52 | if len(refParts) < 1 { 53 | return "", "", "", errors.New("invalid syntax") 54 | } 55 | fullname := refParts[0] 56 | version = "latest" 57 | if len(refParts) == 2 { 58 | version = refParts[1] 59 | } 60 | 61 | fullnameParts := strings.SplitN(fullname, "/", 2) 62 | if len(fullnameParts) < 2 { 63 | return "", "", "", errors.New("invalid syntax") 64 | } 65 | project = fullnameParts[0] 66 | name = fullnameParts[1] 67 | 68 | return project, name, version, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/murmur/providers/gcpsm/client_test.go: -------------------------------------------------------------------------------- 1 | package gcpsm_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/busser/murmur/internal/murmur/providers/gcpsm" 9 | ) 10 | 11 | func Example() { 12 | c, err := gcpsm.New() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | ref := "example-project/secret-sauce" 18 | val, err := c.Resolve(context.Background(), ref) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | fmt.Println("The secret sauce is", val) 24 | } 25 | -------------------------------------------------------------------------------- /internal/murmur/providers/gcpsm/e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package gcpsm_test 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/busser/murmur/internal/murmur/providers/gcpsm" 11 | ) 12 | 13 | func TestClient(t *testing.T) { 14 | 15 | // The secrets this test reads were created with Terraform. The code is in 16 | // the terraform/layers/gcp-secret-manager directory of this repository. 17 | 18 | client, err := gcpsm.New() 19 | if err != nil { 20 | t.Fatalf("New() returned an error: %v", err) 21 | } 22 | 23 | tt := []struct { 24 | ref string 25 | wantVal string 26 | wantErr bool 27 | }{ 28 | { 29 | ref: "murmur-tests/secret-sauce", 30 | wantVal: "szechuan", 31 | wantErr: false, 32 | }, 33 | { 34 | ref: "murmur-tests/secret-sauce#2", 35 | wantVal: "szechuan", 36 | wantErr: false, 37 | }, 38 | { 39 | ref: "murmur-tests/secret-sauce#1", 40 | wantVal: "ketchup", 41 | wantErr: false, 42 | }, 43 | { 44 | ref: "murmur-tests/does-not-exist", 45 | wantVal: "", 46 | wantErr: true, 47 | }, 48 | { 49 | ref: "invalid-ref", 50 | wantVal: "", 51 | wantErr: true, 52 | }, 53 | } 54 | 55 | // Test cases are grouped such that they run in parallel and we can perform 56 | // cleanup once they are done. 57 | t.Run("group", func(t *testing.T) { 58 | 59 | for _, tc := range tt { 60 | tc := tc // capture range variable 61 | t.Run(tc.ref, func(t *testing.T) { 62 | t.Parallel() 63 | 64 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 65 | defer cancel() 66 | 67 | actualVal, err := client.Resolve(ctx, tc.ref) 68 | if err != nil && !tc.wantErr { 69 | t.Errorf("Resolve() returned an error: %v", err) 70 | } 71 | if err == nil && tc.wantErr { 72 | t.Error("Resolve() did not return an error") 73 | } 74 | if actualVal != tc.wantVal { 75 | t.Errorf("Resolve() == %#v, want %#v", actualVal, tc.wantVal) 76 | } 77 | }) 78 | } 79 | 80 | }) 81 | 82 | if err := client.Close(); err != nil { 83 | t.Fatalf("Close() returned an error: %v", err) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/murmur/providers/jsonmock/client.go: -------------------------------------------------------------------------------- 1 | package jsonmock 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "sync" 9 | ) 10 | 11 | type client struct { 12 | mu sync.RWMutex 13 | resolvedRefs []string 14 | closed bool 15 | } 16 | 17 | // New returns a client useful for testing, which provides JSON-encoded values. 18 | func New() *client { 19 | return new(client) 20 | } 21 | 22 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 23 | c.mu.Lock() 24 | defer c.mu.Unlock() 25 | 26 | c.resolvedRefs = append(c.resolvedRefs, ref) 27 | 28 | if ref == "FAIL" { 29 | return "", ErrorFor(ref) 30 | } 31 | 32 | return ValueFor(ref), nil 33 | } 34 | 35 | func (c *client) Close() error { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | 39 | if c.closed { 40 | return errors.New("already closed") 41 | } 42 | 43 | c.closed = true 44 | 45 | return nil 46 | } 47 | 48 | func (c *client) Closed() bool { 49 | c.mu.RLock() 50 | defer c.mu.RUnlock() 51 | 52 | return c.closed 53 | } 54 | 55 | func (c *client) ResolvedRefs() []string { 56 | c.mu.RLock() 57 | defer c.mu.RUnlock() 58 | 59 | return c.resolvedRefs 60 | } 61 | 62 | const Key = "ref" 63 | 64 | func ValueFor(ref string) string { 65 | obj := map[string]string{ 66 | Key: ref, 67 | } 68 | 69 | encoded, err := json.Marshal(obj) 70 | if err != nil { 71 | panic(fmt.Sprintf("could not encode with ref %q", ref)) 72 | } 73 | 74 | return string(encoded) 75 | } 76 | 77 | func ErrorFor(ref string) error { 78 | return fmt.Errorf("ref %q triggered failure", ref) 79 | } 80 | -------------------------------------------------------------------------------- /internal/murmur/providers/mock/client.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | ) 9 | 10 | type client struct { 11 | mu sync.RWMutex 12 | resolvedRefs []string 13 | closed bool 14 | } 15 | 16 | // New returns a client useful for testing, which provides deterministic values. 17 | func New() *client { 18 | return new(client) 19 | } 20 | 21 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 22 | c.mu.Lock() 23 | defer c.mu.Unlock() 24 | 25 | c.resolvedRefs = append(c.resolvedRefs, ref) 26 | 27 | if ref == "FAIL" { 28 | return "", ErrorFor(ref) 29 | } 30 | 31 | return ValueFor(ref), nil 32 | } 33 | 34 | func (c *client) Close() error { 35 | c.mu.Lock() 36 | defer c.mu.Unlock() 37 | 38 | if c.closed { 39 | return errors.New("already closed") 40 | } 41 | 42 | c.closed = true 43 | 44 | return nil 45 | } 46 | 47 | func (c *client) Closed() bool { 48 | c.mu.RLock() 49 | defer c.mu.RUnlock() 50 | 51 | return c.closed 52 | } 53 | 54 | func (c *client) ResolvedRefs() []string { 55 | c.mu.RLock() 56 | defer c.mu.RUnlock() 57 | 58 | return c.resolvedRefs 59 | } 60 | 61 | func ValueFor(ref string) string { 62 | return fmt.Sprintf("mock value for ref %q", ref) 63 | } 64 | 65 | func ErrorFor(ref string) error { 66 | return fmt.Errorf("ref %q triggered failure", ref) 67 | } 68 | -------------------------------------------------------------------------------- /internal/murmur/providers/passthrough/client.go: -------------------------------------------------------------------------------- 1 | package passthrough 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type client struct{} 8 | 9 | // New returns a client that fetches no secrets, and simply uses the secret's 10 | // reference as its value. 11 | func New() (*client, error) { 12 | return &client{}, nil 13 | } 14 | 15 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 16 | return ref, nil 17 | } 18 | 19 | func (c *client) Close() error { 20 | // The client has no resources to free. 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/murmur/providers/passthrough/client_test.go: -------------------------------------------------------------------------------- 1 | package passthrough 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestClient(t *testing.T) { 9 | tt := []struct { 10 | ref string 11 | }{ 12 | {"a"}, 13 | {"b"}, 14 | {"c"}, 15 | {"abc"}, 16 | } 17 | 18 | for _, tc := range tt { 19 | t.Run(tc.ref, func(t *testing.T) { 20 | c, err := New() 21 | if err != nil { 22 | t.Fatalf("New() returned an error: %v", err) 23 | } 24 | defer c.Close() 25 | 26 | val, err := c.Resolve(context.Background(), tc.ref) 27 | if err != nil { 28 | t.Fatalf("Resolve() returned an error: %v", err) 29 | } 30 | if val != tc.ref { 31 | t.Errorf("Resolve(%#v) == %#v, want %#v", tc.ref, val, tc.ref) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/murmur/providers/scwsm/client.go: -------------------------------------------------------------------------------- 1 | package scwsm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/google/uuid" 10 | scwsecret "github.com/scaleway/scaleway-sdk-go/api/secret/v1alpha1" 11 | "github.com/scaleway/scaleway-sdk-go/scw" 12 | ) 13 | 14 | type client struct { 15 | scwClient *scw.Client 16 | } 17 | 18 | // New returns a client that fetches secrets from Google Secret Manager. 19 | func New() (*client, error) { 20 | profile := loadDefaultProfile() 21 | 22 | c, err := scw.NewClient(scw.WithProfile(profile)) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to setup client: %w", err) 25 | } 26 | 27 | return &client{ 28 | scwClient: c, 29 | }, nil 30 | } 31 | 32 | func (c *client) Resolve(ctx context.Context, ref string) (string, error) { 33 | region, id, name, revision, err := parseRef(ref) 34 | if err != nil { 35 | return "", fmt.Errorf("invalid reference: %w", err) 36 | } 37 | 38 | if id != "" { 39 | return c.resolveByID(ctx, region, id, revision) 40 | } 41 | return c.resolveByName(ctx, region, name, revision) 42 | } 43 | 44 | func loadDefaultProfile() *scw.Profile { 45 | return scw.MergeProfiles(loadConfigProfile(), scw.LoadEnvProfile()) 46 | } 47 | 48 | func loadConfigProfile() *scw.Profile { 49 | config, err := scw.LoadConfig() 50 | if err != nil { 51 | return &scw.Profile{} 52 | } 53 | 54 | profile, err := config.GetActiveProfile() 55 | if err != nil { 56 | return &scw.Profile{} 57 | } 58 | 59 | return profile 60 | } 61 | 62 | func (c *client) resolveByID(ctx context.Context, region scw.Region, id, revision string) (string, error) { 63 | req := &scwsecret.AccessSecretVersionRequest{ 64 | Region: region, 65 | SecretID: id, 66 | Revision: revision, 67 | } 68 | resp, err := scwsecret.NewAPI(c.scwClient).AccessSecretVersion(req) 69 | if err != nil { 70 | return "", fmt.Errorf("failed to access secret (region: %q, id: %q, revision: %q): %w", region, id, revision, err) 71 | } 72 | 73 | return string(resp.Data), nil 74 | } 75 | 76 | func (c *client) resolveByName(ctx context.Context, region scw.Region, name, revision string) (string, error) { 77 | req := &scwsecret.AccessSecretVersionByNameRequest{ 78 | Region: region, 79 | SecretName: name, 80 | Revision: revision, 81 | } 82 | resp, err := scwsecret.NewAPI(c.scwClient).AccessSecretVersionByName(req) 83 | if err != nil { 84 | return "", fmt.Errorf("failed to access secret (region: %q, name: %q, revision: %q): %w", region, name, revision, err) 85 | } 86 | 87 | return string(resp.Data), nil 88 | } 89 | 90 | func (c *client) Close() error { 91 | // No need to close the client. 92 | return nil 93 | } 94 | 95 | const ( 96 | // If the ref contains no region, then we want to use the default region. 97 | // We delegate this to the Scaleway SDK, which will use the default region if 98 | // the region is empty. 99 | defaultRegion scw.Region = "" 100 | 101 | defaultRevision = "latest" 102 | ) 103 | 104 | func parseRef(ref string) (region scw.Region, id, name, revision string, err error) { 105 | refParts := strings.SplitN(ref, "#", 2) 106 | if len(refParts) < 1 { 107 | return "", "", "", "", errors.New("invalid syntax") 108 | } 109 | 110 | revision = defaultRevision 111 | if len(refParts) == 2 { 112 | revision = refParts[1] 113 | } 114 | 115 | fullname := refParts[0] 116 | fullnameParts := strings.SplitN(fullname, "/", 2) 117 | if len(fullnameParts) < 1 { 118 | return "", "", "", "", errors.New("invalid syntax") 119 | } 120 | 121 | region = defaultRegion 122 | idOrName := fullnameParts[0] 123 | if len(fullnameParts) == 2 { 124 | region = scw.Region(fullnameParts[0]) 125 | idOrName = fullnameParts[1] 126 | } 127 | 128 | id, name = extractIDAndName(idOrName) 129 | 130 | return region, id, name, revision, nil 131 | } 132 | 133 | func extractIDAndName(idOrName string) (id, name string) { 134 | if isUUID(idOrName) { 135 | return idOrName, "" 136 | } 137 | return "", idOrName 138 | } 139 | 140 | func isUUID(s string) bool { 141 | _, err := uuid.Parse(s) 142 | return err == nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/murmur/providers/scwsm/client_test.go: -------------------------------------------------------------------------------- 1 | package scwsm_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/busser/murmur/internal/murmur/providers/scwsm" 9 | ) 10 | 11 | func Example() { 12 | c, err := scwsm.New() 13 | if err != nil { 14 | log.Fatal(err) 15 | } 16 | 17 | ref := "fr-par/secret-sauce" 18 | val, err := c.Resolve(context.Background(), ref) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | fmt.Println("The secret sauce is", val) 24 | } 25 | -------------------------------------------------------------------------------- /internal/murmur/providers/scwsm/e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package scwsm_test 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | "github.com/busser/murmur/internal/murmur/providers/scwsm" 11 | ) 12 | 13 | func TestClient(t *testing.T) { 14 | 15 | // The secrets this test reads were created with Terraform. The code is in 16 | // the terraform/layers/scw-secret-manager directory of this repository. 17 | 18 | client, err := scwsm.New() 19 | if err != nil { 20 | t.Fatalf("New() returned an error: %v", err) 21 | } 22 | 23 | tt := []struct { 24 | ref string 25 | wantVal string 26 | wantErr bool 27 | }{ 28 | { 29 | ref: "secret-sauce", 30 | wantVal: "szechuan", 31 | wantErr: false, 32 | }, 33 | { 34 | ref: "secret-sauce#2", 35 | wantVal: "szechuan", 36 | wantErr: false, 37 | }, 38 | { 39 | ref: "secret-sauce#1", 40 | wantVal: "ketchup", 41 | wantErr: false, 42 | }, 43 | { 44 | ref: "fr-par/secret-sauce", 45 | wantVal: "szechuan", 46 | wantErr: false, 47 | }, 48 | { 49 | ref: "fr-par/secret-sauce#2", 50 | wantVal: "szechuan", 51 | wantErr: false, 52 | }, 53 | { 54 | ref: "fr-par/secret-sauce#1", 55 | wantVal: "ketchup", 56 | wantErr: false, 57 | }, 58 | { 59 | ref: "3f34b83f-47a6-4344-bcd4-b63721481cd3", 60 | wantVal: "szechuan", 61 | wantErr: false, 62 | }, 63 | { 64 | ref: "3f34b83f-47a6-4344-bcd4-b63721481cd3#2", 65 | wantVal: "szechuan", 66 | wantErr: false, 67 | }, 68 | { 69 | ref: "3f34b83f-47a6-4344-bcd4-b63721481cd3#1", 70 | wantVal: "ketchup", 71 | wantErr: false, 72 | }, 73 | { 74 | ref: "fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3", 75 | wantVal: "szechuan", 76 | wantErr: false, 77 | }, 78 | { 79 | ref: "fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3#2", 80 | wantVal: "szechuan", 81 | wantErr: false, 82 | }, 83 | { 84 | ref: "fr-par/3f34b83f-47a6-4344-bcd4-b63721481cd3#1", 85 | wantVal: "ketchup", 86 | wantErr: false, 87 | }, 88 | { 89 | ref: "does-not-exist", 90 | wantVal: "", 91 | wantErr: true, 92 | }, 93 | { 94 | ref: "fr-par/does-not-exist", 95 | wantVal: "", 96 | wantErr: true, 97 | }, 98 | { 99 | ref: "fr-par/does-not-exist#123", 100 | wantVal: "", 101 | wantErr: true, 102 | }, 103 | } 104 | 105 | // Test cases are grouped such that they run in parallel and we can perform 106 | // cleanup once they are done. 107 | t.Run("group", func(t *testing.T) { 108 | 109 | for _, tc := range tt { 110 | tc := tc // capture range variable 111 | t.Run(tc.ref, func(t *testing.T) { 112 | t.Parallel() 113 | 114 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 115 | defer cancel() 116 | 117 | actualVal, err := client.Resolve(ctx, tc.ref) 118 | if err != nil && !tc.wantErr { 119 | t.Errorf("Resolve() returned an error: %v", err) 120 | } 121 | if err == nil && tc.wantErr { 122 | t.Error("Resolve() did not return an error") 123 | } 124 | if actualVal != tc.wantVal { 125 | t.Errorf("Resolve() == %#v, want %#v", actualVal, tc.wantVal) 126 | } 127 | }) 128 | } 129 | 130 | }) 131 | 132 | if err := client.Close(); err != nil { 133 | t.Fatalf("Close() returned an error: %v", err) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /internal/murmur/query.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type query struct { 10 | providerID string 11 | secretRef string 12 | 13 | filterID string 14 | filterRule string 15 | } 16 | 17 | func parseQuery(s string) (query, error) { 18 | if len(s) == 0 { 19 | return query{}, errors.New("empty query") 20 | } 21 | 22 | const separator = "|" 23 | 24 | parts := strings.SplitN(s, separator, 2) 25 | 26 | providerID, secretRef, err := parseQuerySecret(parts[0]) 27 | if err != nil { 28 | return query{}, fmt.Errorf("left of first %q: %w", separator, err) 29 | } 30 | 31 | if len(parts) == 1 { 32 | return query{ 33 | providerID: providerID, 34 | secretRef: secretRef, 35 | }, nil 36 | } 37 | 38 | filterID, filterRule, err := parseQueryFilter(parts[1]) 39 | if err != nil { 40 | return query{}, fmt.Errorf("right of first %q: %w", separator, err) 41 | } 42 | 43 | return query{ 44 | providerID: providerID, 45 | secretRef: secretRef, 46 | filterID: filterID, 47 | filterRule: filterRule, 48 | }, nil 49 | } 50 | 51 | func parseQuerySecret(s string) (providerID, secretRef string, err error) { 52 | const separator = ":" 53 | 54 | parts := strings.SplitN(s, separator, 2) 55 | if len(parts) < 2 { 56 | return "", "", fmt.Errorf("must be at least two parts separated by %q", separator) 57 | } 58 | 59 | if parts[0] == "" { 60 | return "", "", fmt.Errorf("provider ID left of %q cannot be empty string", separator) 61 | } 62 | 63 | if parts[1] == "" { 64 | return "", "", fmt.Errorf("reference right of %q cannot be empty string", separator) 65 | } 66 | 67 | return parts[0], parts[1], nil 68 | } 69 | 70 | func parseQueryFilter(s string) (filterID, filterRule string, err error) { 71 | const separator = ":" 72 | 73 | parts := strings.SplitN(s, separator, 2) 74 | if len(parts) < 2 { 75 | return "", "", fmt.Errorf("must be at least two parts separated by %q", separator) 76 | } 77 | 78 | if parts[0] == "" { 79 | return "", "", fmt.Errorf("filter ID left of %q cannot be empty string", separator) 80 | } 81 | 82 | if parts[1] == "" { 83 | return "", "", fmt.Errorf("filter rule right of %q cannot be empty string", separator) 84 | } 85 | 86 | return parts[0], parts[1], nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/murmur/query_test.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestParseQuery(t *testing.T) { 9 | tt := []struct { 10 | s string 11 | want query 12 | wantErr bool 13 | }{ 14 | { 15 | s: "my_provider:my_secret", 16 | want: query{ 17 | providerID: "my_provider", 18 | secretRef: "my_secret", 19 | }, 20 | }, 21 | { 22 | s: "my_provider:my_secret:my_version", 23 | want: query{ 24 | providerID: "my_provider", 25 | secretRef: "my_secret:my_version", 26 | }, 27 | }, 28 | { 29 | s: "my_provider:my_secret:my_version|my_filter:my_filter_rule", 30 | want: query{ 31 | providerID: "my_provider", 32 | secretRef: "my_secret:my_version", 33 | filterID: "my_filter", 34 | filterRule: "my_filter_rule", 35 | }, 36 | }, 37 | { 38 | s: "my_provider:my_secret:my_version|my_filter:my:complex|filter:rule", 39 | want: query{ 40 | providerID: "my_provider", 41 | secretRef: "my_secret:my_version", 42 | filterID: "my_filter", 43 | filterRule: "my:complex|filter:rule", 44 | }, 45 | }, 46 | { 47 | s: "", 48 | wantErr: true, 49 | }, 50 | { 51 | s: "my_provider", 52 | wantErr: true, 53 | }, 54 | { 55 | s: "my_provider:", 56 | wantErr: true, 57 | }, 58 | { 59 | s: ":my_secret", 60 | wantErr: true, 61 | }, 62 | { 63 | s: ":my_secret:my_version", 64 | wantErr: true, 65 | }, 66 | { 67 | s: "my_provider:my_secret|", 68 | wantErr: true, 69 | }, 70 | { 71 | s: "my_provider:my_secret|my_filter", 72 | wantErr: true, 73 | }, 74 | { 75 | s: "my_provider:my_secret|my_filter:", 76 | wantErr: true, 77 | }, 78 | { 79 | s: "my_provider:my_secret|:my_filter_rule", 80 | wantErr: true, 81 | }, 82 | { 83 | s: "my_provider:my_secret|:my:complex:filter:rule", 84 | wantErr: true, 85 | }, 86 | } 87 | 88 | for _, tc := range tt { 89 | t.Run(tc.s, func(t *testing.T) { 90 | actual, err := parseQuery(tc.s) 91 | 92 | if err != nil && !tc.wantErr { 93 | t.Errorf("unexpected error: %v", err) 94 | } 95 | if err == nil && tc.wantErr { 96 | t.Errorf("expected error, got none") 97 | } 98 | 99 | if !reflect.DeepEqual(actual, tc.want) { 100 | t.Errorf("ParseQuery() = %#v, want %#v", actual, tc.want) 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/murmur/resolve.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/hashicorp/go-multierror" 9 | ) 10 | 11 | type variable struct { 12 | // Name of the environment variable. 13 | name string 14 | // The environment variable's original value. 15 | rawValue string 16 | // The environment variable's query value, if it is a valid murmur query. 17 | query *query 18 | // The resolved value of the secret referenced in the query. 19 | resolvedValue string 20 | // The filtered value of the secret. 21 | filteredValue string 22 | // The final value of the environment variable. 23 | finalValue string 24 | // Any error that occurred while processing the environment variable. 25 | err error 26 | } 27 | 28 | // ResolveAll returns a map with the same keys as vars, where all values with 29 | // known prefixes have been replaced with their values. 30 | func ResolveAll(vars map[string]string) (map[string]string, error) { 31 | var ( 32 | rawVars = make(chan variable, len(vars)) 33 | parsed = make(chan variable, len(vars)) 34 | resolved = make(chan variable, len(vars)) 35 | done = make(chan variable, len(vars)) 36 | failed = make(chan variable, len(vars)) 37 | ) 38 | 39 | // First, feed all the environment variable into the pipeline. 40 | 41 | for name, value := range vars { 42 | v := variable{ 43 | name: name, 44 | rawValue: value, 45 | } 46 | rawVars <- v 47 | } 48 | close(rawVars) 49 | 50 | // Next, launch the first step of the pipeline: parsing. 51 | 52 | go func() { 53 | parseVariables(rawVars, parsed, done) 54 | close(parsed) 55 | }() 56 | 57 | // Then, launch the second step of the pipeline: reference resolution. 58 | 59 | go func() { 60 | resolveVariables(parsed, resolved, failed) 61 | close(resolved) 62 | }() 63 | 64 | // Next, launch the third step of the pipeline: filtering. 65 | 66 | go func() { 67 | filterVariables(resolved, done, failed) 68 | close(done) 69 | close(failed) 70 | }() 71 | 72 | // Finally, drain the end of the pipeline and aggregate the results. 73 | 74 | var multierr error 75 | for v := range failed { 76 | multierr = multierror.Append(multierr, fmt.Errorf("%s: %w", v.name, v.err)) 77 | } 78 | 79 | if multierr != nil { 80 | return nil, multierr 81 | } 82 | 83 | newVars := make(map[string]string) 84 | for v := range done { 85 | newVars[v.name] = v.finalValue 86 | } 87 | 88 | return newVars, nil 89 | } 90 | 91 | func parseVariables(rawVars <-chan variable, parsed, done chan<- variable) { 92 | for v := range rawVars { 93 | q, err := parseQuery(v.rawValue) 94 | if err != nil { 95 | // The variable's value is not a murmur query, so we should leave 96 | // it as is. 97 | v.finalValue = v.rawValue 98 | done <- v 99 | continue 100 | } 101 | if _, known := ProviderFactories[q.providerID]; !known { 102 | // The variable's value looks like a query but the provider is 103 | // unknown. It probably isn't a query. 104 | // ?(busser): should we log a message here? 105 | v.finalValue = v.rawValue 106 | done <- v 107 | continue 108 | } 109 | if _, known := Filters[q.filterID]; q.filterID != "" && !known { 110 | // The variable's value looks like a query but the filter is 111 | // unknown. It probably isn't a query. 112 | // ?(busser): should we log a message here? 113 | v.finalValue = v.rawValue 114 | done <- v 115 | continue 116 | } 117 | 118 | v.query = &q 119 | parsed <- v 120 | } 121 | } 122 | 123 | // resolveVariables drains `in` and, for each variable, attempts to resolve the 124 | // reference the query contains. Variables with successful resolutions are 125 | // pushed to `out`. Variables with failed resolutions are pushed to `failed`. 126 | func resolveVariables(in <-chan variable, out, failed chan<- variable) { 127 | chanByProvider := make(map[string]chan variable) 128 | var wg sync.WaitGroup 129 | 130 | for v := range in { 131 | providerID := v.query.providerID 132 | 133 | // Dispatch variable to separate goroutines based on the provider 134 | // required to fetch the secret referenced in the variable's query. 135 | if _, ok := chanByProvider[providerID]; !ok { 136 | ch := make(chan variable, cap(in)) 137 | chanByProvider[providerID] = ch 138 | 139 | wg.Add(1) 140 | go func() { 141 | resolveVariablesWithProvider(providerID, ch, out, failed) 142 | wg.Done() 143 | }() 144 | } 145 | 146 | chanByProvider[providerID] <- v 147 | } 148 | 149 | // Wait for each provider to finish resolving its secrets. 150 | for _, ch := range chanByProvider { 151 | close(ch) 152 | } 153 | wg.Wait() 154 | } 155 | 156 | // resolveVariablesWithProvider drains `int` and, for each variable, attempts to 157 | // resolve the reference the query contains with a specific provider. Variables 158 | // with successful resolutions are pushed to `out`. Variables with failed 159 | // resolutions are pushed to `failed`. 160 | func resolveVariablesWithProvider(providerID string, in <-chan variable, out, failed chan<- variable) { 161 | provider, err := ProviderFactories[providerID]() 162 | if err != nil { 163 | // Since we cannot instanciate the provider, we return the same error 164 | // for all variables sent our way. 165 | for v := range in { 166 | v.err = fmt.Errorf("provider instantiation error: %w", err) 167 | failed <- v 168 | } 169 | return 170 | } 171 | defer provider.Close() 172 | 173 | // To avoid querying the provider for the same secret twice, we keep a 174 | // cache of resolved secrets. Since secrets are resolved concurrently, 175 | // duplicate references are put aside until all unique references have been 176 | // resolved. 177 | 178 | type result struct { 179 | secretValue string 180 | err error 181 | } 182 | 183 | var ( 184 | seen = make(map[string]bool) 185 | duplicates []variable 186 | 187 | wg sync.WaitGroup 188 | 189 | mu sync.Mutex // protects cache 190 | cache = make(map[string]result) 191 | ) 192 | 193 | for v := range in { 194 | if seen[v.query.secretRef] { 195 | duplicates = append(duplicates, v) 196 | continue 197 | } 198 | seen[v.query.secretRef] = true 199 | 200 | wg.Add(1) 201 | go func(v variable) { 202 | defer wg.Done() 203 | 204 | secretValue, err := provider.Resolve(context.TODO(), v.query.secretRef) 205 | 206 | mu.Lock() 207 | cache[v.query.secretRef] = result{secretValue, err} 208 | mu.Unlock() 209 | 210 | if err != nil { 211 | v.err = fmt.Errorf("could not resolve reference: %w", err) 212 | failed <- v 213 | return 214 | } 215 | 216 | v.resolvedValue = secretValue 217 | out <- v 218 | }(v) 219 | } 220 | 221 | wg.Wait() 222 | 223 | // Now that all unique references have been resolved, results for duplicate 224 | // references can be read from cache. 225 | 226 | for _, v := range duplicates { 227 | result := cache[v.query.secretRef] 228 | if result.err != nil { 229 | v.err = fmt.Errorf("could not resolve reference: %w", err) 230 | failed <- v 231 | continue 232 | } 233 | 234 | v.resolvedValue = result.secretValue 235 | out <- v 236 | } 237 | } 238 | 239 | // filterVariables drains `in` and, for each variable, attempts to filter the 240 | // the secret's value with the filtering rule contained in the query. Variables 241 | // with successful resolutions are pushed to `out`. Variables with failed 242 | // resolutions are pushed to `failed`. 243 | func filterVariables(in <-chan variable, out, failed chan<- variable) { 244 | chanByFilter := make(map[string]chan variable) 245 | var wg sync.WaitGroup 246 | 247 | for v := range in { 248 | filterID := v.query.filterID 249 | 250 | if filterID == "" { 251 | v.filteredValue = v.resolvedValue 252 | v.finalValue = v.filteredValue 253 | out <- v 254 | continue 255 | } 256 | 257 | // Dispatch variable to separate goroutines based on the filter 258 | // specified in the variable's query. 259 | if _, ok := chanByFilter[filterID]; !ok { 260 | ch := make(chan variable, cap(in)) 261 | chanByFilter[filterID] = ch 262 | 263 | wg.Add(1) 264 | go func() { 265 | filterVariablesWithFilter(filterID, ch, out, failed) 266 | wg.Done() 267 | }() 268 | } 269 | 270 | chanByFilter[filterID] <- v 271 | } 272 | 273 | // Wait for each filter to finish filtering its secrets. 274 | for _, ch := range chanByFilter { 275 | close(ch) 276 | } 277 | wg.Wait() 278 | } 279 | 280 | // filterVariablesWithFilter drains `in` and, for each variable, attempts to 281 | // filter the the secret's value with a specific filter. Variables with 282 | // successful resolutions are pushed to `out`. Variables with failed resolutions 283 | // are pushed to `failed`. 284 | func filterVariablesWithFilter(filterID string, in <-chan variable, out, failed chan<- variable) { 285 | filter := Filters[filterID] 286 | 287 | for v := range in { 288 | filteredValue, err := filter(v.resolvedValue, v.query.filterRule) 289 | if err != nil { 290 | v.err = fmt.Errorf("could not filter value: %w", err) 291 | failed <- v 292 | continue 293 | } 294 | 295 | v.filteredValue = filteredValue 296 | v.finalValue = v.filteredValue 297 | out <- v 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /internal/murmur/resolve_e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build e2e 2 | 3 | package murmur 4 | 5 | import ( 6 | "strings" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | ) 11 | 12 | func TestResolveAllEndToEnd(t *testing.T) { 13 | 14 | // The secrets this test reads were created with Terraform. The code is in 15 | // the terraform directory of this repository. 16 | 17 | // This test does a high-level pass using all clients. More in-depth 18 | // end-to-end testing of each provider is done in the provider's package. 19 | 20 | envVars := map[string]string{ 21 | "NOT_A_SECRET": "My app listens on port 3000", 22 | "FROM_AZURE": "azkv:murmur-alpha.vault.azure.net/secret-sauce", 23 | "FROM_AWS": "awssm:secret-sauce", 24 | "FROM_GCP": "gcpsm:murmur-tests/secret-sauce", 25 | "FROM_SCALEWAY": "scwsm:secret-sauce", 26 | "FROM_PASSTHROUGH": "passthrough:szechuan", 27 | "JSON_SECRET": `passthrough:{"sauce": "szechuan"}|jsonpath:{ .sauce }`, 28 | "LOOKS_LIKE_A_SECRET": "baz:but isn't a secret", 29 | } 30 | 31 | actual, err := ResolveAll(envVars) 32 | if err != nil { 33 | t.Fatalf("ResolveAll() returned an error: %v", err) 34 | } 35 | 36 | want := map[string]string{ 37 | "NOT_A_SECRET": "My app listens on port 3000", 38 | "FROM_AZURE": "szechuan", 39 | "FROM_AWS": "szechuan", 40 | "FROM_GCP": "szechuan", 41 | "FROM_SCALEWAY": "szechuan", 42 | "FROM_PASSTHROUGH": "szechuan", 43 | "JSON_SECRET": `szechuan`, 44 | "LOOKS_LIKE_A_SECRET": "baz:but isn't a secret", 45 | } 46 | 47 | if diff := cmp.Diff(want, actual); diff != "" { 48 | t.Errorf("ResolveAll() mismatch (-want +got):\n%s", diff) 49 | } 50 | } 51 | 52 | func TestResolveAllEndToEndWithError(t *testing.T) { 53 | envVars := map[string]string{ 54 | "NOT_A_SECRET": "My app listens on port 3000", 55 | "OK_SECRET": "awssm:secret-sauce", 56 | "BROKEN_SECRET": "azkv:murmur-alpha.vault.azure.net/does-not-exist", 57 | "BUGGY_SECRET": "gcpsm:invalid-ref", 58 | "NOT_JSON": "passthrough:not-json|jsonpath:{}", 59 | "LOOKS_LIKE_A_SECRET": "baz:FAIL", 60 | } 61 | 62 | _, err := ResolveAll(envVars) 63 | if err == nil { 64 | t.Fatal("ResolveAll() returned no error but it should have") 65 | } 66 | 67 | errMsg := err.Error() 68 | 69 | errorShouldMention := []string{"BROKEN_SECRET", "BUGGY_SECRET"} 70 | for _, s := range errorShouldMention { 71 | if !strings.Contains(errMsg, s) { 72 | t.Errorf("Error message %q should mention %q", errMsg, s) 73 | } 74 | } 75 | 76 | errorShouldNotMention := []string{"NOT_A_SECRET", "OK_SECRET", "LOOKS_LIKE_A_SECRET"} 77 | for _, s := range errorShouldNotMention { 78 | if strings.Contains(errMsg, s) { 79 | t.Errorf("Error message %q should not mention %q", errMsg, s) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/murmur/resolve_test.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/busser/murmur/internal/murmur/providers/jsonmock" 8 | "github.com/busser/murmur/internal/murmur/providers/mock" 9 | "github.com/busser/murmur/internal/slices" 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | type MockProvider interface { 14 | Provider 15 | ResolvedRefs() []string 16 | Closed() bool 17 | } 18 | 19 | func TestResolveAll(t *testing.T) { 20 | tt := []struct { 21 | name string 22 | providers map[string]MockProvider 23 | variables map[string]string 24 | want map[string]string 25 | }{ 26 | { 27 | name: "no overloads", 28 | variables: map[string]string{ 29 | "A": "A", 30 | "B": "B", 31 | "C": "bar:C", 32 | }, 33 | want: map[string]string{ 34 | "A": "A", 35 | "B": "B", 36 | "C": "bar:C", 37 | }, 38 | }, 39 | { 40 | name: "multiple providers", 41 | providers: map[string]MockProvider{ 42 | "foo": mock.New(), 43 | "bar": mock.New(), 44 | "json": jsonmock.New(), 45 | }, 46 | variables: map[string]string{ 47 | "A": "foo:A", 48 | "B": "foo:B", 49 | "C": "bar:C", 50 | "D": "json:D", 51 | }, 52 | want: map[string]string{ 53 | "A": mock.ValueFor("A"), 54 | "B": mock.ValueFor("B"), 55 | "C": mock.ValueFor("C"), 56 | "D": jsonmock.ValueFor("D"), 57 | }, 58 | }, 59 | { 60 | name: "filters", 61 | providers: map[string]MockProvider{ 62 | "json": jsonmock.New(), 63 | }, 64 | variables: map[string]string{ 65 | "A": "json:A|jsonpath:{ ." + jsonmock.Key + " }", 66 | "B": "json:B|jsonpath:ref={ ." + jsonmock.Key + " }", 67 | "C": "json:C|jsonpath:is my ref { ." + jsonmock.Key + " }?", 68 | }, 69 | want: map[string]string{ 70 | "A": "A", 71 | "B": "ref=B", 72 | "C": "is my ref C?", 73 | }, 74 | }, 75 | { 76 | name: "caching", 77 | providers: map[string]MockProvider{ 78 | "json": jsonmock.New(), 79 | }, 80 | variables: map[string]string{ 81 | "A": "json:A|jsonpath:{ ." + jsonmock.Key + " }", 82 | "B": "json:A|jsonpath:ref={ ." + jsonmock.Key + " }", 83 | "C": "json:A|jsonpath:is my ref { ." + jsonmock.Key + " }?", 84 | }, 85 | want: map[string]string{ 86 | "A": "A", 87 | "B": "ref=A", 88 | "C": "is my ref A?", 89 | }, 90 | }, 91 | { 92 | name: "a bit of everything", 93 | providers: map[string]MockProvider{ 94 | "foo": mock.New(), 95 | "bar": mock.New(), 96 | "json": jsonmock.New(), 97 | }, 98 | variables: map[string]string{ 99 | "NOT_A_SECRET": "My app listens on port 3000", 100 | "NOT_A_SECRET_EITHER": "The cloud is awesome", 101 | "FIRST_SECRET": "foo:database password", 102 | "SECOND_SECRET": "foo:private key", 103 | "THIRD_SECRET": "bar:api key", 104 | "FOURTH_SECRET": "bar:api key", 105 | "LOOKS_LIKE_A_SECRET": "baz:but isn't a secret", 106 | "JSON_SECRET": "json:cloud credentials|jsonpath:{ ." + jsonmock.Key + " }", 107 | "SAME_JSON_SECRET": "json:cloud credentials|jsonpath:ref={ ." + jsonmock.Key + " }", 108 | }, 109 | want: map[string]string{ 110 | "NOT_A_SECRET": "My app listens on port 3000", 111 | "NOT_A_SECRET_EITHER": "The cloud is awesome", 112 | "FIRST_SECRET": mock.ValueFor("database password"), 113 | "SECOND_SECRET": mock.ValueFor("private key"), 114 | "THIRD_SECRET": mock.ValueFor("api key"), 115 | "FOURTH_SECRET": mock.ValueFor("api key"), 116 | "LOOKS_LIKE_A_SECRET": "baz:but isn't a secret", 117 | "JSON_SECRET": "cloud credentials", 118 | "SAME_JSON_SECRET": "ref=cloud credentials", 119 | }, 120 | }, 121 | } 122 | 123 | for _, tc := range tt { 124 | t.Run(tc.name, func(t *testing.T) { 125 | factories := make(map[string]ProviderFactory) 126 | for prefix, provider := range tc.providers { 127 | provider := provider 128 | factories[prefix] = func() (Provider, error) { return provider, nil } 129 | } 130 | 131 | // Replace murmur's clients with mocks for the duration of the test. 132 | originalProviderFactories := ProviderFactories 133 | defer func() { ProviderFactories = originalProviderFactories }() 134 | ProviderFactories = factories 135 | 136 | actual, err := ResolveAll(tc.variables) 137 | if err != nil { 138 | t.Fatalf("ResolveAll() returned an error: %v", err) 139 | } 140 | 141 | for prefix, provider := range tc.providers { 142 | if !provider.Closed() { 143 | t.Errorf("%q provider not closed", prefix) 144 | } 145 | if slices.Duplicates(provider.ResolvedRefs()) != 0 { 146 | t.Errorf("%q provider resolved the same reference more than once, is caching broken?", prefix) 147 | t.Logf("%q provider resolved: %q", prefix, provider.ResolvedRefs()) 148 | } 149 | } 150 | 151 | if diff := cmp.Diff(tc.want, actual); diff != "" { 152 | t.Errorf("ResolveAll() mismatch (-want +got):\n%s", diff) 153 | } 154 | }) 155 | } 156 | } 157 | 158 | func TestResolveAllWithError(t *testing.T) { 159 | tt := []struct { 160 | name string 161 | providers map[string]MockProvider 162 | variables map[string]string 163 | wantOK []string 164 | wantFailed []string 165 | }{ 166 | { 167 | name: "a bit of everything", 168 | providers: map[string]MockProvider{ 169 | "foo": mock.New(), 170 | "bar": mock.New(), 171 | "json": jsonmock.New(), 172 | }, 173 | variables: map[string]string{ 174 | "NOT_A_SECRET": "My app listens on port 3000", 175 | "OK_SECRET": "foo:database password", 176 | "BROKEN_SECRET": "foo:FAIL", 177 | "BUGGY_SECRET": "bar:FAIL", 178 | "LOOKS_LIKE_A_SECRET": "baz:FAIL", 179 | "JSON_ERR": "json:cloud credentials|jsonpath:{ .missing }", 180 | "NOT_JSON": "foo:api key|jsonpath:{ .foo }", 181 | "OK_JSON": "json:cloud credentials|jsonpath:{ ." + jsonmock.Key + " }", 182 | }, 183 | wantOK: []string{"NOT_A_SECRET", "OK_SECRET", "LOOKS_LIKE_A_SECRET", "OK_JSON"}, 184 | wantFailed: []string{"BROKEN_SECRET", "BUGGY_SECRET", "JSON_ERR", "NOT_JSON"}, 185 | }, 186 | } 187 | 188 | for _, tc := range tt { 189 | t.Run(tc.name, func(t *testing.T) { 190 | factories := make(map[string]ProviderFactory) 191 | for prefix, provider := range tc.providers { 192 | provider := provider 193 | factories[prefix] = func() (Provider, error) { return provider, nil } 194 | } 195 | 196 | // Replace murmur's clients with mocks for the duration of the test. 197 | originalProviderFactories := ProviderFactories 198 | defer func() { ProviderFactories = originalProviderFactories }() 199 | ProviderFactories = factories 200 | 201 | _, err := ResolveAll(tc.variables) 202 | if err == nil { 203 | t.Fatal("ResolveAll() returned no error but it should have") 204 | } 205 | 206 | for prefix, provider := range tc.providers { 207 | if !provider.Closed() { 208 | t.Errorf("%q provider not closed", prefix) 209 | } 210 | if slices.Duplicates(provider.ResolvedRefs()) != 0 { 211 | t.Errorf("%q provider resolved the same reference more than once, is caching broken?", prefix) 212 | t.Logf("%q provider resolved: %q", prefix, provider.ResolvedRefs()) 213 | } 214 | } 215 | 216 | errMsg := err.Error() 217 | 218 | for _, s := range tc.wantOK { 219 | if strings.Contains(errMsg, s) { 220 | t.Errorf("Error message %q should not mention %q", errMsg, s) 221 | } 222 | } 223 | 224 | for _, s := range tc.wantFailed { 225 | if !strings.Contains(errMsg, s) { 226 | t.Errorf("Error message %q should mention %q", errMsg, s) 227 | } 228 | } 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /internal/murmur/run.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "sort" 11 | 12 | "github.com/busser/murmur/internal/environ" 13 | ) 14 | 15 | // Modified during testing to catch command output. 16 | var ( 17 | runOut io.Writer = os.Stdout 18 | runErr io.Writer = os.Stderr 19 | ) 20 | 21 | func Run(name string, args ...string) (exitCode int, err error) { 22 | originalVars := environ.ToMap(os.Environ()) 23 | 24 | newVars, err := ResolveAll(originalVars) 25 | if err != nil { 26 | return 0, err 27 | } 28 | 29 | var overloaded []string 30 | for name, original := range originalVars { 31 | if newVars[name] != original { 32 | overloaded = append(overloaded, name) 33 | } 34 | } 35 | 36 | sort.Strings(overloaded) 37 | for _, name := range overloaded { 38 | log.Printf("[murmur] overloading %s", name) 39 | } 40 | 41 | subCmd := exec.Command(name, args...) 42 | subCmd.Env = environ.ToSlice(newVars) 43 | subCmd.Stdin = os.Stdin 44 | subCmd.Stdout = runOut 45 | subCmd.Stderr = runErr 46 | 47 | if err := subCmd.Start(); err != nil { 48 | return 1, err 49 | } 50 | 51 | // Capture signals for the duration of the sub process and forward them. 52 | // This is challenging to test automatically, so it's not. 53 | // This feature can be tested manually by running this command: 54 | // murmur run -- ./internal/murmur/testdata/signal.sh 55 | // and then sending an interrupt signal to the murmur process. 56 | signals := make(chan os.Signal, 1) 57 | signal.Notify(signals) 58 | 59 | stop := make(chan struct{}) 60 | defer func() { 61 | close(stop) 62 | }() 63 | 64 | // Forward signals to the sub process. 65 | go func() { 66 | for { 67 | select { 68 | case <-stop: 69 | return 70 | case sig := <-signals: 71 | _ = subCmd.Process.Signal(sig) 72 | } 73 | } 74 | }() 75 | 76 | if err := subCmd.Wait(); err != nil { 77 | exitErr := new(exec.ExitError) 78 | if errors.As(err, &exitErr) { 79 | return exitErr.ProcessState.ExitCode(), nil 80 | } 81 | return 0, err 82 | } 83 | 84 | return subCmd.ProcessState.ExitCode(), nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/murmur/run_test.go: -------------------------------------------------------------------------------- 1 | package murmur 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "testing" 7 | 8 | "github.com/busser/murmur/internal/environ" 9 | ) 10 | 11 | func TestRun(t *testing.T) { 12 | tt := []struct { 13 | name string 14 | command []string 15 | env []string 16 | wantExitCode int 17 | wantOutput string 18 | }{ 19 | { 20 | name: "exit code 0", 21 | command: []string{"/bin/sh", "-c", "exit 0"}, 22 | env: nil, 23 | wantExitCode: 0, 24 | wantOutput: "", 25 | }, 26 | { 27 | name: "exit code 1", 28 | command: []string{"/bin/sh", "-c", "exit 1"}, 29 | env: nil, 30 | wantExitCode: 1, 31 | wantOutput: "", 32 | }, 33 | { 34 | name: "exit code 123", 35 | command: []string{"/bin/sh", "-c", "exit 123"}, 36 | env: nil, 37 | wantExitCode: 123, 38 | wantOutput: "", 39 | }, 40 | { 41 | name: "env without replacement", 42 | command: []string{"/bin/sh", "-c", "printenv SECRET_SAUCE"}, 43 | env: []string{"SECRET_SAUCE=szechuan"}, 44 | wantExitCode: 0, 45 | wantOutput: "szechuan\n", 46 | }, 47 | { 48 | name: "env with replacement", 49 | command: []string{"/bin/sh", "-c", "printenv SECRET_SAUCE"}, 50 | env: []string{"SECRET_SAUCE=passthrough:szechuan"}, 51 | wantExitCode: 0, 52 | wantOutput: "szechuan\n", 53 | }, 54 | } 55 | 56 | for _, tc := range tt { 57 | t.Run(tc.name, func(t *testing.T) { 58 | 59 | // Capture Run()'s output for the duration of the test. 60 | var output bytes.Buffer 61 | runOut = &output 62 | runErr = &output 63 | defer func() { 64 | runOut = os.Stdout 65 | runErr = os.Stderr 66 | }() 67 | 68 | // Clear all environment variables for the duration of the test. 69 | originalEnv := os.Environ() 70 | os.Clearenv() 71 | defer func() { 72 | os.Clearenv() 73 | for k, v := range environ.ToMap(originalEnv) { 74 | os.Setenv(k, v) 75 | } 76 | }() 77 | 78 | // Set specific environment variables for this test. 79 | for k, v := range environ.ToMap(tc.env) { 80 | os.Setenv(k, v) 81 | } 82 | 83 | exitCode, err := Run(tc.command[0], tc.command[1:]...) 84 | if err != nil { 85 | t.Errorf("Run() returned an error: %v", err) 86 | } 87 | 88 | if exitCode != tc.wantExitCode { 89 | t.Errorf("got exit code %d, want %d", exitCode, tc.wantExitCode) 90 | } 91 | 92 | if output.String() != tc.wantOutput { 93 | t.Errorf("got output %q, want %q", output.String(), tc.wantOutput) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/murmur/testdata/signal.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | trap catch 2 # SIGINT 6 | 7 | catch() 8 | { 9 | echo "Caught SIGINT" 10 | sleep 2 11 | echo "Graceful shutdown OK" 12 | } 13 | 14 | echo "Started" 15 | cat 16 | -------------------------------------------------------------------------------- /internal/slices/slices.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | // Contains reports whether v is within s. 4 | func Contains[T comparable](s []T, v T) bool { 5 | return Index(s, v) >= 0 6 | } 7 | 8 | // Index returns the index of the first instance of v in s, or -1 if v is not 9 | // present in s. 10 | func Index[T comparable](s []T, v T) int { 11 | for i := range s { 12 | if s[i] == v { 13 | return i 14 | } 15 | } 16 | return -1 17 | } 18 | 19 | // Equal returns whether a and b's contents are identical. 20 | func Equal[T comparable](a, b []T) bool { 21 | if len(a) != len(b) { 22 | return false 23 | } 24 | for i := range a { 25 | if a[i] != b[i] { 26 | return false 27 | } 28 | } 29 | return true 30 | } 31 | 32 | // Unique filters out repeated elements in a slice. If s is sorted, Unique 33 | // returns a slice with no duplicate entries. 34 | func Unique[T comparable](s []T) []T { 35 | if len(s) == 0 { 36 | return nil 37 | } 38 | 39 | previous := s[0] 40 | t := []T{previous} 41 | 42 | for i := 1; i < len(s); i++ { 43 | if s[i] != previous { 44 | t = append(t, s[i]) 45 | } 46 | previous = s[i] 47 | } 48 | 49 | return t 50 | } 51 | 52 | func Duplicates[T comparable](s []T) int { 53 | uniq := make(map[T]struct{}) 54 | 55 | for _, v := range s { 56 | uniq[v] = struct{}{} 57 | } 58 | 59 | return len(s) - len(uniq) 60 | } 61 | -------------------------------------------------------------------------------- /internal/slices/slices_test.go: -------------------------------------------------------------------------------- 1 | package slices 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIndex(t *testing.T) { 8 | tt := []struct { 9 | s []int 10 | v int 11 | want int 12 | }{ 13 | {[]int{1, 1, 2, 3, 5}, 0, -1}, 14 | {[]int{1, 1, 2, 3, 5}, 1, 0}, 15 | {[]int{1, 1, 2, 3, 5}, 2, 2}, 16 | {[]int{1, 1, 2, 3, 5}, 3, 3}, 17 | {[]int{1, 1, 2, 3, 5}, 4, -1}, 18 | {[]int{1, 1, 2, 3, 5}, 5, 4}, 19 | {[]int{}, 123, -1}, 20 | } 21 | 22 | for _, tc := range tt { 23 | actual := Index(tc.s, tc.v) 24 | if actual != tc.want { 25 | t.Errorf("Index(%#v, %#v) = %#v, want %#v", 26 | tc.s, tc.s, actual, tc.want) 27 | } 28 | } 29 | } 30 | 31 | func TestContains(t *testing.T) { 32 | tt := []struct { 33 | s []int 34 | v int 35 | want bool 36 | }{ 37 | {[]int{1, 1, 2, 3, 5}, 0, false}, 38 | {[]int{1, 1, 2, 3, 5}, 1, true}, 39 | {[]int{1, 1, 2, 3, 5}, 2, true}, 40 | {[]int{1, 1, 2, 3, 5}, 3, true}, 41 | {[]int{1, 1, 2, 3, 5}, 4, false}, 42 | {[]int{1, 1, 2, 3, 5}, 5, true}, 43 | {[]int{}, 123, false}, 44 | } 45 | 46 | for _, tc := range tt { 47 | actual := Contains(tc.s, tc.v) 48 | if actual != tc.want { 49 | t.Errorf("Contains(%#v, %#v) = %#v, want %#v", 50 | tc.s, tc.s, actual, tc.want) 51 | } 52 | } 53 | } 54 | 55 | func TestEqual(t *testing.T) { 56 | tt := []struct { 57 | a, b []int 58 | want bool 59 | }{ 60 | {[]int{1, 2, 3}, []int{1, 2, 3}, true}, 61 | {[]int{1, 2, 3}, []int{1, 2, 4}, false}, 62 | {[]int{1, 2, 3, 4}, []int{1, 2, 3}, false}, 63 | {[]int{}, []int{1, 2, 3}, false}, 64 | {nil, []int{1, 2, 3}, false}, 65 | {nil, nil, true}, 66 | {nil, []int{}, true}, 67 | {[]int{}, []int{}, true}, 68 | } 69 | 70 | for _, tc := range tt { 71 | actual := Equal(tc.a, tc.b) 72 | if actual != tc.want { 73 | t.Errorf("Equal(%#v, %#v) = %#v, want %#v", 74 | tc.a, tc.b, actual, tc.want) 75 | } 76 | 77 | // Equality is symetric. 78 | actual = Equal(tc.b, tc.a) 79 | if actual != tc.want { 80 | t.Errorf("Equal(%#v, %#v) = %#v, want %#v", 81 | tc.b, tc.a, actual, tc.want) 82 | } 83 | } 84 | } 85 | 86 | func TestUnique(t *testing.T) { 87 | tt := []struct { 88 | s []int 89 | want []int 90 | }{ 91 | {nil, nil}, 92 | {[]int{}, nil}, 93 | {[]int{1, 2, 3, 4}, []int{1, 2, 3, 4}}, 94 | {[]int{1, 2, 1, 2}, []int{1, 2, 1, 2}}, 95 | {[]int{1, 1, 2, 2}, []int{1, 2}}, 96 | {[]int{1, 1, 2, 1}, []int{1, 2, 1}}, 97 | } 98 | 99 | for _, tc := range tt { 100 | actual := Unique(tc.s) 101 | if !Equal(actual, tc.want) { 102 | t.Errorf("Unique(%#v) = %#v, want %#v", 103 | tc.s, actual, tc.want) 104 | } 105 | } 106 | } 107 | 108 | func TestDuplicates(t *testing.T) { 109 | tt := []struct { 110 | s []int 111 | want int 112 | }{ 113 | {nil, 0}, 114 | {[]int{}, 0}, 115 | {[]int{1, 2, 3, 4}, 0}, 116 | {[]int{1, 2, 1, 2}, 2}, 117 | {[]int{1, 1, 2, 2}, 2}, 118 | {[]int{1, 1, 2, 1}, 2}, 119 | {[]int{1, 1, 1, 1}, 3}, 120 | } 121 | 122 | for _, tc := range tt { 123 | actual := Duplicates(tc.s) 124 | if actual != tc.want { 125 | t.Errorf("Duplicates(%#v) = %#v, want %#v", 126 | tc.s, actual, tc.want) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/busser/murmur/internal/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | # Cloud Infrastucture 2 | 3 | This directory contains all Terraform code required to provision the cloud 4 | resources used to test murmur functionality. 5 | 6 | - [Requirements](#requirements) 7 | - [Usage](#usage) 8 | - [Documentation](#documentation) 9 | 10 | ## Requirements 11 | 12 | Our recommended setup requires that you install [tfswitch](https://tfswitch.warrensbox.com/Install/). 13 | Alternatively, you can install a version of the [Terraform CLI](https://www.terraform.io/downloads.html) 14 | that matches the version constraints of this project. 15 | 16 | You must be authenticated as a Microsoft Azure account that has the necessary 17 | permissions to manage the infrastructure. To log in, run this command: 18 | 19 | ```bash 20 | az login 21 | ``` 22 | 23 | You must be authenticated to a Google account that has the necessary permissions 24 | to manage the infrastructure. To log in, run this command: 25 | 26 | ```bash 27 | gcloud auth application-default login 28 | ``` 29 | 30 | You must be authenticated as an AWS user that has the necessary permissions 31 | to manage the infrastructure. Do this by setting the necessary environment 32 | variables. You must also set the `AWS_REGION` environment variable to 33 | `eu-west-3`. 34 | 35 | You must also set the `GITHUB_TOKEN` environment variable to a personal access 36 | token with the necessary permissions to manage this repository's secrets, and 37 | the `GITHUB_OWNER` environment variable to `busser`, this repository's owner. 38 | 39 | To generate documentation, you must install [terraform-docs](https://terraform-docs.io/) 40 | and [prettier](https://prettier.io/). 41 | 42 | ## Usage 43 | 44 | This repository contains the following layers: 45 | 46 | - `layers/bootstrap`: resources that must exist before the other layers can be applied. 47 | - `layers/azure-keyvault`: resources to test integration with Azure Key Vault. 48 | 49 | To apply a layer, run the following commands in the layer's directory: 50 | 51 | ```bash 52 | # Download the latest version of Terraform that matches the layer's version contraints. 53 | tfswitch 54 | 55 | # Initialise the layer. 56 | terraform init 57 | 58 | # Apply the layer. 59 | terraform apply 60 | ``` 61 | 62 | ## Documentation 63 | 64 | Each directory with Terraform code in it has auto-generated documentation. To 65 | update this documentation, run this command: 66 | 67 | ```bash 68 | make docs 69 | ``` 70 | -------------------------------------------------------------------------------- /terraform/layers/aws-secrets-manager/_providers.tf: -------------------------------------------------------------------------------- 1 | # Terraform is a general purpose tool. To interact with specific APIs, it 2 | # requires users to configure plugins called "providers". 3 | # For more information: https://www.terraform.io/docs/language/providers/index.html 4 | 5 | # The "aws" provider enables us to provision cloud resources on Amazon Web 6 | # Services. 7 | provider "aws" {} 8 | 9 | # The "github" provider enables us to configure CI/CD on GitHub. 10 | provider "github" {} 11 | -------------------------------------------------------------------------------- /terraform/layers/aws-secrets-manager/_settings.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # At the root of a layer (ie, the directory where "terraform apply" is run), 3 | # best practice is to specify an exact version of Terraform to use. Use the 4 | # "= 1.2.3" constraint to do this. 5 | # 6 | # In a module, you can allow more flexibility with regards to Terraform's 7 | # minor and/or patch versions. For example, the "~> 1.0" constraint will allow 8 | # all 1.x.x versions of Terraform, while the "~> 1.0.0" constraint will allow 9 | # all 1.0.x versions. 10 | # 11 | # For more information: https://www.terraform.io/docs/language/settings/index.html#specifying-a-required-terraform-version 12 | required_version = "~> 1.4" 13 | 14 | # Terraform keeps track of all resources it knows of in its state. This state 15 | # can be stored remotely in a "backend". 16 | # For more information on state backends: https://www.terraform.io/docs/language/settings/backends/index.html 17 | # For more information on the "s3" backend: https://www.terraform.io/docs/language/settings/backends/s3.html 18 | backend "s3" { 19 | bucket = "busser-murmur-tfstate" 20 | key = "aws-secrets-manager" 21 | region = "fr-par" 22 | endpoint = "https://s3.fr-par.scw.cloud" 23 | profile = "scaleway" 24 | # We are swapping the AWS S3 API for the Scaleway S3 API, so we need to 25 | # skip certain validation steps. 26 | skip_credentials_validation = true 27 | skip_region_validation = true 28 | skip_requesting_account_id = true 29 | } 30 | 31 | # This layer requires that certain providers be configured by the caller. 32 | # For more information: https://www.terraform.io/docs/language/providers/requirements.html 33 | required_providers { 34 | aws = { 35 | source = "hashicorp/aws" 36 | version = "5.0.1" 37 | } 38 | github = { 39 | source = "integrations/github" 40 | version = "6.2.1" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /terraform/layers/aws-secrets-manager/github_actions.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_openid_connect_provider" "github_actions" { 2 | url = "https://token.actions.githubusercontent.com" 3 | 4 | client_id_list = [ 5 | "sts.amazonaws.com", 6 | ] 7 | 8 | thumbprint_list = [ 9 | "6938fd4d98bab03faadb97b34396831e3780aea1" 10 | ] 11 | } 12 | 13 | resource "aws_iam_role" "github_actions" { 14 | name = "GithubActions" 15 | 16 | assume_role_policy = jsonencode({ 17 | Version = "2012-10-17" 18 | Statement = { 19 | Effect = "Allow" 20 | Action = [ 21 | "sts:AssumeRoleWithWebIdentity" 22 | ] 23 | Principal = { 24 | Federated = aws_iam_openid_connect_provider.github_actions.arn 25 | } 26 | Condition = { 27 | StringLike = { 28 | "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com", 29 | "token.actions.githubusercontent.com:sub" = "repo:busser/murmur:*" 30 | } 31 | } 32 | } 33 | }) 34 | } 35 | 36 | resource "aws_iam_role_policy" "github_actions_read_secret" { 37 | name = "github-actions-read-secret" 38 | role = aws_iam_role.github_actions.id 39 | 40 | policy = jsonencode({ 41 | Version = "2012-10-17" 42 | Statement = [ 43 | { 44 | Action = [ 45 | "secretsmanager:GetSecretValue", 46 | ] 47 | Effect = "Allow" 48 | Resource = [ 49 | aws_secretsmanager_secret.example.arn, 50 | ] 51 | }, 52 | ] 53 | }) 54 | } 55 | 56 | # resource "aws_secretsmanager_secret_policy" "github_actions_read_secret" { 57 | # secret_arn = aws_secretsmanager_secret.example.arn 58 | 59 | # policy = jsonencode({ 60 | # Version = "2012-10-17" 61 | # Statement = { 62 | # Effect = "Allow" 63 | # Action = "secretsmanager:GetSecretValue" 64 | # Principal = { 65 | # AWS = aws_iam_role.github_actions.arn 66 | # } 67 | # Resource = "*" 68 | # } 69 | # }) 70 | # } 71 | -------------------------------------------------------------------------------- /terraform/layers/aws-secrets-manager/secret.tf: -------------------------------------------------------------------------------- 1 | resource "aws_secretsmanager_secret" "example" { 2 | name = "secret-sauce" 3 | } 4 | 5 | resource "aws_secretsmanager_secret_version" "ketchup" { 6 | secret_id = aws_secretsmanager_secret.example.id 7 | secret_string = "ketchup" 8 | 9 | version_stages = [ 10 | "v1", 11 | ] 12 | } 13 | 14 | resource "aws_secretsmanager_secret_version" "szechuan" { 15 | secret_id = aws_secretsmanager_secret.example.id 16 | secret_string = "szechuan" 17 | 18 | version_stages = [ 19 | "v2", 20 | "AWSCURRENT", 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /terraform/layers/azure-keyvault/_providers.tf: -------------------------------------------------------------------------------- 1 | # Terraform is a general purpose tool. To interact with specific APIs, it 2 | # requires users to configure plugins called "providers". 3 | # For more information: https://www.terraform.io/docs/language/providers/index.html 4 | 5 | # The "azurerm" provider enables us to provision cloud resources on Azure. 6 | provider "azurerm" { 7 | features {} 8 | 9 | # "Default subscription" subscription 10 | subscription_id = "8ab3da27-5e1b-494f-abc6-726fb04729b3" 11 | 12 | # We don't need to register any resource providers. 13 | # For more information: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs#skip_provider_registration 14 | skip_provider_registration = false 15 | } 16 | 17 | # The "azuread" provider enables us to provision resources in Azure Active 18 | # Directory. 19 | provider "azuread" { 20 | # Default Directory 21 | tenant_id = "0581e2b2-19ee-4e7c-94f7-d3e38a2409df" 22 | } 23 | 24 | # The "github" provider enables us to configure CI/CD on GitHub. 25 | provider "github" {} 26 | -------------------------------------------------------------------------------- /terraform/layers/azure-keyvault/_settings.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # At the root of a layer (ie, the directory where "terraform apply" is run), 3 | # best practice is to specify an exact version of Terraform to use. Use the 4 | # "= 1.2.3" constraint to do this. 5 | # 6 | # In a module, you can allow more flexibility with regards to Terraform's 7 | # minor and/or patch versions. For example, the "~> 1.0" constraint will allow 8 | # all 1.x.x versions of Terraform, while the "~> 1.0.0" constraint will allow 9 | # all 1.0.x versions. 10 | # 11 | # For more information: https://www.terraform.io/docs/language/settings/index.html#specifying-a-required-terraform-version 12 | required_version = "~> 1.4" 13 | 14 | # Terraform keeps track of all resources it knows of in its state. This state 15 | # can be stored remotely in a "backend". 16 | # For more information on state backends: https://www.terraform.io/docs/language/settings/backends/index.html 17 | # For more information on the "s3" backend: https://www.terraform.io/docs/language/settings/backends/s3.html 18 | backend "s3" { 19 | bucket = "busser-murmur-tfstate" 20 | key = "azurerm-keyvault" 21 | region = "fr-par" 22 | endpoint = "https://s3.fr-par.scw.cloud" 23 | profile = "scaleway" 24 | # We are swapping the AWS S3 API for the Scaleway S3 API, so we need to 25 | # skip certain validation steps. 26 | skip_credentials_validation = true 27 | skip_region_validation = true 28 | skip_requesting_account_id = true 29 | } 30 | 31 | # This layer requires that certain providers be configured by the caller. 32 | # For more information: https://www.terraform.io/docs/language/providers/requirements.html 33 | required_providers { 34 | azurerm = { 35 | source = "hashicorp/azurerm" 36 | version = "3.58.0" 37 | } 38 | azuread = { 39 | source = "hashicorp/azuread" 40 | version = "2.39.0" 41 | } 42 | github = { 43 | source = "integrations/github" 44 | version = "6.2.1" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /terraform/layers/azure-keyvault/client_configs.tf: -------------------------------------------------------------------------------- 1 | data "azurerm_client_config" "current" {} 2 | 3 | data "azuread_client_config" "current" {} 4 | -------------------------------------------------------------------------------- /terraform/layers/azure-keyvault/github_actions.tf: -------------------------------------------------------------------------------- 1 | // The repository's continuous integration pipelines run murmur's end-to-end 2 | // tests. These tests require credentials that can read secrets from our Key 3 | // Vaults. 4 | 5 | // The pipelines authenticate to Azure with a service principal. 6 | 7 | resource "azuread_application" "github_actions" { 8 | display_name = "murmur-github-actions" 9 | owners = [data.azuread_client_config.current.object_id] 10 | } 11 | 12 | resource "azuread_service_principal" "github_actions" { 13 | application_id = azuread_application.github_actions.application_id 14 | app_role_assignment_required = false 15 | owners = [data.azuread_client_config.current.object_id] 16 | } 17 | 18 | resource "azuread_service_principal_password" "github_actions" { 19 | service_principal_id = azuread_service_principal.github_actions.object_id 20 | } 21 | 22 | // The necessary credentials are stored in this repository's Github Actions 23 | // secrets. Pipelines use these secrets to set environment variables used by 24 | // murmur. 25 | 26 | data "github_repository" "murmur" { 27 | name = "murmur" 28 | } 29 | 30 | resource "github_actions_secret" "tenant_id" { 31 | repository = data.github_repository.murmur.name 32 | secret_name = "AZURE_TENANT_ID" 33 | plaintext_value = data.azuread_client_config.current.tenant_id 34 | } 35 | 36 | resource "github_actions_secret" "client_id" { 37 | repository = data.github_repository.murmur.name 38 | secret_name = "AZURE_CLIENT_ID" 39 | plaintext_value = azuread_service_principal.github_actions.application_id 40 | } 41 | 42 | resource "github_actions_secret" "client_secret" { 43 | repository = data.github_repository.murmur.name 44 | secret_name = "AZURE_CLIENT_SECRET" 45 | plaintext_value = azuread_service_principal_password.github_actions.value 46 | } 47 | -------------------------------------------------------------------------------- /terraform/layers/azure-keyvault/keyvault_secrets.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_resource_group" "murmur" { 2 | name = "murmur" 3 | location = "West Europe" 4 | } 5 | 6 | // We have multiple Key Vaults because murmur supports fetching secrets from 7 | // multiple Key Vaults at once. 8 | resource "azurerm_key_vault" "murmur" { 9 | for_each = toset(["alpha", "bravo"]) 10 | 11 | name = "murmur-${each.key}" 12 | 13 | tenant_id = data.azurerm_client_config.current.tenant_id 14 | location = azurerm_resource_group.murmur.location 15 | resource_group_name = azurerm_resource_group.murmur.name 16 | 17 | soft_delete_retention_days = 7 18 | enable_rbac_authorization = true 19 | 20 | sku_name = "standard" 21 | } 22 | 23 | // This secret has multiple versions because murmur supports fetching any 24 | // version of a secret. The secret's version IDs are hard-coded in murmur's 25 | // end-to-end tests. 26 | resource "azurerm_key_vault_secret" "example" { 27 | for_each = azurerm_key_vault.murmur 28 | 29 | name = "secret-sauce" 30 | value = "szechuan" // Was previously applied with value "ketchup". 31 | key_vault_id = azurerm_key_vault.murmur[each.key].id 32 | 33 | depends_on = [ 34 | azurerm_role_assignment.keyvault_admin, 35 | ] 36 | } 37 | 38 | // Infrastructure is managed by @busser. To date, he is the only person with 39 | // write access to cloud resources used by murmur. 40 | resource "azurerm_role_assignment" "keyvault_admin" { 41 | for_each = azurerm_key_vault.murmur 42 | 43 | scope = azurerm_key_vault.murmur[each.key].id 44 | principal_id = data.azurerm_client_config.current.object_id 45 | role_definition_name = "Key Vault Administrator" 46 | } 47 | 48 | // The repository's continuous integrations pipelines read secrets from our Key 49 | // Vaults when running murmur's end-to-end tests. 50 | resource "azurerm_role_assignment" "github_actions" { 51 | for_each = azurerm_key_vault.murmur 52 | 53 | scope = azurerm_key_vault.murmur[each.key].id 54 | principal_id = azuread_service_principal.github_actions.object_id 55 | role_definition_name = "Key Vault Secrets User" 56 | } 57 | -------------------------------------------------------------------------------- /terraform/layers/bootstrap/README.md: -------------------------------------------------------------------------------- 1 | # Bootstrap layer 2 | 3 | This layer creates Scaleway buckets where the other layers' state will be stored. 4 | 5 | > ⚠️ WARNING ⚠️ 6 | > 7 | > The project should only need to be bootstrapped once. If you don't now what 8 | > bootstrapping means, don't do it. 9 | 10 | Because this layer's state contains no sensitive information, we store this 11 | state in this repository. 12 | 13 | > ⚠️ WARNING ⚠️ 14 | > 15 | > Make sure that no sensitive information is ever stored in this repository's 16 | > state. 17 | 18 | 19 | 20 | ## Requirements 21 | 22 | | Name | Version | 23 | | ------------------------------------------------------------------------ | ---------- | 24 | | [terraform](#requirement_terraform) | = 1.1.9 | 25 | | [scaleway](#requirement_scaleway) | 2.2.1-rc.3 | 26 | 27 | ## Providers 28 | 29 | | Name | Version | 30 | | --------------------------------------------------------------- | ---------- | 31 | | [scaleway](#provider_scaleway) | 2.2.1-rc.3 | 32 | 33 | ## Modules 34 | 35 | No modules. 36 | 37 | ## Resources 38 | 39 | | Name | Type | 40 | | ------------------------------------------------------------------------------------------------------------------------------------------- | -------- | 41 | | [scaleway_object_bucket.terraform_state](https://registry.terraform.io/providers/scaleway/scaleway/2.2.1-rc.3/docs/resources/object_bucket) | resource | 42 | 43 | ## Inputs 44 | 45 | No inputs. 46 | 47 | ## Outputs 48 | 49 | No outputs. 50 | 51 | 52 | -------------------------------------------------------------------------------- /terraform/layers/bootstrap/_providers.tf: -------------------------------------------------------------------------------- 1 | # Terraform is a general purpose tool. To interact with specific APIs, it 2 | # requires users to configure plugins called "providers". 3 | # For more information: https://www.terraform.io/docs/language/providers/index.html 4 | 5 | # The "scaleway" provider enables us to provision cloud resources on Scaleway. 6 | provider "scaleway" { 7 | project_id = "5e83ac90-5df2-4c7d-98ba-ef50ff4d148a" # Cloudlab 8 | region = "fr-par" 9 | zone = "fr-par-1" 10 | } 11 | -------------------------------------------------------------------------------- /terraform/layers/bootstrap/_settings.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # At the root of a layer (ie, the directory where "terraform apply" is run), 3 | # best practice is to specify an exact version of Terraform to use. Use the 4 | # "= 1.2.3" constraint to do this. 5 | # 6 | # In a module, you can allow more flexibility with regards to Terraform's 7 | # minor and/or patch versions. For example, the "~> 1.0" constraint will allow 8 | # all 1.x.x versions of Terraform, while the "~> 1.0.0" constraint will allow 9 | # all 1.0.x versions. 10 | # 11 | # For more information: https://www.terraform.io/docs/language/settings/index.html#specifying-a-required-terraform-version 12 | required_version = "~> 1.4" 13 | 14 | # This layer's state is stored locally and persisted in the git repository. 15 | backend "local" {} 16 | 17 | # This layer requires that certain providers be configured by the caller. 18 | # For more information: https://www.terraform.io/docs/language/providers/requirements.html 19 | required_providers { 20 | scaleway = { 21 | source = "scaleway/scaleway" 22 | version = "2.19.0" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /terraform/layers/bootstrap/state-bucket.tf: -------------------------------------------------------------------------------- 1 | # Other Terraform layers' state is stored in this bucket. Each layer should use 2 | # a different sub-path. 3 | resource "scaleway_object_bucket" "terraform_state" { 4 | name = "busser-murmur-tfstate" 5 | versioning { 6 | enabled = true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/layers/bootstrap/terraform.tfstate: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "1.4.6", 4 | "serial": 11, 5 | "lineage": "891a092d-d945-9809-28a0-4f40f90dbd55", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "scaleway_object_bucket", 11 | "name": "terraform_state", 12 | "provider": "provider[\"registry.terraform.io/scaleway/scaleway\"]", 13 | "instances": [ 14 | { 15 | "schema_version": 0, 16 | "attributes": { 17 | "acl": "private", 18 | "cors_rule": [], 19 | "endpoint": "https://busser-murmur-tfstate.s3.fr-par.scw.cloud", 20 | "force_destroy": false, 21 | "id": "fr-par/busser-murmur-tfstate", 22 | "lifecycle_rule": [], 23 | "name": "busser-murmur-tfstate", 24 | "object_lock_enabled": false, 25 | "region": "fr-par", 26 | "tags": {}, 27 | "timeouts": null, 28 | "versioning": [ 29 | { 30 | "enabled": true 31 | } 32 | ] 33 | }, 34 | "sensitive_attributes": [], 35 | "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlZmF1bHQiOjYwMDAwMDAwMDAwMCwiZGVsZXRlIjo2MDAwMDAwMDAwMDAsInJlYWQiOjYwMDAwMDAwMDAwMCwidXBkYXRlIjo2MDAwMDAwMDAwMDB9fQ==" 36 | } 37 | ] 38 | } 39 | ], 40 | "check_results": null 41 | } 42 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/_providers.tf: -------------------------------------------------------------------------------- 1 | # Terraform is a general purpose tool. To interact with specific APIs, it 2 | # requires users to configure plugins called "providers". 3 | # For more information: https://www.terraform.io/docs/language/providers/index.html 4 | 5 | # The "google" provider enables us to provision cloud resources on Google Cloud 6 | # Platform. 7 | provider "google" { 8 | project = "murmur-tests" 9 | region = "europe-west9" 10 | } 11 | 12 | # The "google-beta" provider enables us to use features of Google Cloud Platform 13 | # that are still in beta. The use of beta features should generally be kept to a 14 | # minimum, but Google's betas are overall very stable. 15 | provider "google-beta" { 16 | project = "murmur-tests" 17 | region = "europe-west9" 18 | } 19 | 20 | # The "github" provider enables us to configure CI/CD on GitHub. 21 | provider "github" {} 22 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/_settings.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # At the root of a layer (ie, the directory where "terraform apply" is run), 3 | # best practice is to specify an exact version of Terraform to use. Use the 4 | # "= 1.2.3" constraint to do this. 5 | # 6 | # In a module, you can allow more flexibility with regards to Terraform's 7 | # minor and/or patch versions. For example, the "~> 1.0" constraint will allow 8 | # all 1.x.x versions of Terraform, while the "~> 1.0.0" constraint will allow 9 | # all 1.0.x versions. 10 | # 11 | # For more information: https://www.terraform.io/docs/language/settings/index.html#specifying-a-required-terraform-version 12 | required_version = "~> 1.4" 13 | 14 | # Terraform keeps track of all resources it knows of in its state. This state 15 | # can be stored remotely in a "backend". 16 | # For more information on state backends: https://www.terraform.io/docs/language/settings/backends/index.html 17 | # For more information on the "s3" backend: https://www.terraform.io/docs/language/settings/backends/s3.html 18 | backend "s3" { 19 | bucket = "busser-murmur-tfstate" 20 | key = "gcp-secret-manager" 21 | region = "fr-par" 22 | endpoint = "https://s3.fr-par.scw.cloud" 23 | profile = "scaleway" 24 | # We are swapping the AWS S3 API for the Scaleway S3 API, so we need to 25 | # skip certain validation steps. 26 | skip_credentials_validation = true 27 | skip_region_validation = true 28 | skip_requesting_account_id = true 29 | } 30 | 31 | # This layer requires that certain providers be configured by the caller. 32 | # For more information: https://www.terraform.io/docs/language/providers/requirements.html 33 | required_providers { 34 | google = { 35 | source = "hashicorp/google" 36 | version = "5.23.0" 37 | } 38 | google-beta = { 39 | source = "hashicorp/google-beta" 40 | version = "5.23.0" 41 | } 42 | github = { 43 | source = "integrations/github" 44 | version = "6.2.1" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/github_actions.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "github_actions" { 2 | account_id = "github-actions" 3 | 4 | display_name = "Github Actions" 5 | } 6 | 7 | resource "google_secret_manager_secret_iam_member" "github_actions_access_secret" { 8 | secret_id = google_secret_manager_secret.example.secret_id 9 | 10 | role = "roles/secretmanager.secretAccessor" 11 | member = "serviceAccount:${google_service_account.github_actions.email}" 12 | } 13 | 14 | # We use workload identity to enable keyless authentication from murmur's 15 | # Github Actions workflows. 16 | resource "google_iam_workload_identity_pool" "default" { 17 | provider = google-beta 18 | 19 | workload_identity_pool_id = "default" 20 | } 21 | 22 | resource "google_iam_workload_identity_pool_provider" "github_oidc" { 23 | provider = google-beta 24 | project = local.google_project 25 | 26 | workload_identity_pool_provider_id = "github-oidc" 27 | 28 | workload_identity_pool_id = google_iam_workload_identity_pool.default.workload_identity_pool_id 29 | attribute_mapping = { 30 | "google.subject" = "assertion.sub" 31 | "attribute.actor" = "assertion.actor" 32 | "attribute.aud" = "assertion.aud" 33 | "attribute.repository" = "assertion.repository" 34 | } 35 | 36 | attribute_condition = "attribute.repository == 'busser/murmur'" 37 | 38 | oidc { 39 | issuer_uri = "https://token.actions.githubusercontent.com" 40 | } 41 | } 42 | 43 | # Murmur's Github Actions workflows use a dedicated Google service account 44 | # to interact with the Google API and access secret versions. 45 | resource "google_service_account_iam_member" "github_actions_workload_identity" { 46 | service_account_id = google_service_account.github_actions.id 47 | 48 | role = "roles/iam.workloadIdentityUser" 49 | member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.default.name}/attribute.repository/busser/murmur" 50 | } 51 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/google.tf: -------------------------------------------------------------------------------- 1 | data "google_client_config" "current" {} 2 | 3 | locals { 4 | google_project = data.google_client_config.current.project 5 | } 6 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/project_services.tf: -------------------------------------------------------------------------------- 1 | # Required for Github Actions to use workload identity. 2 | resource "google_project_service" "iamcredentials" { 3 | service = "iamcredentials.googleapis.com" 4 | disable_on_destroy = false 5 | } 6 | 7 | resource "google_project_service" "secretmanager" { 8 | service = "secretmanager.googleapis.com" 9 | disable_on_destroy = false 10 | } 11 | -------------------------------------------------------------------------------- /terraform/layers/gcp-secret-manager/secret.tf: -------------------------------------------------------------------------------- 1 | resource "google_secret_manager_secret" "example" { 2 | secret_id = "secret-sauce" 3 | 4 | replication { 5 | auto {} 6 | } 7 | 8 | depends_on = [ 9 | google_project_service.secretmanager, 10 | ] 11 | } 12 | 13 | resource "google_secret_manager_secret_version" "ketchup" { 14 | secret = google_secret_manager_secret.example.id 15 | 16 | secret_data = "ketchup" 17 | } 18 | 19 | resource "google_secret_manager_secret_version" "szechuan" { 20 | secret = google_secret_manager_secret.example.id 21 | 22 | secret_data = "szechuan" 23 | 24 | depends_on = [ 25 | // This controls the order in which the versions are created. 26 | google_secret_manager_secret_version.ketchup, 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /terraform/layers/scw-secret-manager/_providers.tf: -------------------------------------------------------------------------------- 1 | # Terraform is a general purpose tool. To interact with specific APIs, it 2 | # requires users to configure plugins called "providers". 3 | # For more information: https://www.terraform.io/docs/language/providers/index.html 4 | 5 | # The "scaleway" provider enables us to provision cloud resources on Scaleway. 6 | provider "scaleway" { 7 | project_id = "5e83ac90-5df2-4c7d-98ba-ef50ff4d148a" # Cloudlab 8 | region = "fr-par" 9 | zone = "fr-par-1" 10 | } 11 | 12 | # The "github" provider enables us to configure CI/CD on GitHub. 13 | provider "github" {} 14 | -------------------------------------------------------------------------------- /terraform/layers/scw-secret-manager/_settings.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | # At the root of a layer (ie, the directory where "terraform apply" is run), 3 | # best practice is to specify an exact version of Terraform to use. Use the 4 | # "= 1.2.3" constraint to do this. 5 | # 6 | # In a module, you can allow more flexibility with regards to Terraform's 7 | # minor and/or patch versions. For example, the "~> 1.0" constraint will allow 8 | # all 1.x.x versions of Terraform, while the "~> 1.0.0" constraint will allow 9 | # all 1.0.x versions. 10 | # 11 | # For more information: https://www.terraform.io/docs/language/settings/index.html#specifying-a-required-terraform-version 12 | required_version = "~> 1.4" 13 | 14 | # Terraform keeps track of all resources it knows of in its state. This state 15 | # can be stored remotely in a "backend". 16 | # For more information on state backends: https://www.terraform.io/docs/language/settings/backends/index.html 17 | # For more information on the "s3" backend: https://www.terraform.io/docs/language/settings/backends/s3.html 18 | backend "s3" { 19 | bucket = "busser-murmur-tfstate" 20 | key = "scw-secret-manager" 21 | region = "fr-par" 22 | endpoint = "https://s3.fr-par.scw.cloud" 23 | profile = "scaleway" 24 | # We are swapping the AWS S3 API for the Scaleway S3 API, so we need to 25 | # skip certain validation steps. 26 | skip_credentials_validation = true 27 | skip_region_validation = true 28 | skip_requesting_account_id = true 29 | } 30 | 31 | # This layer requires that certain providers be configured by the caller. 32 | # For more information: https://www.terraform.io/docs/language/providers/requirements.html 33 | required_providers { 34 | scaleway = { 35 | source = "scaleway/scaleway" 36 | version = "2.19.0" 37 | } 38 | github = { 39 | source = "integrations/github" 40 | version = "6.2.1" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /terraform/layers/scw-secret-manager/github_actions.tf: -------------------------------------------------------------------------------- 1 | data "scaleway_account_project" "current" { 2 | project_id = "5e83ac90-5df2-4c7d-98ba-ef50ff4d148a" # Cloudlab 3 | } 4 | 5 | resource "scaleway_iam_application" "github_actions" { 6 | name = "murmur-github-actions" 7 | description = "Github Actions (busser/murmur)" 8 | } 9 | 10 | resource "scaleway_iam_api_key" "github_actions" { 11 | application_id = scaleway_iam_application.github_actions.id 12 | description = "Used by Github Actions (busser/murmur)" 13 | } 14 | 15 | resource "scaleway_iam_group" "secrets_readers" { 16 | name = "secrets-readers" 17 | description = "members can read all secrets" 18 | application_ids = [ 19 | scaleway_iam_application.github_actions.id 20 | ] 21 | } 22 | 23 | resource "scaleway_iam_policy" "secrets_readers" { 24 | name = "secrets-readers" 25 | description = "grants read-only access to all secrets" 26 | group_id = scaleway_iam_group.secrets_readers.id 27 | rule { 28 | project_ids = [ 29 | data.scaleway_account_project.current.id, 30 | ] 31 | permission_set_names = [ 32 | # Must grant full access because the ReadOnly permission set does not 33 | # grant access to secret values. 34 | # Discussion started on Slack here: 35 | # https://scaleway-community.slack.com/archives/C04KGMME3U1/p1682867139987979 36 | "SecretManagerFullAccess", 37 | # "SecretManagerReadOnly", 38 | ] 39 | } 40 | } 41 | 42 | // The necessary credentials are stored in this repository's Github Actions 43 | // secrets. Pipelines use these secrets to set environment variables used by 44 | // murmur. 45 | 46 | data "github_repository" "murmur" { 47 | name = "murmur" 48 | } 49 | 50 | resource "github_actions_secret" "access_key" { 51 | repository = data.github_repository.murmur.name 52 | secret_name = "SCW_ACCESS_KEY" 53 | plaintext_value = scaleway_iam_api_key.github_actions.access_key 54 | } 55 | 56 | resource "github_actions_secret" "secret_key" { 57 | repository = data.github_repository.murmur.name 58 | secret_name = "SCW_SECRET_KEY" 59 | plaintext_value = scaleway_iam_api_key.github_actions.secret_key 60 | } 61 | -------------------------------------------------------------------------------- /terraform/layers/scw-secret-manager/secret.tf: -------------------------------------------------------------------------------- 1 | resource "scaleway_secret" "example" { 2 | name = "secret-sauce" 3 | } 4 | 5 | resource "scaleway_secret_version" "ketchup" { 6 | secret_id = scaleway_secret.example.id 7 | 8 | data = "ketchup" 9 | } 10 | 11 | resource "scaleway_secret_version" "szechuan" { 12 | secret_id = scaleway_secret.example.id 13 | 14 | data = "szechuan" 15 | 16 | depends_on = [ 17 | // This controls the order in which the versions are created. 18 | scaleway_secret_version.ketchup, 19 | ] 20 | } 21 | --------------------------------------------------------------------------------