├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── go.yml │ ├── release.yml │ └── tagpr.yml ├── .gitignore ├── .goreleaser.yaml ├── .tagpr ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cli ├── cli.go └── cli_test.go ├── cmd └── ssmwrap │ └── main.go ├── examples └── lib │ └── main.go ├── go.mod ├── go.sum ├── integrated_test.go ├── internal ├── app │ ├── app.go │ ├── app_test.go │ ├── destination_rule.go │ ├── env_exporter.go │ ├── env_exporter_test.go │ ├── exporter.go │ ├── file_exporter.go │ ├── file_exporter_test.go │ ├── parameter.go │ ├── parameter_rule.go │ ├── parameter_rule_test.go │ ├── parameter_store.go │ ├── parameter_store_test.go │ ├── rule.go │ ├── rule_test.go │ ├── ssm.go │ └── ssm_test.go └── cli │ ├── env_flags.go │ ├── env_flags_test.go │ ├── file_flags.go │ ├── file_flags_test.go │ ├── rule_flags.go │ └── rule_flags_test.go ├── lib.go ├── logger.go └── ssmwrap_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | groups: 9 | aws-sdk-go-v2: 10 | patterns: 11 | - "^github.com/aws/aws-sdk-go-v2" 12 | update-types: 13 | - "minor" 14 | - "patch" 15 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - tagpr 5 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: go 2 | on: [push] 3 | jobs: 4 | test: 5 | name: build 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | go: 10 | - '1.24' 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 14 | with: 15 | go-version: ${{ matrix.go }} 16 | id: go 17 | - name: build & test 18 | run: | 19 | make test 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - "!**" 6 | tags: 7 | - "v[0-9]+.[0-9]+.[0-9]+" 8 | workflow_dispatch: ~ 9 | 10 | jobs: 11 | release: 12 | name: release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 17 | with: 18 | go-version: "1.24" 19 | - name: Run GoReleaser 20 | uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6.2.1 21 | with: 22 | version: latest 23 | args: release --clean 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | - name: release docker image 27 | run: | 28 | echo ${{ secrets.GH_PAT }} | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin 29 | make build-docker-image 30 | make push-docker-image 31 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | 3 | on: 4 | push: 5 | branches: 6 | - v2 7 | 8 | jobs: 9 | tagpr: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | with: 14 | token: ${{ secrets.GH_PAT }} 15 | - uses: Songmu/tagpr@e89d37247ca73d3e5620bf074a53fbd5b39e66b0 # v1.5.1 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GH_PAT }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | cmd/ssmwrap/ssmwrap 14 | dist/* 15 | 16 | # for dep 17 | vendor/ 18 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | main: ./cmd/ssmwrap 7 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | # config file for the tagpr in git config format 2 | # The tagpr generates the initial configuration, which you can rewrite to suit your environment. 3 | # CONFIGURATIONS: 4 | # tagpr.releaseBranch 5 | # Generally, it is "main." It is the branch for releases. The tagpr tracks this branch, 6 | # creates or updates a pull request as a release candidate, or tags when they are merged. 7 | # 8 | # tagpr.versionFile 9 | # Versioning file containing the semantic version needed to be updated at release. 10 | # It will be synchronized with the "git tag". 11 | # Often this is a meta-information file such as gemspec, setup.cfg, package.json, etc. 12 | # Sometimes the source code file, such as version.go or Bar.pm, is used. 13 | # If you do not want to use versioning files but only git tags, specify the "-" string here. 14 | # You can specify multiple version files by comma separated strings. 15 | # 16 | # tagpr.vPrefix 17 | # Flag whether or not v-prefix is added to semver when git tagging. (e.g. v1.2.3 if true) 18 | # This is only a tagging convention, not how it is described in the version file. 19 | # 20 | # tagpr.changelog (Optional) 21 | # Flag whether or not changelog is added or changed during the release. 22 | # 23 | # tagpr.command (Optional) 24 | # Command to change files just before release. 25 | # 26 | # tagpr.template (Optional) 27 | # Pull request template in go template format 28 | # 29 | # tagpr.release (Optional) 30 | # GitHub Release creation behavior after tagging [true, draft, false] 31 | # If this value is not set, the release is to be created. 32 | # 33 | # tagpr.majorLabels (Optional) 34 | # Label of major update targets. Default is [major] 35 | # 36 | # tagpr.minorLabels (Optional) 37 | # Label of minor update targets. Default is [minor] 38 | # 39 | [tagpr] 40 | vPrefix = true 41 | releaseBranch = v2 42 | versionFile = cmd/ssmwrap/main.go 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v2.2.3](https://github.com/handlename/ssmwrap/compare/v2.2.2...v2.2.3) - 2025-04-30 4 | - use the latest patch version of Go by @fujiwara in https://github.com/handlename/ssmwrap/pull/115 5 | - chore(deps): bump github.com/google/go-cmp from 0.6.0 to 0.7.0 by @dependabot in https://github.com/handlename/ssmwrap/pull/111 6 | - chore(deps): bump github.com/samber/lo from 1.47.0 to 1.49.1 by @dependabot in https://github.com/handlename/ssmwrap/pull/112 7 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ssm from 1.54.3 to 1.58.0 by @dependabot in https://github.com/handlename/ssmwrap/pull/113 8 | - chore(deps): bump github.com/lmittmann/tint from 1.0.5 to 1.0.7 by @dependabot in https://github.com/handlename/ssmwrap/pull/114 9 | 10 | ## [v2.2.2](https://github.com/handlename/ssmwrap/compare/v2.2.1...v2.2.2) - 2025-03-29 11 | - Fix: docker image build by @handlename in https://github.com/handlename/ssmwrap/pull/109 12 | 13 | ## [v2.2.1](https://github.com/handlename/ssmwrap/compare/v2.2.0...v2.2.1) - 2025-03-29 14 | - Bump up go1.24 due to CVE-2024-24791 by @comi91262 in https://github.com/handlename/ssmwrap/pull/105 15 | - chore(deps): bump github.com/lmittmann/tint from 1.0.4 to 1.0.5 by @dependabot in https://github.com/handlename/ssmwrap/pull/97 16 | - chore(deps): bump github.com/samber/lo from 1.44.0 to 1.47.0 by @dependabot in https://github.com/handlename/ssmwrap/pull/100 17 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ssm from 1.52.1 to 1.54.3 by @dependabot in https://github.com/handlename/ssmwrap/pull/102 18 | - chore(deps): bump github.com/aws/aws-sdk-go-v2 from 1.30.1 to 1.31.0 by @dependabot in https://github.com/handlename/ssmwrap/pull/103 19 | - Fix build error: non-consistant format string in call to fmt.Errorf by @handlename in https://github.com/handlename/ssmwrap/pull/107 20 | - Update and pin actions by @handlename in https://github.com/handlename/ssmwrap/pull/108 21 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.27.23 to 1.27.39 by @dependabot in https://github.com/handlename/ssmwrap/pull/104 22 | 23 | ## [v2.2.0](https://github.com/handlename/ssmwrap/compare/v2.1.0...v2.2.0) - 2024-07-01 24 | - Fix install command in README by @handlename in https://github.com/handlename/ssmwrap/pull/87 25 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ssm from 1.49.4 to 1.52.1 by @dependabot in https://github.com/handlename/ssmwrap/pull/89 26 | - chore(deps): bump github.com/aws/aws-sdk-go-v2 from 1.26.1 to 1.30.1 by @dependabot in https://github.com/handlename/ssmwrap/pull/90 27 | - chore(deps): bump github.com/samber/lo from 1.39.0 to 1.44.0 by @dependabot in https://github.com/handlename/ssmwrap/pull/91 28 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.27.12 to 1.27.23 by @dependabot in https://github.com/handlename/ssmwrap/pull/92 29 | 30 | ## [v2.1.0](https://github.com/handlename/ssmwrap/compare/v2.0.1...v2.1.0) - 2024-05-20 31 | - Skip overlapped rules by @handlename in https://github.com/handlename/ssmwrap/pull/79 32 | - Replace slog handler by @handlename in https://github.com/handlename/ssmwrap/pull/81 33 | - Release by goreleaser by @handlename in https://github.com/handlename/ssmwrap/pull/82 34 | - Update README: how to install v2 by @handlename in https://github.com/handlename/ssmwrap/pull/83 35 | 36 | ## [v2.0.1](https://github.com/handlename/ssmwrap/compare/v2.0.0...v2.0.1) - 2024-05-10 37 | - go import path for v2 by @handlename in https://github.com/handlename/ssmwrap/pull/76 38 | - Update license URL to not depend on branch name by @handlename in https://github.com/handlename/ssmwrap/pull/78 39 | - chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.27.9 to 1.27.12 by @dependabot in https://github.com/handlename/ssmwrap/pull/75 40 | 41 | ## [v2.0.0](https://github.com/handlename/ssmwrap/compare/v1.2.2...v2.0.0) - 2024-05-02 42 | - migrate to aws-sdk-go-v2 by @handlename in https://github.com/handlename/ssmwrap/pull/61 43 | - test parsing flags by @handlename in https://github.com/handlename/ssmwrap/pull/63 44 | - io/ioutil is deprecated by @handlename in https://github.com/handlename/ssmwrap/pull/67 45 | - Handle SIGINT by @handlename in https://github.com/handlename/ssmwrap/pull/68 46 | - Group dependabot PRs by @handlename in https://github.com/handlename/ssmwrap/pull/69 47 | - Reorganize flags by @handlename in https://github.com/handlename/ssmwrap/pull/70 48 | - Sort parameters on test by @handlename in https://github.com/handlename/ssmwrap/pull/73 49 | 50 | ## [v1.2.1](https://github.com/handlename/ssmwrap/compare/v1.2.1...v1.2.1) - 2024-03-22 51 | 52 | ## 1.2.0 (2022-03-03) 53 | 54 | - build with go v1.17.7 55 | - add `-env-entire-path` option #51 #53 #55 56 | - add release binaries for arm64 57 | - add lisence file #45 58 | - update package aws/aws-sdk-go 59 | 60 | ## 1.1.1 (2020-05-11) 61 | 62 | - fix panic without command #30 63 | - fix useless export for versioned name #29 64 | - update dependencies #28 65 | 66 | ## 1.1.0 (2020-05-11) 67 | 68 | - release from GitHub Actions. there are no changes for ssmwrap itself #31 69 | 70 | ## 1.0.3 (2020-01-14) 71 | 72 | - ssmwrap reads sharde config file (~/.aws/config) #24 73 | - update dependencies #23 74 | 75 | ## 1.0.2 (2019-12-26) 76 | 77 | - now, -file option enabled without -path/-names #21 #22 78 | - update dependencies #20 79 | 80 | ## 1.0.1 (2019-10-25) 81 | 82 | - update dependencies #15 #17 83 | - build with go 1.13 84 | 85 | ## 1.0.0 (2019-03-04) 86 | 87 | - add `-names` option #14 88 | - remove public function `FetchParameters` and `FetchParametersByNames` 89 | 90 | ## 0.7.0 (2019-02-12) 91 | 92 | - returns exit code 1 when error occurred #13 93 | 94 | ## 0.6.0 (2019-01-18) 95 | 96 | - add `-file` option #9 97 | - add `-recursive`/`-no-recursive` options #12 98 | 99 | ## 0.5.0 (2018-09-06) 100 | 101 | - add `-env`/`-no-env`/`-env-prefix` options #5 102 | - add library interface `Export` #6 103 | 104 | ## 0.4.0 (2018-07-13) 105 | 106 | - configurations via environment variables #4 107 | 108 | ## 0.3.1 (2018-07-11) 109 | 110 | - build without cgo. 0.2.1 is not worked... 111 | 112 | ## 0.3.0 (2018-07-04) 113 | 114 | - added -retries flag #3 115 | 116 | ## 0.2.1 (2018-06-28) 117 | 118 | - build without cgo 119 | 120 | ## 0.2.0 (2018-06-27) 121 | 122 | - ssm parameters takes precedence over the current environment variables 123 | 124 | ## 0.1.0 (2018-06-25) 125 | 126 | - First release 127 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | COPY dist/ssmwrap_linux_386_sse2/ssmwrap /usr/local/bin/ssmwrap 4 | 5 | ENTRYPOINT ["/usr/local/bin/ssmwrap"] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 NAGATA Hiroaki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=$(shell git describe --tags --always --dirty="-dev") 2 | PROJECT_USERNAME=handlename 3 | PROJECT_REPONAME=ssmwrap 4 | DIST_DIR=dist 5 | 6 | export GO111MODULE := on 7 | 8 | cmd/ssmwrap/ssmwrap: *.go */**/*.go 9 | CGO_ENABLED=0 go build -v -o $@ cmd/ssmwrap/main.go 10 | 11 | test: 12 | go test -v ./... 13 | 14 | test/integrated: 15 | go test -v -tags=integrated -run '^TestIntegrated' ./... 16 | 17 | .PHONY: build-docker-image 18 | build-docker-image: 19 | docker build \ 20 | --rm \ 21 | --tag $(PROJECT_USERNAME)/$(PROJECT_REPONAME):$(VERSION) \ 22 | --tag ghcr.io/$(PROJECT_USERNAME)/$(PROJECT_REPONAME):$(VERSION) \ 23 | . 24 | 25 | .PHONY: push-docker-image 26 | push-docker-image: 27 | docker push ghcr.io/$(PROJECT_USERNAME)/$(PROJECT_REPONAME):$(VERSION) 28 | 29 | clean: 30 | rm -rf cmd/ssmwrap/ssmwrap $(DIST_DIR)/* 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssmwrap 2 | 3 | [![Documentation](https://godoc.org/github.com/handlename/ssmwrap?status.svg)](https://godoc.org/github.com/handlename/ssmwrap) 4 | 5 | ssmwrap execute commands with output values loaded from AWS SSM Parameter Store to somewhere. 6 | 7 | Supported output targets: 8 | 9 | - environment variables 10 | - files 11 | 12 | ## Usage 13 | 14 | ```console 15 | $ ssmwrap \ 16 | -env 'path=/production/*' \ 17 | -file 'path=/production/ssl_cert,to=/etc/ssl/cert.pem,mode=0600' \ 18 | -- app 19 | ``` 20 | 21 | ## Install 22 | 23 | Download binary from [releases](https://github.com/handlename/ssmwrap/releases) 24 | 25 | or 26 | 27 | ```console 28 | $ brew install handlename/tap/ssmwrap 29 | ``` 30 | 31 | or 32 | 33 | ```console 34 | $ go install github.com/handlename/ssmwrap/v2/cmd/ssmwrap@latest 35 | ``` 36 | 37 | ## Options 38 | 39 | ```console 40 | $ ssmwrap -help 41 | Usage of ssmwrap: 42 | -env rule 43 | Alias of rule flag with `type=env`. 44 | -file rule 45 | Alias of rule flag with `type=file`. 46 | -retries int 47 | Number of times of retry. Default is 0 48 | -rule path 49 | Set rule for exporting values. multiple flags are allowed. 50 | format: path=...,type={env,file}[,to=...][,entirepath={true,false}][,prefix=...][,mode=...][,gid=...][,uid=...] 51 | parameters: 52 | path: [required] 53 | Path of parameter store. 54 | If path ends with no-slash character, only the value of the path will be exported. 55 | If `path` ends with `/**/*`, all values under the path will be exported. 56 | If `path` ends with `/*`, only top level values under the path will be exported. 57 | type: [required] 58 | Destination type. `env` or `file`. 59 | to: [required for `type=file`] 60 | Destination path. 61 | If `type=env`, `to` is name of exported environment variable. 62 | If `type=env`, but `to` is not set, `path` will be used as name of exported environment variable. 63 | If `type=file`, `to` is path of file to write. 64 | entirepath: [optional, only for `type=env`] 65 | Export entire path as environment variables name. 66 | If `entirepath=true`, all values under the path will be exported. (/path/to/param -> PATH_TO_PARAM) 67 | If `entirepath=false`, only top level values under the path will be exported. (/path/to/param -> PARAM) 68 | prefix: [optional, only for `type=env`] 69 | Prefix for exported environment variable. 70 | mode: [optional, only for `type=file`] 71 | File mode. Default is 0644. 72 | gid: [optional, only for `type=file`] 73 | Group ID of file. Default is current user's Gid. 74 | uid: [optional, only for `type=file`] 75 | User ID of file. Default is current user's Uid. 76 | -version 77 | Display version and exit 78 | ``` 79 | 80 | ### Environment Variables 81 | 82 | All of command line options can be set via environment variables. 83 | 84 | ```console 85 | $ SSMWRAP_ENV='path=/production/*' ssmwrap ... 86 | ``` 87 | 88 | is same as, 89 | 90 | ```console 91 | $ ssmwrap -env 'path=/production/*' ... 92 | ``` 93 | 94 | You can set multiple options by add suffix like '_1', '_2', '_3'... 95 | 96 | ```console 97 | $ SSMWRAP_ENV_1='path=/production/app/*' SSMWRAP_ENV_2='path=/production/db/*' ssmwrap ... 98 | ``` 99 | 100 | ## Migration from v1.x to v2.x 101 | 102 | On v2, options flags are reformed. 103 | 104 | ### Output to environment variables 105 | 106 | Flags for output to environment variables are consolidated to `-env` flag. 107 | 108 | ```conosle 109 | # v1 110 | $ ssmwrap \ 111 | -paths '/foo,/bar' \ 112 | -env-entire-path \ 113 | -- ... 114 | 115 | # v2 116 | $ ssmwrap \ 117 | -env 'path=/foo/*,entirepath=true' \ 118 | -env 'path=/bar/*,entirepath=true' \ 119 | -- ... 120 | ``` 121 | 122 | ### Output to files 123 | 124 | Flags for output to files are remaining as `-file` flag, but format is changed. 125 | 126 | ```conosle 127 | # v1 128 | $ ssmwrap -file 'Path=/foo/value,Name=/path/to/file,Mode=0600' -- ... 129 | 130 | # v2 131 | $ ssmwrap -file 'path=/foo/value,to=/path/to/file,mode=0600' -- ... 132 | ``` 133 | 134 | ### General output rules 135 | 136 | Added new flag `-rule` that can be used for all type of output. 137 | Flag `-env` and `-file` are alias of `-rule` flag. 138 | 139 | ```conosle 140 | # by -env and -file flag 141 | $ ssmwrap \ 142 | -env 'path=/foo/*' \ 143 | -file 'path=/bar/value,to=/path/to/file' \ 144 | -- ... 145 | 146 | # by -rule flag (same as above) 147 | $ ssmwrap \ 148 | -rule 'type=env,path=/foo/*' \ 149 | -rule 'type=file,path=/bar/value,to=/path/to/file' \ 150 | -- ... 151 | ``` 152 | 153 | ## Motivation 154 | 155 | There are some tools to use values stored in AWS System Manager Parameter Store, 156 | but I couldn't find that manipulate values including newline characters correctly. 157 | 158 | ssmwrap runs your command through syscall.Exec, not via shell, 159 | so newline characters are treated as part of a environment value. 160 | 161 | ## Usage as a library 162 | 163 | `ssmwrap.Export()` fetches parameters from SSM and export those to envrionment variables. 164 | Please check [example](./examples/lib/main.go). 165 | 166 | ## License 167 | 168 | see [LICENSE](https://github.com/handlename/ssmwrap?tab=MIT-1-ov-file#readme) file. 169 | 170 | ## Special Thanks 171 | 172 | @fujiwara has gave me an idea of ssmwrap. 173 | 174 | ## Author 175 | 176 | @handlename (https://github.com/handlename) 177 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "os/signal" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | "syscall" 14 | 15 | "github.com/handlename/ssmwrap/v2" 16 | "github.com/handlename/ssmwrap/v2/internal/app" 17 | "github.com/handlename/ssmwrap/v2/internal/cli" 18 | ) 19 | 20 | type ExitStatus int 21 | 22 | const ( 23 | ExitStatusOK ExitStatus = 0 24 | ExitStatusError ExitStatus = 1 25 | ) 26 | 27 | func flagViaEnv(prefix string, multiple bool) []string { 28 | if multiple { 29 | values := []string{} 30 | 31 | // read values named like `{prefix}_FOO_123` 32 | re := regexp.MustCompile("^" + prefix + `(_\d+)?$`) 33 | keys := []string{} 34 | 35 | for _, env := range os.Environ() { 36 | parts := strings.SplitN(env, "=", 2) 37 | 38 | if re.FindString(parts[0]) != "" { 39 | keys = append(keys, parts[0]) 40 | } 41 | } 42 | 43 | // sort keys for test stability 44 | sort.Strings(keys) 45 | 46 | for _, key := range keys { 47 | values = append(values, os.Getenv(key)) 48 | } 49 | 50 | return values 51 | } 52 | 53 | return []string{os.Getenv(prefix)} 54 | } 55 | 56 | type Flags struct { 57 | VersionFlag bool 58 | Retries int 59 | 60 | RuleFlags cli.RuleFlags 61 | EnvFlags cli.EnvFlags 62 | FileFlags cli.FileFlags 63 | } 64 | 65 | func parseFlags(args []string, flagEnvPrefix string) (*Flags, []string, error) { 66 | flags := &Flags{} 67 | 68 | fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) 69 | 70 | fs.BoolVar(&flags.VersionFlag, "version", false, "Display version and exit") 71 | fs.IntVar(&flags.Retries, "retries", 0, "Number of times of retry. Default is 0") 72 | fs.Var(&flags.RuleFlags, "rule", strings.Join([]string{ 73 | "Set rule for exporting values. multiple flags are allowed.", 74 | "format: path=...,type={env,file}[,to=...][,entirepath={true,false}][,prefix=...][,mode=...][,gid=...][,uid=...]", 75 | "parameters:", 76 | " path: [required]", 77 | " Path of parameter store.", 78 | " If `path` ends with no-slash character, only the value of the path will be exported.", 79 | " If `path` ends with `/**/*`, all values under the path will be exported.", 80 | " If `path` ends with `/*`, only top level values under the path will be exported.", 81 | " type: [required]", 82 | " Destination type. `env` or `file`.", 83 | " to: [required for `type=file`]", 84 | " Destination path.", 85 | " If `type=env`, `to` is name of exported environment variable.", 86 | " If `type=env`, but `to` is not set, `path` will be used as name of exported environment variable.", 87 | " If `type=file`, `to` is path of file to write.", 88 | " entirepath: [optional, only for `type=env`]", 89 | " Export entire path as environment variables name.", 90 | " If `entirepath=true`, all values under the path will be exported. (/path/to/param -> PATH_TO_PARAM)", 91 | " If `entirepath=false`, only top level values under the path will be exported. (/path/to/param -> PARAM)", 92 | " prefix: [optional, only for `type=env`]", 93 | " Prefix for exported environment variable.", 94 | " mode: [optional, only for `type=file`]", 95 | " File mode. Default is 0644.", 96 | " gid: [optional, only for `type=file`]", 97 | " Group ID of file. Default is current user's Gid.", 98 | " uid: [optional, only for `type=file`]", 99 | " User ID of file. Default is current user's Uid.", 100 | }, "\n")) 101 | fs.Var(&flags.EnvFlags, "env", "Alias of `rule` flag with `type=env`.") 102 | fs.Var(&flags.FileFlags, "file", "Alias of `rule` flag with `type=file`.") 103 | 104 | // Read flag values also from environment variable e.g. {flagEnvPrefix}_PATHS 105 | // Environment variables will be overwritten by flags. 106 | // Multiple values will be merged. 107 | fs.VisitAll(func(f *flag.Flag) { 108 | multiple := false 109 | 110 | switch f.Name { 111 | case "rule", "env", "file": 112 | multiple = true 113 | } 114 | 115 | envName := strings.ToUpper(f.Name) 116 | envName = strings.ReplaceAll(envName, "-", "_") 117 | envName = flagEnvPrefix + envName 118 | 119 | for _, value := range flagViaEnv(envName, multiple) { 120 | f.Value.Set(value) 121 | } 122 | }) 123 | 124 | if err := fs.Parse(args); err != nil { 125 | return nil, nil, err 126 | } 127 | 128 | return flags, fs.Args(), nil 129 | } 130 | 131 | // Run runs ssmwrap as a CLI, returns exit code. 132 | func Run(version string, flagEnvPrefix string) ExitStatus { 133 | ssmwrap.InitLogger() 134 | 135 | flags, restArgs, err := parseFlags(os.Args[1:], flagEnvPrefix) 136 | if err != nil { 137 | fmt.Fprintf(os.Stderr, "%s", err) 138 | return ExitStatusError 139 | } 140 | 141 | if flags.VersionFlag { 142 | fmt.Printf("ssmwrap v%s\n", version) 143 | return ExitStatusOK 144 | } 145 | 146 | command := restArgs 147 | if (0 < len(command)) && (command[0] == "--") { 148 | command = command[1:] 149 | } 150 | if len(command) == 0 { 151 | fmt.Fprintln(os.Stderr, "command required in arguments") 152 | return ExitStatusError 153 | } 154 | 155 | rules := []app.Rule{} 156 | rules = append(rules, flags.RuleFlags.Rules...) 157 | rules = append(rules, flags.EnvFlags.Rules...) 158 | rules = append(rules, flags.FileFlags.Rules...) 159 | if len(rules) == 0 { 160 | fmt.Fprintf(os.Stderr, "At least one rule required\n") 161 | return ExitStatusError 162 | } 163 | 164 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT) 165 | defer stop() 166 | 167 | sw := app.NewSSMWrap() 168 | if flags.Retries != 0 { 169 | sw.Retries = flags.Retries 170 | } 171 | 172 | if err := sw.Run(ctx, rules, command); err != nil { 173 | if errors.Is(err, context.Canceled) { 174 | fmt.Fprintf(os.Stderr, "Interrupted\n") 175 | } else { 176 | fmt.Fprintf(os.Stderr, "Error occurred: %s\n", err) 177 | } 178 | 179 | return ExitStatusError 180 | } 181 | 182 | return ExitStatusOK 183 | } 184 | -------------------------------------------------------------------------------- /cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/handlename/ssmwrap/v2/internal/app" 11 | "github.com/handlename/ssmwrap/v2/internal/cli" 12 | "github.com/samber/lo" 13 | ) 14 | 15 | func mustAbsPath(t *testing.T, path string) string { 16 | abs, err := filepath.Abs(path) 17 | if err != nil { 18 | t.Fatalf("failed to get absolute path: %s", err) 19 | } 20 | 21 | return abs 22 | } 23 | 24 | func TestParseFlag(t *testing.T) { 25 | flagEnvPrefix := "SSMWRAP_TEST_" 26 | 27 | envRules := lo.Times(3, func(i int) app.Rule { 28 | return app.Rule{ 29 | ParameterRule: app.ParameterRule{ 30 | Path: fmt.Sprintf("/path/to/env/param%d/", i), 31 | Level: app.ParameterLevelUnder, 32 | }, 33 | DestinationRule: app.DestinationRule{ 34 | Type: app.DestinationTypeEnv, 35 | To: "", 36 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 37 | Prefix: "", 38 | EntirePath: false, 39 | }, 40 | }, 41 | } 42 | }) 43 | 44 | fileRules := lo.Times(3, func(i int) app.Rule { 45 | return app.Rule{ 46 | ParameterRule: app.ParameterRule{ 47 | Path: fmt.Sprintf("/path/to/file/param%d/", i), 48 | Level: app.ParameterLevelStrict, 49 | }, 50 | DestinationRule: app.DestinationRule{ 51 | Type: app.DestinationTypeFile, 52 | To: fmt.Sprintf("/path/to/file%d", i), 53 | TypeFileOptions: &app.DestinationTypeFileOptions{ 54 | Mode: 0, 55 | Uid: 0, 56 | Gid: 0, 57 | }, 58 | }, 59 | } 60 | }) 61 | 62 | tests := []struct { 63 | name string 64 | flags []string 65 | envs map[string]string 66 | expected *Flags 67 | }{ 68 | { 69 | name: "valid: flags", 70 | flags: []string{ 71 | "-retries", "3", 72 | "-rule", envRules[0].String(), 73 | "-rule", fileRules[0].String(), 74 | "-env", envRules[1].String(), 75 | "-env", envRules[2].String(), 76 | "-file", fileRules[1].String(), 77 | "-file", fileRules[2].String(), 78 | }, 79 | expected: &Flags{ 80 | VersionFlag: false, 81 | Retries: 3, 82 | RuleFlags: cli.RuleFlags{ 83 | Rules: []app.Rule{ 84 | envRules[0], 85 | fileRules[0], 86 | }, 87 | }, 88 | EnvFlags: cli.EnvFlags{ 89 | RuleFlags: cli.RuleFlags{ 90 | Rules: []app.Rule{ 91 | envRules[1], 92 | envRules[2], 93 | }, 94 | }, 95 | }, 96 | FileFlags: cli.FileFlags{ 97 | RuleFlags: cli.RuleFlags{ 98 | Rules: []app.Rule{ 99 | fileRules[1], 100 | fileRules[2], 101 | }, 102 | }, 103 | }, 104 | }, 105 | }, 106 | { 107 | name: "valid: envs", 108 | envs: map[string]string{ 109 | flagEnvPrefix + "RULE_1": envRules[0].String(), 110 | flagEnvPrefix + "RULE_2": fileRules[0].String(), 111 | flagEnvPrefix + "ENV_1": envRules[1].String(), 112 | flagEnvPrefix + "ENV_2": envRules[2].String(), 113 | flagEnvPrefix + "FILE_1": fileRules[1].String(), 114 | flagEnvPrefix + "FILE_2": fileRules[2].String(), 115 | }, 116 | expected: &Flags{ 117 | VersionFlag: false, 118 | Retries: 0, 119 | RuleFlags: cli.RuleFlags{ 120 | Rules: []app.Rule{ 121 | envRules[0], 122 | fileRules[0], 123 | }, 124 | }, 125 | EnvFlags: cli.EnvFlags{ 126 | RuleFlags: cli.RuleFlags{ 127 | Rules: []app.Rule{ 128 | envRules[1], 129 | envRules[2], 130 | }, 131 | }, 132 | }, 133 | FileFlags: cli.FileFlags{ 134 | RuleFlags: cli.RuleFlags{ 135 | Rules: []app.Rule{ 136 | fileRules[1], 137 | fileRules[2], 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | { 144 | name: "valid: flags & envs", 145 | flags: []string{ 146 | // will overwrite env 147 | "-retries", "3", 148 | 149 | // will be merged 150 | "-rule", fileRules[0].String(), 151 | "-env", envRules[1].String(), 152 | "-file", fileRules[1].String(), 153 | }, 154 | envs: map[string]string{ 155 | // will be overwritten by flag 156 | flagEnvPrefix + "RETRIES": "5", 157 | 158 | // will be merged 159 | flagEnvPrefix + "RULE": envRules[0].String(), 160 | flagEnvPrefix + "ENV": envRules[2].String(), 161 | flagEnvPrefix + "FILE": fileRules[2].String(), 162 | }, 163 | expected: &Flags{ 164 | VersionFlag: false, 165 | Retries: 3, 166 | RuleFlags: cli.RuleFlags{ 167 | Rules: []app.Rule{ 168 | envRules[0], 169 | fileRules[0], 170 | }, 171 | }, 172 | EnvFlags: cli.EnvFlags{ 173 | RuleFlags: cli.RuleFlags{ 174 | Rules: []app.Rule{ 175 | envRules[2], 176 | envRules[1], 177 | }, 178 | }, 179 | }, 180 | FileFlags: cli.FileFlags{ 181 | RuleFlags: cli.RuleFlags{ 182 | Rules: []app.Rule{ 183 | fileRules[2], 184 | fileRules[1], 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | } 191 | 192 | for _, tt := range tests { 193 | t.Run(tt.name, func(t *testing.T) { 194 | // reset flags 195 | flag.CommandLine = flag.NewFlagSet("ssmwrap", flag.ExitOnError) 196 | 197 | if tt.envs != nil { 198 | for k, v := range tt.envs { 199 | t.Setenv(k, v) 200 | } 201 | } 202 | 203 | parsedFlags, _, err := parseFlags(tt.flags, flagEnvPrefix) 204 | if err != nil { 205 | t.Errorf("unexpected error: %s", err) 206 | } 207 | 208 | // test.expected.FixOrder() 209 | if diff := cmp.Diff(tt.expected, parsedFlags); diff != "" { 210 | t.Errorf("unexpected result: %s", diff) 211 | } 212 | }) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /cmd/ssmwrap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/handlename/ssmwrap/v2/cli" 7 | ) 8 | 9 | const version = "2.2.3" 10 | 11 | const FlagEnvPrefix = "SSMWRAP_" 12 | 13 | func main() { 14 | os.Exit(int(cli.Run(version, FlagEnvPrefix))) 15 | } 16 | -------------------------------------------------------------------------------- /examples/lib/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/handlename/ssmwrap/v2" 11 | ) 12 | 13 | func main() { 14 | var ( 15 | path string 16 | prefix string 17 | useentirepath bool 18 | ) 19 | 20 | flag.StringVar(&path, "path", "", "path to export") 21 | flag.StringVar(&prefix, "prefix", "EXAMPLE_", "prefix for exported environment variable") 22 | flag.BoolVar(&useentirepath, "entirepath", false, "use entire path as environment variables name") 23 | flag.Parse() 24 | 25 | ctx := context.Background() 26 | 27 | rules := []ssmwrap.ExportRule{ 28 | { 29 | Path: path, 30 | Prefix: prefix, 31 | UseEntirePath: useentirepath, 32 | }, 33 | } 34 | 35 | if err := ssmwrap.Export(ctx, rules, ssmwrap.ExportOptions{}); err != nil { 36 | fmt.Fprintf(os.Stderr, "failed to export parameters: %v", err) 37 | os.Exit(1) 38 | } 39 | 40 | for _, env := range os.Environ() { 41 | if !strings.HasPrefix(env, prefix) { 42 | continue 43 | } 44 | 45 | fmt.Println(env) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/handlename/ssmwrap/v2 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.36.3 7 | github.com/aws/aws-sdk-go-v2/config v1.29.14 8 | github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 9 | github.com/google/go-cmp v0.7.0 10 | github.com/lmittmann/tint v1.0.7 11 | github.com/mattn/go-isatty v0.0.20 12 | github.com/pkg/errors v0.9.1 13 | github.com/samber/lo v1.50.0 14 | github.com/stretchr/testify v1.10.0 15 | ) 16 | 17 | require ( 18 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 19 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 20 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 22 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 28 | github.com/aws/smithy-go v1.22.2 // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | golang.org/x/sys v0.6.0 // indirect 32 | golang.org/x/text v0.22.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 2 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 3 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 4 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 5 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 6 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 7 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 8 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 9 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 10 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 11 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 12 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 13 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 14 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 15 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 16 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 17 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 18 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 19 | github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0 h1:KWArCwA/WkuHWKfygkNz0B6YS6OvdgoJUaJHX0Qby1s= 20 | github.com/aws/aws-sdk-go-v2/service/ssm v1.59.0/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8= 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 25 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 26 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 27 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 28 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 32 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 33 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 34 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 35 | github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= 36 | github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 40 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 41 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 42 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= 46 | github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= 47 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 48 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 50 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 52 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 55 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 57 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 | -------------------------------------------------------------------------------- /integrated_test.go: -------------------------------------------------------------------------------- 1 | //go:build integrated 2 | 3 | package ssmwrap_test 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go-v2/aws" 14 | "github.com/aws/aws-sdk-go-v2/config" 15 | "github.com/aws/aws-sdk-go-v2/service/ssm" 16 | "github.com/aws/aws-sdk-go-v2/service/ssm/types" 17 | "github.com/samber/lo" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/require" 20 | ) 21 | 22 | func TestIntegrated(t *testing.T) { 23 | RootDir := "." 24 | // PathPrefixPlaceholder := ""+envPrefix 25 | // EnvPrefixPlaceholder := "__ENV_PREFIX__" 26 | 27 | ctx := t.Context() 28 | pathPrefix := "/test/ssmwrap" 29 | envPrefix := fmt.Sprintf("%d_", time.Now().UnixNano()) 30 | 31 | // Prepare SSM Params 32 | 33 | params := []struct { 34 | name string 35 | value string 36 | }{ 37 | { 38 | name: pathPrefix + "/foo", 39 | value: "foo_value", 40 | }, 41 | { 42 | name: pathPrefix + "/foo/bar/bar1", 43 | value: "foo_bar_bar1_value", 44 | }, 45 | { 46 | name: pathPrefix + "/foo/bar/bar2", 47 | value: "foo_bar_bar2_value", 48 | }, 49 | { 50 | name: pathPrefix + "/foo/bar/buzz/buzz1", 51 | value: "foo_bar_buzz_buzz1_value", 52 | }, 53 | { 54 | name: pathPrefix + "/hoge", 55 | value: "hoge_value", 56 | }, 57 | } 58 | 59 | cfg := lo.Must(config.LoadDefaultConfig(ctx)) 60 | client := ssm.NewFromConfig(cfg) 61 | 62 | for _, p := range params { 63 | input := &ssm.PutParameterInput{ 64 | Name: aws.String(p.name), 65 | Value: aws.String(p.value), 66 | Type: types.ParameterTypeString, 67 | Overwrite: aws.Bool(true), 68 | } 69 | 70 | lo.Must(client.PutParameter(ctx, input)) 71 | t.Logf("SSM Param %s is created or updated", p.name) 72 | } 73 | 74 | // Define test cases 75 | 76 | tests := []struct { 77 | flags []string 78 | expectedEnvs []string // Key=Value 79 | }{ 80 | { 81 | flags: []string{"-env", "path=" + pathPrefix + "/foo,prefix=" + envPrefix}, 82 | expectedEnvs: []string{ 83 | envPrefix + "FOO=foo_value", 84 | }, 85 | }, 86 | { 87 | flags: []string{"-env", "path=" + pathPrefix + "/foo/bar/*,prefix=" + envPrefix}, 88 | expectedEnvs: []string{ 89 | envPrefix + "BAR1=foo_bar_bar1_value", 90 | envPrefix + "BAR2=foo_bar_bar2_value", 91 | }, 92 | }, 93 | { 94 | flags: []string{"-env", "path=" + pathPrefix + "/foo/bar/**/*,prefix=" + envPrefix}, 95 | expectedEnvs: []string{ 96 | envPrefix + "BAR1=foo_bar_bar1_value", 97 | envPrefix + "BAR2=foo_bar_bar2_value", 98 | envPrefix + "BUZZ1=foo_bar_buzz_buzz1_value", 99 | }, 100 | }, 101 | } 102 | 103 | // Run test cases 104 | 105 | for _, tt := range tests { 106 | t.Run(strings.Join(tt.flags, " "), func(t *testing.T) { 107 | // Build command args 108 | args := []string{"run", "cmd/ssmwrap/main.go"} 109 | args = append(args, tt.flags...) 110 | args = append(args, []string{"--", "env"}...) 111 | 112 | // Prepare command 113 | cmd := exec.Command("go", args...) 114 | cmd.Dir = RootDir 115 | var stdout, stderr bytes.Buffer 116 | cmd.Stdout = &stdout 117 | cmd.Stderr = &stderr 118 | 119 | // Run! 120 | require.NoError(t, cmd.Run()) 121 | if stderr.String() != "" { 122 | t.Logf("logs:\n%s", stderr.String()) 123 | } 124 | gotEnvs := strings.Split(stdout.String(), "\n") 125 | 126 | // Check 127 | for _, expected := range tt.expectedEnvs { 128 | assert.Contains(t, gotEnvs, expected) 129 | } 130 | assert.Equal(t, 131 | len(tt.expectedEnvs), 132 | len(lo.Filter(gotEnvs, func(line string, _ int) bool { 133 | return strings.HasPrefix(line, envPrefix) 134 | })), 135 | ) 136 | }) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "syscall" 10 | 11 | "github.com/aws/aws-sdk-go-v2/config" 12 | "github.com/aws/aws-sdk-go-v2/service/ssm" 13 | "github.com/samber/lo" 14 | ) 15 | 16 | type SSMWrap struct { 17 | // Retry limit to request to SSM. 18 | Retries int 19 | 20 | // Command and arguments to run. 21 | Command []string 22 | } 23 | 24 | func NewSSMWrap() *SSMWrap { 25 | return &SSMWrap{ 26 | Retries: 3, 27 | } 28 | } 29 | 30 | func (s *SSMWrap) Run(ctx context.Context, rules []Rule, command []string) error { 31 | if len(command) == 0 { 32 | return fmt.Errorf("command required") 33 | } 34 | 35 | if err := s.Export(ctx, rules); err != nil { 36 | return fmt.Errorf("failed to export parameters: %w", err) 37 | } 38 | 39 | bin, err := exec.LookPath(command[0]) 40 | if err != nil { 41 | return fmt.Errorf("command is not executable %s: %w", command[0], err) 42 | } 43 | 44 | return syscall.Exec(bin, command, os.Environ()) 45 | } 46 | 47 | func (s SSMWrap) Export(ctx context.Context, rules []Rule) error { 48 | slog.DebugContext(ctx, fmt.Sprintf("start to process %d rules", len(rules))) 49 | 50 | ssmClient, err := s.ssmClient(ctx) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // store related ssm params 56 | 57 | slog.DebugContext(ctx, "start to store parameters") 58 | 59 | store := NewParameterStore(ssmClient, DefaultSSMConnector{}) 60 | if err := store.Store(ctx, lo.Map(rules, func(r Rule, _ int) ParameterRule { 61 | return r.ParameterRule 62 | })); err != nil { 63 | return fmt.Errorf("failed to refresh parameters: %w", err) 64 | } 65 | 66 | slog.DebugContext(ctx, fmt.Sprintf("%d parameters stored successfully", len(store.Parameters))) 67 | 68 | // execute rules 69 | 70 | for _, r := range rules { 71 | slog.DebugContext(ctx, "executing rule", slog.String("rule", r.String())) 72 | 73 | if err := r.Execute(*store); err != nil { 74 | return fmt.Errorf("failed to execute rule %s: %w", r, err) 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (s SSMWrap) ssmClient(ctx context.Context) (*ssm.Client, error) { 82 | opts := []func(*config.LoadOptions) error{} 83 | 84 | if 0 < s.Retries { 85 | opts = append(opts, config.WithRetryMaxAttempts(s.Retries)) 86 | } 87 | 88 | conf, err := config.LoadDefaultConfig(ctx, opts...) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to load default aws config: %w", err) 91 | } 92 | 93 | return ssm.NewFromConfig(conf), nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | type EnvCleaner struct { 9 | orig []string 10 | } 11 | 12 | func (e *EnvCleaner) Clean() { 13 | e.orig = os.Environ() 14 | os.Clearenv() 15 | } 16 | 17 | func (e EnvCleaner) Restore() { 18 | os.Clearenv() 19 | 20 | for _, env := range e.orig { 21 | p := strings.SplitN(env, "=", 2) 22 | os.Setenv(p[0], p[1]) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/app/destination_rule.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | ) 7 | 8 | type DestinationType string 9 | 10 | const ( 11 | DestinationTypeEnv DestinationType = "env" 12 | DestinationTypeFile DestinationType = "file" 13 | ) 14 | 15 | type DestinationRule struct { 16 | Type DestinationType 17 | 18 | // To is address of destination. 19 | To string 20 | 21 | TypeEnvOptions *DestinationTypeEnvOptions 22 | TypeFileOptions *DestinationTypeFileOptions 23 | } 24 | 25 | func (r DestinationRule) String() string { 26 | s := "to=" + r.To 27 | 28 | switch r.Type { 29 | case DestinationTypeEnv: 30 | s += fmt.Sprintf(" (%s %+v)", r.Type, r.TypeEnvOptions) 31 | case DestinationTypeFile: 32 | s += fmt.Sprintf(" (%s %+v)", r.Type, r.TypeFileOptions) 33 | } 34 | 35 | return s 36 | } 37 | 38 | type DestinationTypeEnvOptions struct { 39 | // Prefix is a prefix for environment variable. 40 | // For example, if Prefix is PREFIX, then the environment variable name will be PREFIX_NAME. 41 | Prefix string 42 | 43 | // EntirePath is a flag to export entire path as environment variable name. 44 | // For example, if EntirePath is true and the path is /a/b/c, then the environment variable name will be A_B_C. 45 | // If EntirePath is false, then the environment variable name will be C. 46 | EntirePath bool 47 | } 48 | 49 | func (o DestinationTypeEnvOptions) String() string { 50 | return fmt.Sprintf("prefix=%s,entirepath=%t", o.Prefix, o.EntirePath) 51 | } 52 | 53 | type DestinationTypeFileOptions struct { 54 | // Mode is a file mode of exported file. 55 | // If Mode is 0, then the default file mode is used defined in FileExporter. 56 | Mode fs.FileMode 57 | 58 | // Uid is a user id of exported file. 59 | // If Uid is 0, then the default user id is used defined in FileExporter. 60 | Uid int 61 | 62 | // Gid is a group id of exported file. 63 | // If Gid is 0, then the default group id is used defined in FileExporter. 64 | Gid int 65 | } 66 | 67 | func (o DestinationTypeFileOptions) String() string { 68 | return fmt.Sprintf("mode=%04o,uid=%d,gid=%d", o.Mode, o.Uid, o.Gid) 69 | } 70 | -------------------------------------------------------------------------------- /internal/app/env_exporter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "os" 4 | 5 | type EnvExporter struct { 6 | Name string 7 | } 8 | 9 | func NewEnvExporter(name string) *EnvExporter { 10 | return &EnvExporter{ 11 | Name: name, 12 | } 13 | } 14 | 15 | func (e EnvExporter) Address() string { 16 | return e.Name 17 | } 18 | 19 | func (e EnvExporter) Export(value string) error { 20 | return os.Setenv(e.Name, value) 21 | } 22 | -------------------------------------------------------------------------------- /internal/app/env_exporter_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestEnvExporterExportSuccess(t *testing.T) { 10 | tests := []struct { 11 | title string 12 | init func() *EnvExporter 13 | }{ 14 | { 15 | title: "normal", 16 | init: func() *EnvExporter { 17 | return NewEnvExporter("TEST") 18 | }, 19 | }, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.title, func(t *testing.T) { 24 | cleaner := EnvCleaner{} 25 | cleaner.Clean() 26 | defer cleaner.Restore() 27 | 28 | ex := tt.init() 29 | if ex == nil { 30 | t.Errorf("failed to generate EnvExporter") 31 | } 32 | 33 | value := tt.title 34 | if err := ex.Export(value); err != nil { 35 | t.Errorf("failed to export: %s", err) 36 | } 37 | 38 | if env := os.Getenv(ex.Name); env != value { 39 | t.Errorf("unexpected env %s=%s (expected %s)", ex.Name, env, value) 40 | } 41 | }) 42 | } 43 | } 44 | 45 | func TestEnvExporterExportReturnsError(t *testing.T) { 46 | tests := []struct { 47 | title string 48 | init func() *EnvExporter 49 | err string 50 | }{ 51 | { 52 | title: "name contains `=`", 53 | init: func() *EnvExporter { 54 | return NewEnvExporter("LEFT=RIGHT") 55 | }, 56 | err: "invalid argument", 57 | }, 58 | { 59 | title: "name is empty string", 60 | init: func() *EnvExporter { 61 | return NewEnvExporter("") 62 | }, 63 | err: "invalid argument", 64 | }, 65 | } 66 | 67 | for _, tt := range tests { 68 | t.Run(tt.title, func(t *testing.T) { 69 | cleaner := EnvCleaner{} 70 | cleaner.Clean() 71 | defer cleaner.Restore() 72 | 73 | ex := tt.init() 74 | if ex == nil { 75 | t.Errorf("failed to generate EnvExporter") 76 | } 77 | 78 | value := tt.title 79 | err := ex.Export(value) 80 | if err == nil { 81 | t.Fatal("expected error") 82 | } 83 | 84 | if !strings.Contains(err.Error(), tt.err) { 85 | t.Errorf("unexpected error: %s", err) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/app/exporter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type Exporter interface { 4 | Address() string 5 | Export(value string) error 6 | } 7 | -------------------------------------------------------------------------------- /internal/app/file_exporter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | ) 8 | 9 | type FileExporter struct { 10 | Path string 11 | Mode fs.FileMode 12 | Uid int 13 | Gid int 14 | } 15 | 16 | func NewFileExporter(path string) *FileExporter { 17 | return &FileExporter{ 18 | Path: path, 19 | Mode: 0644, 20 | Uid: os.Getuid(), 21 | Gid: os.Getgid(), 22 | } 23 | } 24 | 25 | func (e FileExporter) Address() string { 26 | return e.Path 27 | } 28 | 29 | func (e FileExporter) Export(v string) error { 30 | err := os.WriteFile(e.Path, []byte(v), e.Mode) 31 | if err != nil { 32 | return fmt.Errorf("failed to write to file %s: %w", e.Path, err) 33 | } 34 | 35 | uid := e.Uid 36 | if uid == 0 { 37 | uid = os.Getuid() 38 | } 39 | 40 | gid := e.Gid 41 | if gid == 0 { 42 | gid = os.Getgid() 43 | } 44 | 45 | err = os.Chown(e.Path, uid, gid) 46 | if err != nil { 47 | return fmt.Errorf("failed to chown file %s: %w", e.Path, err) 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/app/file_exporter_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestFileExporterExportSuccess(t *testing.T) { 12 | makeTempfileName := func(t *testing.T) string { 13 | tmpdir, err := os.MkdirTemp("", "") 14 | if err != nil { 15 | t.Errorf("failed to create temp dir: %s", err) 16 | } 17 | 18 | return filepath.Join(tmpdir, "out") 19 | } 20 | 21 | tests := []struct { 22 | title string 23 | init func(path string) *FileExporter 24 | }{ 25 | { 26 | title: "default", 27 | init: func(path string) *FileExporter { 28 | return NewFileExporter(path) 29 | }, 30 | }, 31 | { 32 | title: "with mode", 33 | init: func(path string) *FileExporter { 34 | ex := NewFileExporter(path) 35 | ex.Mode = 0600 36 | return ex 37 | }, 38 | }, 39 | } 40 | 41 | for _, tt := range tests { 42 | tempfile := makeTempfileName(t) 43 | defer os.Remove(tempfile) 44 | 45 | ex := tt.init(tempfile) 46 | if ex == nil { 47 | t.Errorf("failed to generate FileExporter") 48 | } 49 | 50 | value := tt.title 51 | if err := ex.Export(value); err != nil { 52 | t.Errorf("failed to export: %s", err) 53 | } 54 | 55 | f, err := os.Open(ex.Path) 56 | if err != nil { 57 | t.Errorf("failed to open destination file: %s", err) 58 | } 59 | 60 | body, err := io.ReadAll(f) 61 | if err != nil { 62 | t.Errorf("failed to read body from file: %s", err) 63 | } 64 | 65 | if value := string(body); value != value { 66 | t.Errorf("unexpected body: %s != %s", body, value) 67 | } 68 | } 69 | } 70 | 71 | func TestFileExporterExportFailedToWrite(t *testing.T) { 72 | tempDirPath, err := os.MkdirTemp("", "test") 73 | if err != nil { 74 | t.Errorf("failed to create tempdir: %s", err) 75 | } 76 | defer os.Remove(tempDirPath) 77 | 78 | ex := NewFileExporter(tempDirPath) // directory!! 79 | 80 | err = ex.Export("foo") 81 | if err == nil { 82 | t.Errorf("should be error: %s", err) 83 | } 84 | 85 | if !strings.HasPrefix(err.Error(), "failed to write to file") { 86 | t.Errorf("unexpected error: %s", err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/app/parameter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type Parameter struct { 4 | Path string 5 | Value string 6 | } 7 | -------------------------------------------------------------------------------- /internal/app/parameter_rule.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type ParameterLevel int 10 | 11 | const ( 12 | // ParameterLevelStrict means the path will be searched strictly. 13 | ParameterLevelStrict ParameterLevel = 0 14 | 15 | // ParameterLevelUnder means the path will be searched just under the path. 16 | ParameterLevelUnder ParameterLevel = 1 17 | 18 | // ParameterLevelAll means the path will be searched under the path recursively. 19 | ParameterLevelAll ParameterLevel = 2 20 | ) 21 | 22 | var validPathRegexp = regexp.MustCompile(`^/[-_/a-zA-Z0-9]+((/\**)?/\*)?$`) 23 | 24 | type ParameterRule struct { 25 | // Path is the target path on SSM Parameter Store. 26 | Path string 27 | 28 | // Level means how deep the path should be searched. 29 | Level ParameterLevel 30 | } 31 | 32 | // NewParameterRule creates a new ParameterRule. 33 | // The path should be a valid path format. 34 | // If the path ends with `/*`, the level will be `ParameterLevelUnder`. 35 | // If the path ends with `/**/*`, the level will be `ParameterLevelAll`. 36 | // Otherwise, the level will be `ParameterLevelStrict`. 37 | func NewParameterRule(path string) (*ParameterRule, error) { 38 | if !validPathRegexp.MatchString(path) { 39 | return nil, fmt.Errorf("invalid `path` format") 40 | } 41 | 42 | if strings.HasSuffix(path, "/**/*") { 43 | return &ParameterRule{ 44 | Path: path[:len(path)-4], 45 | Level: ParameterLevelAll, 46 | }, nil 47 | } 48 | 49 | if strings.HasSuffix(path, "/*") { 50 | return &ParameterRule{ 51 | Path: path[:len(path)-1], 52 | Level: ParameterLevelUnder, 53 | }, nil 54 | } 55 | 56 | return &ParameterRule{ 57 | Path: path, 58 | Level: ParameterLevelStrict, 59 | }, nil 60 | } 61 | 62 | func (r ParameterRule) String() string { 63 | s := r.Path 64 | 65 | switch r.Level { 66 | case ParameterLevelStrict: 67 | // do nothing 68 | case ParameterLevelUnder: 69 | s += "*" 70 | case ParameterLevelAll: 71 | s += "**/*" 72 | } 73 | 74 | return s 75 | } 76 | 77 | func (r1 ParameterRule) Equals(r2 ParameterRule) bool { 78 | return r1.Path == r2.Path && r1.Level == r2.Level 79 | } 80 | 81 | func (r1 ParameterRule) IsCovers(r2 ParameterRule) bool { 82 | if r1.Equals(r2) { 83 | return true 84 | } 85 | 86 | switch r1.Level { 87 | case ParameterLevelStrict: 88 | return false 89 | case ParameterLevelUnder: 90 | if r2.Level == ParameterLevelAll { 91 | return false 92 | } 93 | 94 | if strings.HasPrefix(r2.Path, r1.Path) { 95 | // Is r2 just unedr r1? 96 | s := strings.Replace(r2.Path, r1.Path, "", 1) 97 | if strings.Contains(s, "/") { 98 | return false 99 | } 100 | 101 | return true 102 | } 103 | case ParameterLevelAll: 104 | if strings.HasPrefix(r2.Path, r1.Path) { 105 | return true 106 | } 107 | } 108 | 109 | return false 110 | } 111 | -------------------------------------------------------------------------------- /internal/app/parameter_rule_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestParameterRuleIsCovers(t *testing.T) { 9 | tests := []struct { 10 | r1 ParameterRule 11 | r2 ParameterRule 12 | want bool 13 | }{ 14 | { 15 | r1: ParameterRule{ 16 | Path: "/foo/v1", 17 | Level: ParameterLevelStrict, 18 | }, 19 | r2: ParameterRule{ 20 | Path: "/foo/v1", 21 | Level: ParameterLevelStrict, 22 | }, 23 | want: true, 24 | }, 25 | { 26 | r1: ParameterRule{ 27 | Path: "/foo/", 28 | Level: ParameterLevelUnder, 29 | }, 30 | r2: ParameterRule{ 31 | Path: "/foo/v1", 32 | Level: ParameterLevelStrict, 33 | }, 34 | want: true, 35 | }, 36 | { 37 | r1: ParameterRule{ 38 | Path: "/foo/", 39 | Level: ParameterLevelUnder, 40 | }, 41 | r2: ParameterRule{ 42 | Path: "/foo/v1/value", 43 | Level: ParameterLevelStrict, 44 | }, 45 | want: false, 46 | }, 47 | { 48 | r1: ParameterRule{ 49 | Path: "/foo/", 50 | Level: ParameterLevelAll, 51 | }, 52 | r2: ParameterRule{ 53 | Path: "/foo/v1/value", 54 | Level: ParameterLevelStrict, 55 | }, 56 | want: true, 57 | }, 58 | { 59 | r1: ParameterRule{ 60 | Path: "/foo/", 61 | Level: ParameterLevelAll, 62 | }, 63 | r2: ParameterRule{ 64 | Path: "/foo/", 65 | Level: ParameterLevelUnder, 66 | }, 67 | want: true, 68 | }, 69 | { 70 | r1: ParameterRule{ 71 | Path: "/foo/", 72 | Level: ParameterLevelUnder, 73 | }, 74 | r2: ParameterRule{ 75 | Path: "/foo/", 76 | Level: ParameterLevelAll, 77 | }, 78 | want: false, 79 | }, 80 | } 81 | 82 | for _, tt := range tests { 83 | t.Run(fmt.Sprintf("%s covers %s -> %t", tt.r1, tt.r2, tt.want), func(t *testing.T) { 84 | if tt.r1.IsCovers(tt.r2) != tt.want { 85 | t.Errorf("unexpected result") 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/app/parameter_store.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/service/ssm" 11 | "github.com/samber/lo" 12 | ) 13 | 14 | type ParameterStore struct { 15 | client *ssm.Client 16 | conn SSMConnector 17 | 18 | Parameters []Parameter 19 | } 20 | 21 | func NewParameterStore(client *ssm.Client, conn SSMConnector) *ParameterStore { 22 | return &ParameterStore{ 23 | client: client, 24 | conn: conn, 25 | } 26 | } 27 | 28 | func (c *ParameterStore) Store(ctx context.Context, rules []ParameterRule) error { 29 | c.Parameters = []Parameter{} 30 | 31 | paths := map[ParameterLevel][]string{ 32 | ParameterLevelStrict: {}, 33 | ParameterLevelUnder: {}, 34 | ParameterLevelAll: {}, 35 | } 36 | 37 | // Rules that represent a broader range come first. 38 | sort.Slice(rules, func(i, j int) bool { 39 | if rules[i].Level == rules[j].Level { 40 | return rules[i].Path < rules[j].Path 41 | } 42 | 43 | return rules[i].Level > rules[j].Level 44 | }) 45 | 46 | filteredRules := []ParameterRule{} 47 | 48 | for _, rule := range rules { 49 | // Skip paths that have already been retrieved 50 | if lo.ContainsBy(filteredRules, func(r ParameterRule) bool { 51 | return r.IsCovers(rule) 52 | }) { 53 | slog.Debug("skip to fetch parameters due to overlapping", slog.String("rule", rule.String())) 54 | continue 55 | } 56 | 57 | switch rule.Level { 58 | case ParameterLevelStrict: 59 | paths[ParameterLevelStrict] = append(paths[ParameterLevelStrict], rule.Path) 60 | case ParameterLevelUnder: 61 | paths[ParameterLevelUnder] = append(paths[ParameterLevelUnder], rule.Path) 62 | case ParameterLevelAll: 63 | paths[ParameterLevelAll] = append(paths[ParameterLevelAll], rule.Path) 64 | default: 65 | slog.Warn("invalid ParameterRule path level", slog.Int("level", int(rule.Level))) 66 | } 67 | 68 | filteredRules = append(filteredRules, rule) 69 | } 70 | 71 | add := func(params map[string]string) { 72 | for key, value := range params { 73 | c.Parameters = append(c.Parameters, Parameter{ 74 | Path: key, 75 | Value: value, 76 | }) 77 | } 78 | } 79 | 80 | if p, err := c.conn.fetchParametersByNames(ctx, c.client, paths[ParameterLevelStrict]); err != nil { 81 | return fmt.Errorf("failed to fetch parameters from SSM by strict paths %v: %w", paths[ParameterLevelStrict], err) 82 | } else { 83 | add(p) 84 | } 85 | 86 | if p, err := c.conn.fetchParametersByPaths(ctx, c.client, paths[ParameterLevelUnder], false); err != nil { 87 | return fmt.Errorf("failed to fetch parameters from SSM by just under paths %v: %w", paths[ParameterLevelUnder], err) 88 | } else { 89 | add(p) 90 | } 91 | 92 | if p, err := c.conn.fetchParametersByPaths(ctx, c.client, paths[ParameterLevelAll], true); err != nil { 93 | return fmt.Errorf("failed to fetch parameters from SSM by under paths recursively %v: %w", paths[ParameterLevelAll], err) 94 | } else { 95 | add(p) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func (c ParameterStore) Retrieve(path string, level ParameterLevel) ([]Parameter, error) { 102 | switch level { 103 | case ParameterLevelStrict: 104 | if param := c.FindByName(path); param == nil { 105 | return []Parameter{}, nil 106 | } else { 107 | return []Parameter{*param}, nil 108 | } 109 | case ParameterLevelUnder: 110 | return c.SearchByPath(path, false), nil 111 | case ParameterLevelAll: 112 | return c.SearchByPath(path, true), nil 113 | default: 114 | return nil, fmt.Errorf("invalid ParameterLevel: %d", level) 115 | } 116 | } 117 | 118 | func (c ParameterStore) FindByName(name string) *Parameter { 119 | params := lo.Filter(c.Parameters, func(p Parameter, _ int) bool { 120 | return p.Path == name 121 | }) 122 | if len(params) == 0 { 123 | return nil 124 | } 125 | if 2 <= len(params) { 126 | slog.Warn("found multiple parameters with the same name", slog.String("name", name)) 127 | } 128 | 129 | return ¶ms[0] 130 | } 131 | 132 | func (c ParameterStore) SearchByPath(path string, recursive bool) []Parameter { 133 | return lo.Filter(c.Parameters, func(p Parameter, _ int) bool { 134 | if !strings.HasPrefix(p.Path, path) { 135 | return false 136 | } 137 | 138 | if !recursive { 139 | rest := strings.Replace(p.Path, path, "", 1) 140 | if strings.Contains(rest, "/") { 141 | return false 142 | } 143 | } 144 | 145 | return true 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /internal/app/parameter_store_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestParameterStoreStore(t *testing.T) { 12 | rules := []ParameterRule{ 13 | { 14 | Path: "/foo/v1", 15 | Level: ParameterLevelStrict, 16 | }, 17 | { 18 | Path: "/bar/", 19 | Level: ParameterLevelUnder, 20 | }, 21 | { 22 | Path: "/buzz/", 23 | Level: ParameterLevelAll, 24 | }, 25 | } 26 | 27 | want := []Parameter{ 28 | { 29 | Path: "/foo/v1", 30 | Value: "this is /foo/v1", 31 | }, 32 | { 33 | Path: "/bar/v1", 34 | Value: "this is /bar/v1", 35 | }, 36 | { 37 | Path: "/buzz/v1", 38 | Value: "this is /buzz/v1", 39 | }, 40 | { 41 | Path: "/buzz/a/v2", 42 | Value: "this is /buzz/a/v2", 43 | }, 44 | { 45 | Path: "/buzz/a/b/v3", 46 | Value: "this is /buzz/a/b/v3", 47 | }, 48 | } 49 | 50 | mock := MockSSMConnector{ 51 | data: map[string]string{ 52 | "/foo/v1": "this is /foo/v1", 53 | "/foo/v2": "this is /foo/v2", 54 | "/bar/v1": "this is /bar/v1", 55 | "/bar/a/v2": "this is /bar/v2", 56 | "/buzz/v1": "this is /buzz/v1", 57 | "/buzz/a/v2": "this is /buzz/a/v2", 58 | "/buzz/a/b/v3": "this is /buzz/a/b/v3", 59 | }, 60 | } 61 | 62 | ctx := context.Background() 63 | store := NewParameterStore(nil, mock) 64 | store.Store(ctx, rules) 65 | 66 | sort.Slice(want, func(i, j int) bool { 67 | return want[i].Path < want[j].Path 68 | }) 69 | 70 | sort.Slice(store.Parameters, func(i, j int) bool { 71 | return store.Parameters[i].Path < store.Parameters[j].Path 72 | }) 73 | 74 | if diff := cmp.Diff(want, store.Parameters); diff != "" { 75 | t.Errorf("Store() has diff:\n%s", diff) 76 | } 77 | } 78 | 79 | func TestParameterStoreRetrieve(t *testing.T) { 80 | paramAttrs := map[string]string{ 81 | "/foo/v1": "this is /foo/v1", 82 | "/bar/v1": "this is /bar/v1", 83 | "/bar/v2": "this is /bar/v2", 84 | "/bar/a/v3": "this is /bar/a/v3", 85 | } 86 | 87 | params := []Parameter{} 88 | for p, v := range paramAttrs { 89 | params = append(params, Parameter{ 90 | Path: p, 91 | Value: v, 92 | }) 93 | } 94 | 95 | store := ParameterStore{ 96 | Parameters: params, 97 | } 98 | 99 | tests := []struct { 100 | title string 101 | path string 102 | level ParameterLevel 103 | want []Parameter 104 | }{ 105 | { 106 | title: "strict", 107 | path: "/bar/a/v3", 108 | level: ParameterLevelStrict, 109 | want: []Parameter{ 110 | {Path: "/bar/a/v3", Value: paramAttrs["/bar/a/v3"]}, 111 | }, 112 | }, 113 | { 114 | title: "under", 115 | path: "/bar/", 116 | level: ParameterLevelUnder, 117 | want: []Parameter{ 118 | {Path: "/bar/v1", Value: paramAttrs["/bar/v1"]}, 119 | {Path: "/bar/v2", Value: paramAttrs["/bar/v2"]}, 120 | }, 121 | }, 122 | { 123 | title: "all", 124 | path: "/bar/", 125 | level: ParameterLevelAll, 126 | want: []Parameter{ 127 | {Path: "/bar/v1", Value: paramAttrs["/bar/v1"]}, 128 | {Path: "/bar/v2", Value: paramAttrs["/bar/v2"]}, 129 | {Path: "/bar/a/v3", Value: paramAttrs["/bar/a/v3"]}, 130 | }, 131 | }, 132 | } 133 | 134 | for _, tt := range tests { 135 | t.Run(tt.title, func(t *testing.T) { 136 | got, _ := store.Retrieve(tt.path, tt.level) 137 | 138 | sort.Slice(got, func(i, j int) bool { 139 | return got[i].Path < got[j].Path 140 | }) 141 | 142 | sort.Slice(tt.want, func(i, j int) bool { 143 | return tt.want[i].Path < tt.want[j].Path 144 | }) 145 | 146 | if diff := cmp.Diff(got, tt.want); diff != "" { 147 | t.Errorf("Retrieve() has diff:\n%s", diff) 148 | } 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /internal/app/rule.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strings" 7 | ) 8 | 9 | type Rule struct { 10 | ParameterRule ParameterRule 11 | DestinationRule DestinationRule 12 | } 13 | 14 | func (r Rule) String() string { 15 | ss := []string{ 16 | "path=" + r.ParameterRule.String(), 17 | "type=" + string(r.DestinationRule.Type), 18 | } 19 | 20 | if r.DestinationRule.To != "" { 21 | ss = append(ss, "to="+r.DestinationRule.To) 22 | } 23 | 24 | switch r.DestinationRule.Type { 25 | case DestinationTypeEnv: 26 | ss = append(ss, r.DestinationRule.TypeEnvOptions.String()) 27 | case DestinationTypeFile: 28 | ss = append(ss, r.DestinationRule.TypeFileOptions.String()) 29 | } 30 | 31 | return strings.Join(ss, ",") 32 | } 33 | 34 | func (r Rule) Execute(store ParameterStore) error { 35 | params, err := store.Retrieve(r.ParameterRule.Path, r.ParameterRule.Level) 36 | if err != nil { 37 | return fmt.Errorf("failed to retrieve parameters: %w", err) 38 | } 39 | 40 | for _, p := range params { 41 | var ex Exporter 42 | 43 | switch r.DestinationRule.Type { 44 | case DestinationTypeEnv: 45 | if r.DestinationRule.TypeEnvOptions == nil { 46 | return fmt.Errorf("TypeEnvOptions is required for DestinationTypeEnv") 47 | } 48 | 49 | envName := r.buildEnvName(p.Path) 50 | 51 | ex = NewEnvExporter(envName) 52 | case DestinationTypeFile: 53 | if r.DestinationRule.TypeFileOptions == nil { 54 | return fmt.Errorf("TypeFileOption is required for DestinationTypeFile") 55 | } 56 | 57 | e := NewFileExporter(r.DestinationRule.To) 58 | 59 | if r.DestinationRule.TypeFileOptions.Mode != 0 { 60 | e.Mode = r.DestinationRule.TypeFileOptions.Mode 61 | } 62 | 63 | if r.DestinationRule.TypeFileOptions.Uid != 0 { 64 | e.Uid = r.DestinationRule.TypeFileOptions.Uid 65 | } 66 | 67 | if r.DestinationRule.TypeFileOptions.Gid != 0 { 68 | e.Gid = r.DestinationRule.TypeFileOptions.Gid 69 | } 70 | 71 | ex = e 72 | default: 73 | return fmt.Errorf("invalid destination type: %s", r.DestinationRule.Type) 74 | } 75 | 76 | slog.Debug( 77 | "exporting parameter", 78 | slog.String("type", string(r.DestinationRule.Type)), 79 | slog.String("address", ex.Address()), 80 | ) 81 | 82 | if err := ex.Export(p.Value); err != nil { 83 | return fmt.Errorf("failed to export parameter for %s: %w", r.DestinationRule.To, err) 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (r Rule) buildEnvName(path string) string { 91 | var envName string 92 | 93 | if r.DestinationRule.TypeEnvOptions.EntirePath { 94 | envName += strings.ReplaceAll(path, "/", "_") 95 | envName = strings.TrimPrefix(envName, "_") 96 | } else { 97 | parts := strings.Split(path, "/") 98 | envName += parts[len(parts)-1] 99 | } 100 | 101 | if r.DestinationRule.TypeEnvOptions.Prefix != "" { 102 | envName = r.DestinationRule.TypeEnvOptions.Prefix + envName 103 | } 104 | 105 | return strings.ToUpper(envName) 106 | } 107 | -------------------------------------------------------------------------------- /internal/app/rule_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "testing" 4 | 5 | func TestRuleString(t *testing.T) { 6 | tests := []struct { 7 | title string 8 | rule Rule 9 | want string 10 | }{ 11 | { 12 | title: "type env", 13 | rule: Rule{ 14 | ParameterRule: ParameterRule{ 15 | Path: "/path/to/param/", 16 | Level: ParameterLevelAll, 17 | }, 18 | DestinationRule: DestinationRule{ 19 | Type: DestinationTypeEnv, 20 | To: "", 21 | TypeEnvOptions: &DestinationTypeEnvOptions{ 22 | Prefix: "TEST_", 23 | EntirePath: true, 24 | }, 25 | }, 26 | }, 27 | want: "path=/path/to/param/**/*,type=env,prefix=TEST_,entirepath=true", 28 | }, 29 | { 30 | title: "type file", 31 | rule: Rule{ 32 | ParameterRule: ParameterRule{ 33 | Path: "/path/to/param", 34 | Level: ParameterLevelStrict, 35 | }, 36 | DestinationRule: DestinationRule{ 37 | Type: DestinationTypeFile, 38 | To: "/path/to/file", 39 | TypeFileOptions: &DestinationTypeFileOptions{ 40 | Mode: 0644, 41 | Uid: 1000, 42 | Gid: 2000, 43 | }, 44 | }, 45 | }, 46 | want: "path=/path/to/param,type=file,to=/path/to/file,mode=0644,uid=1000,gid=2000", 47 | }, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.title, func(t *testing.T) { 52 | got := tt.rule.String() 53 | if got != tt.want { 54 | t.Errorf("got %s, want %s", got, tt.want) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestRuleBuildEnvName(t *testing.T) { 61 | tests := []struct { 62 | title string 63 | path string 64 | prefix string 65 | entirePath bool 66 | want string 67 | }{ 68 | { 69 | title: "no options", 70 | path: "/path/to/param", 71 | prefix: "", 72 | entirePath: false, 73 | want: "PARAM", 74 | }, 75 | { 76 | title: "with prefix", 77 | path: "/path/to/param", 78 | prefix: "TEST_", 79 | entirePath: false, 80 | want: "TEST_PARAM", 81 | }, 82 | { 83 | title: "entire path", 84 | path: "/path/to/param", 85 | prefix: "", 86 | entirePath: true, 87 | want: "PATH_TO_PARAM", 88 | }, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.title, func(t *testing.T) { 93 | rule := Rule{ 94 | DestinationRule: DestinationRule{ 95 | TypeEnvOptions: &DestinationTypeEnvOptions{ 96 | Prefix: tt.prefix, 97 | EntirePath: tt.entirePath, 98 | }, 99 | }, 100 | } 101 | 102 | got := rule.buildEnvName(tt.path) 103 | if got != tt.want { 104 | t.Errorf("got %s, want %s", got, tt.want) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/app/ssm.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/config" 9 | "github.com/aws/aws-sdk-go-v2/service/ssm" 10 | ) 11 | 12 | type SSMConnector interface { 13 | fetchParametersByPaths(ctx context.Context, client *ssm.Client, paths []string, recursive bool) (map[string]string, error) 14 | fetchParametersByNames(ctx context.Context, client *ssm.Client, names []string) (map[string]string, error) 15 | } 16 | 17 | type DefaultSSMConnector struct{} 18 | 19 | func (c DefaultSSMConnector) fetchParametersByPaths(ctx context.Context, client *ssm.Client, paths []string, recursive bool) (map[string]string, error) { 20 | params := map[string]string{} 21 | if len(paths) == 0 { 22 | return params, nil 23 | } 24 | 25 | for _, path := range paths { 26 | nextToken := "" 27 | 28 | for { 29 | input := &ssm.GetParametersByPathInput{ 30 | Path: &path, 31 | Recursive: aws.Bool(recursive), 32 | WithDecryption: aws.Bool(true), 33 | } 34 | 35 | if nextToken != "" { 36 | input.NextToken = aws.String(nextToken) 37 | } 38 | 39 | output, err := client.GetParametersByPath(ctx, input) 40 | if err != nil { 41 | return params, fmt.Errorf("failed to GetParametersByPath: %w", err) 42 | } 43 | 44 | for _, param := range output.Parameters { 45 | params[*param.Name] = *param.Value 46 | } 47 | 48 | if output.NextToken == nil { 49 | break 50 | } 51 | 52 | nextToken = *output.NextToken 53 | } 54 | } 55 | 56 | return params, nil 57 | } 58 | 59 | func (c DefaultSSMConnector) fetchParametersByNames(ctx context.Context, client *ssm.Client, names []string) (map[string]string, error) { 60 | params := make(map[string]string, len(names)) 61 | if len(names) == 0 { 62 | return params, nil 63 | } 64 | 65 | input := &ssm.GetParametersInput{ 66 | WithDecryption: aws.Bool(true), 67 | } 68 | for _, name := range names { 69 | if _, exists := params[name]; exists { // discard duplication 70 | continue 71 | } 72 | input.Names = append(input.Names, name) 73 | } 74 | 75 | output, err := client.GetParameters(ctx, input) 76 | if err != nil { 77 | return params, fmt.Errorf("failed to GetParameters: %s", err) 78 | } 79 | 80 | for _, param := range output.Parameters { 81 | params[*param.Name] = *param.Value 82 | } 83 | 84 | return params, nil 85 | } 86 | 87 | func NewSSMClient(ctx context.Context, retries int) (*ssm.Client, error) { 88 | opts := []func(*config.LoadOptions) error{} 89 | 90 | if 0 < retries { 91 | opts = append(opts, config.WithRetryMaxAttempts(retries)) 92 | } 93 | 94 | conf, err := config.LoadDefaultConfig(ctx, opts...) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to load default aws config: %w", err) 97 | } 98 | 99 | return ssm.NewFromConfig(conf), nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/app/ssm_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/ssm" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/samber/lo" 11 | ) 12 | 13 | type MockSSMConnector struct { 14 | data map[string]string 15 | } 16 | 17 | func (c MockSSMConnector) fetchParametersByPaths(ctx context.Context, client *ssm.Client, paths []string, recursive bool) (map[string]string, error) { 18 | ret := map[string]string{} 19 | dataKeys := lo.Keys(c.data) 20 | 21 | for _, path := range paths { 22 | keys := lo.Filter(dataKeys, func(key string, _ int) bool { 23 | if !strings.HasPrefix(key, path) { 24 | return false 25 | } 26 | 27 | if !recursive { 28 | rest := strings.Replace(key, path, "", 1) 29 | if strings.Contains(rest, "/") { 30 | return false 31 | } 32 | } 33 | 34 | return true 35 | }) 36 | 37 | if len(keys) == 0 { 38 | continue 39 | } 40 | 41 | for _, key := range keys { 42 | ret[key] = c.data[key] 43 | } 44 | } 45 | 46 | return ret, nil 47 | } 48 | 49 | func (c MockSSMConnector) fetchParametersByNames(ctx context.Context, client *ssm.Client, names []string) (map[string]string, error) { 50 | ret := map[string]string{} 51 | 52 | for _, name := range names { 53 | v, ok := c.data[name] 54 | if ok { 55 | ret[name] = v 56 | } 57 | } 58 | 59 | return ret, nil 60 | } 61 | 62 | func TestMockSSMConnectorFetchParametersByPaths(t *testing.T) { 63 | mock := MockSSMConnector{ 64 | data: map[string]string{ 65 | "/foo/bar": "this is /foo/bar", 66 | "/bar/v1": "this is /bar/v1", 67 | "/bar/v2": "this is /bar/v2", 68 | "/buzz/v1": "this is /buzz/v1", 69 | "/buzz/qux/v2": "this is /buzz/qux/v2", 70 | "/buzz/qux/quux/v3": "this is /buzz/qux/quux/v3", 71 | }, 72 | } 73 | 74 | test := []struct { 75 | title string 76 | paths []string 77 | recursive bool 78 | want map[string]string 79 | }{ 80 | { 81 | title: "just under", 82 | paths: []string{"/bar/", "/buzz/"}, 83 | recursive: false, 84 | want: map[string]string{ 85 | "/bar/v1": "this is /bar/v1", 86 | "/bar/v2": "this is /bar/v2", 87 | "/buzz/v1": "this is /buzz/v1", 88 | }, 89 | }, 90 | { 91 | title: "recursively", 92 | paths: []string{"/bar/", "/buzz/"}, 93 | recursive: true, 94 | want: map[string]string{ 95 | "/bar/v1": "this is /bar/v1", 96 | "/bar/v2": "this is /bar/v2", 97 | "/buzz/v1": "this is /buzz/v1", 98 | "/buzz/qux/v2": "this is /buzz/qux/v2", 99 | "/buzz/qux/quux/v3": "this is /buzz/qux/quux/v3", 100 | }, 101 | }, 102 | } 103 | 104 | for _, tt := range test { 105 | t.Run(tt.title, func(t *testing.T) { 106 | got, err := mock.fetchParametersByPaths(context.Background(), nil, tt.paths, tt.recursive) 107 | if err != nil { 108 | t.Errorf("fetchParametersByPaths() error = %v", err) 109 | return 110 | } 111 | 112 | if diff := cmp.Diff(got, tt.want); diff != "" { 113 | t.Errorf("fetchParametersByPaths() has diff:\n%s", diff) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestMockSSMConnectorFetchParametersByNames(t *testing.T) { 120 | mock := MockSSMConnector{ 121 | data: map[string]string{ 122 | "/foo/v1": "this is /foo/v1", 123 | "/bar/v2": "this is /bar/v2", 124 | }, 125 | } 126 | 127 | test := []struct { 128 | title string 129 | names []string 130 | recursive bool 131 | want map[string]string 132 | }{ 133 | { 134 | title: "success", 135 | names: []string{"/foo/v1", "/bar/v2"}, 136 | recursive: false, 137 | want: map[string]string{ 138 | "/foo/v1": "this is /foo/v1", 139 | "/bar/v2": "this is /bar/v2", 140 | }, 141 | }, 142 | { 143 | title: "no result", 144 | names: []string{"/unknown/value"}, 145 | recursive: true, 146 | want: map[string]string{}, 147 | }, 148 | } 149 | 150 | for _, tt := range test { 151 | t.Run(tt.title, func(t *testing.T) { 152 | got, err := mock.fetchParametersByPaths(context.Background(), nil, tt.names, tt.recursive) 153 | if err != nil { 154 | t.Errorf("fetchParametersByPaths() error = %v", err) 155 | return 156 | } 157 | 158 | if diff := cmp.Diff(got, tt.want); diff != "" { 159 | t.Errorf("fetchParametersByNames() has diff:\n%s", diff) 160 | } 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /internal/cli/env_flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | type EnvFlags struct { 4 | RuleFlags 5 | } 6 | 7 | func (f *EnvFlags) Set(value string) error { 8 | opts, err := f.parseValue(value) 9 | if err != nil { 10 | return f.Errorf(value, err.Error()) 11 | } 12 | 13 | opts["type"] = "env" 14 | 15 | rule, err := f.buildRule(opts) 16 | if err != nil { 17 | return f.Errorf(value, err.Error()) 18 | } 19 | 20 | f.Rules = append(f.Rules, *rule) 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/cli/env_flags_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/handlename/ssmwrap/v2/internal/app" 8 | ) 9 | 10 | func TestEnvFlagsSuccess(t *testing.T) { 11 | var f EnvFlags 12 | err := f.Set("path=/path/to/param,entirepath=true,prefix=PREFIX_") 13 | if err != nil { 14 | t.Fatalf("unexpected error: %s", err) 15 | } 16 | 17 | if len(f.Rules) != 1 { 18 | t.Fatalf("unexpected length: %d", len(f.Rules)) 19 | } 20 | 21 | want := app.Rule{ 22 | ParameterRule: app.ParameterRule{ 23 | Path: "/path/to/param", 24 | Level: 0, 25 | }, 26 | DestinationRule: app.DestinationRule{ 27 | Type: app.DestinationTypeEnv, 28 | To: "", 29 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 30 | Prefix: "PREFIX_", 31 | EntirePath: true, 32 | }, 33 | }, 34 | } 35 | 36 | if diff := cmp.Diff(want, f.Rules[0]); diff != "" { 37 | t.Fatalf("unexpected diff: %s", diff) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/cli/file_flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | type FileFlags struct { 4 | RuleFlags 5 | } 6 | 7 | func (f *FileFlags) Set(value string) error { 8 | opts, err := f.parseValue(value) 9 | if err != nil { 10 | return f.Errorf(value, err.Error()) 11 | } 12 | 13 | opts["type"] = "file" 14 | 15 | rule, err := f.buildRule(opts) 16 | if err != nil { 17 | return f.Errorf(value, err.Error()) 18 | } 19 | 20 | f.Rules = append(f.Rules, *rule) 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /internal/cli/file_flags_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/handlename/ssmwrap/v2/internal/app" 8 | ) 9 | 10 | func TestFileFlagsSuccess(t *testing.T) { 11 | var f FileFlags 12 | err := f.Set("path=/path/to/param,to=/path/to/file,mode=0644,uid=1000,gid=1000") 13 | if err != nil { 14 | t.Fatalf("unexpected error: %s", err) 15 | } 16 | 17 | if len(f.Rules) != 1 { 18 | t.Fatalf("unexpected length: %d", len(f.Rules)) 19 | } 20 | 21 | want := app.Rule{ 22 | ParameterRule: app.ParameterRule{ 23 | Path: "/path/to/param", 24 | Level: 0, 25 | }, 26 | DestinationRule: app.DestinationRule{ 27 | Type: app.DestinationTypeFile, 28 | To: "/path/to/file", 29 | TypeFileOptions: &app.DestinationTypeFileOptions{ 30 | Mode: 0644, 31 | Uid: 1000, 32 | Gid: 1000, 33 | }, 34 | }, 35 | } 36 | 37 | if diff := cmp.Diff(want, f.Rules[0]); diff != "" { 38 | t.Fatalf("unexpected diff: %s", diff) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/cli/rule_flags.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/handlename/ssmwrap/v2/internal/app" 10 | "github.com/pkg/errors" 11 | "github.com/samber/lo" 12 | ) 13 | 14 | type RuleFlags struct { 15 | Rules []app.Rule 16 | } 17 | 18 | func (f RuleFlags) String() string { 19 | ss := make([]string, len(f.Rules)+2) 20 | ss = append(ss, "[") 21 | for _, r := range f.Rules { 22 | ss = append(ss, r.String()) 23 | } 24 | ss = append(ss, "]") 25 | 26 | return strings.Join(ss, " ") 27 | } 28 | 29 | func (f *RuleFlags) Set(value string) error { 30 | opts, err := f.parseValue(value) 31 | if err != nil { 32 | return f.Errorf(value, err.Error()) 33 | } 34 | 35 | rule, err := f.buildRule(opts) 36 | if err != nil { 37 | return f.Errorf(value, err.Error()) 38 | } 39 | 40 | f.Rules = append(f.Rules, *rule) 41 | 42 | return nil 43 | } 44 | 45 | func (f RuleFlags) parseValue(value string) (map[string]string, error) { 46 | optLines := strings.Split(value, ",") 47 | opts := make(map[string]string, len(optLines)) 48 | 49 | for _, opt := range optLines { 50 | parts := strings.SplitN(opt, "=", 2) 51 | if len(parts) != 2 { 52 | return nil, fmt.Errorf("invalid format") 53 | } 54 | 55 | opts[parts[0]] = parts[1] 56 | } 57 | 58 | return opts, nil 59 | } 60 | 61 | func (f RuleFlags) buildRule(opts map[string]string) (*app.Rule, error) { 62 | rule := &app.Rule{} 63 | 64 | if _, ok := opts["path"]; !ok { 65 | return nil, fmt.Errorf("`path` is required") 66 | } 67 | 68 | if pr, err := app.NewParameterRule(opts["path"]); err != nil { 69 | return nil, errors.Wrap(err, "failed to init parameter rule") 70 | } else { 71 | rule.ParameterRule = *pr 72 | } 73 | 74 | switch opts["type"] { 75 | case string(app.DestinationTypeEnv): 76 | if err := f.checkOptionsCombinations(app.DestinationTypeEnv, opts); err != nil { 77 | return nil, errors.Wrap(err, "invalid options combination(s)") 78 | } 79 | 80 | rule.DestinationRule = app.DestinationRule{ 81 | Type: app.DestinationTypeEnv, 82 | To: opts["to"], 83 | TypeEnvOptions: &app.DestinationTypeEnvOptions{}, 84 | } 85 | 86 | if v, ok := opts["prefix"]; ok { 87 | rule.DestinationRule.TypeEnvOptions.Prefix = v 88 | } 89 | 90 | if v, ok := opts["entirepath"]; ok { 91 | entirePath, err := strconv.ParseBool(v) 92 | if err != nil { 93 | return nil, fmt.Errorf("invalid `entirepath`") 94 | } 95 | 96 | rule.DestinationRule.TypeEnvOptions.EntirePath = entirePath 97 | } 98 | case string(app.DestinationTypeFile): 99 | if err := f.checkOptionsCombinations(app.DestinationTypeFile, opts); err != nil { 100 | return nil, errors.Wrap(err, "invalid options combination(s)") 101 | } 102 | 103 | if _, ok := opts["to"]; !ok { 104 | return nil, fmt.Errorf("`to` is required for `type=file`") 105 | } 106 | 107 | if rule.ParameterRule.Level != app.ParameterLevelStrict { 108 | return nil, fmt.Errorf("`path` end with `/*` or `/**/*` is not allowed for `type=file`") 109 | } 110 | 111 | // TODO: check if `to` is valid as file path 112 | 113 | rule.DestinationRule = app.DestinationRule{ 114 | Type: app.DestinationTypeFile, 115 | To: opts["to"], 116 | TypeFileOptions: &app.DestinationTypeFileOptions{}, 117 | } 118 | 119 | if modeStr, ok := opts["mode"]; ok { 120 | mode, err := strconv.ParseUint(modeStr, 8, 32) 121 | if err != nil { 122 | return nil, fmt.Errorf("invalid `mode`") 123 | } 124 | 125 | rule.DestinationRule.TypeFileOptions.Mode = fs.FileMode(mode) 126 | } 127 | 128 | if uidStr, ok := opts["uid"]; ok { 129 | uid, err := strconv.Atoi(uidStr) 130 | if err != nil { 131 | return nil, fmt.Errorf("invalid `uid`") 132 | } 133 | 134 | rule.DestinationRule.TypeFileOptions.Uid = uid 135 | } 136 | 137 | if gidStr, ok := opts["gid"]; ok { 138 | gid, err := strconv.Atoi(gidStr) 139 | if err != nil { 140 | return nil, fmt.Errorf("invalid `gid`") 141 | } 142 | 143 | rule.DestinationRule.TypeFileOptions.Gid = gid 144 | } 145 | default: 146 | return nil, fmt.Errorf("invalid `type`") 147 | } 148 | 149 | return rule, nil 150 | } 151 | 152 | func (f RuleFlags) checkOptionsCombinations(t app.DestinationType, opts map[string]string) error { 153 | for _, key := range lo.Keys(opts) { 154 | switch key { 155 | case "prefix": 156 | if t != app.DestinationTypeEnv { 157 | return f.Errorf(key, "`prefix` is only allowed for `type=env`") 158 | } 159 | case "entirepath": 160 | if t != app.DestinationTypeEnv { 161 | return f.Errorf(key, "`entirepath` is only allowed for `type=env`") 162 | } 163 | 164 | if _, ok := opts["to"]; ok { 165 | return f.Errorf(key, "can't use `to` with `entirepath` in same time") 166 | } 167 | case "mode": 168 | if t != app.DestinationTypeFile { 169 | return f.Errorf(key, "`mode` is only allowed for `type=file`") 170 | } 171 | case "uid": 172 | if t != app.DestinationTypeFile { 173 | return f.Errorf(key, "`uid` is only allowed for `type=file`") 174 | } 175 | case "gid": 176 | if t != app.DestinationTypeFile { 177 | return f.Errorf(key, "`gid` is only allowed for `type=file`") 178 | } 179 | } 180 | } 181 | 182 | return nil 183 | } 184 | 185 | func (f RuleFlags) Errorf(value, format string, args ...interface{}) error { 186 | return fmt.Errorf("-rule "+value+": "+format, args...) 187 | } 188 | -------------------------------------------------------------------------------- /internal/cli/rule_flags_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/handlename/ssmwrap/v2/internal/app" 9 | ) 10 | 11 | func TestRuleFlagsSetSuccess(t *testing.T) { 12 | tests := []struct { 13 | title string 14 | value string 15 | want app.Rule 16 | }{ 17 | { 18 | title: "type env (strict)", 19 | value: "path=/path/to/param,type=env", 20 | want: app.Rule{ 21 | ParameterRule: app.ParameterRule{ 22 | Path: "/path/to/param", 23 | Level: app.ParameterLevelStrict, 24 | }, 25 | DestinationRule: app.DestinationRule{ 26 | Type: app.DestinationTypeEnv, 27 | To: "", 28 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 29 | Prefix: "", 30 | EntirePath: false, 31 | }, 32 | }, 33 | }, 34 | }, 35 | { 36 | title: "type env (under)", 37 | value: "path=/path/under/*,type=env", 38 | want: app.Rule{ 39 | ParameterRule: app.ParameterRule{ 40 | Path: "/path/under/", 41 | Level: app.ParameterLevelUnder, 42 | }, 43 | DestinationRule: app.DestinationRule{ 44 | Type: app.DestinationTypeEnv, 45 | To: "", 46 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 47 | Prefix: "", 48 | EntirePath: false, 49 | }, 50 | }, 51 | }, 52 | }, 53 | { 54 | title: "type env (all)", 55 | value: "path=/path/all/**/*,type=env", 56 | want: app.Rule{ 57 | ParameterRule: app.ParameterRule{ 58 | Path: "/path/all/", 59 | Level: app.ParameterLevelAll, 60 | }, 61 | DestinationRule: app.DestinationRule{ 62 | Type: app.DestinationTypeEnv, 63 | To: "", 64 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 65 | Prefix: "", 66 | EntirePath: false, 67 | }, 68 | }, 69 | }, 70 | }, 71 | { 72 | title: "type env with options", 73 | value: "path=/path/to/param,type=env,prefix=PREFIX_,entirepath=true", 74 | want: app.Rule{ 75 | ParameterRule: app.ParameterRule{ 76 | Path: "/path/to/param", 77 | Level: app.ParameterLevelStrict, 78 | }, 79 | DestinationRule: app.DestinationRule{ 80 | Type: app.DestinationTypeEnv, 81 | To: "", 82 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 83 | Prefix: "PREFIX_", 84 | EntirePath: true, 85 | }, 86 | }, 87 | }, 88 | }, 89 | { 90 | title: "type file", 91 | value: "path=/path/to/param,type=file,to=/path/to/file", 92 | want: app.Rule{ 93 | ParameterRule: app.ParameterRule{ 94 | Path: "/path/to/param", 95 | Level: app.ParameterLevelStrict, 96 | }, 97 | DestinationRule: app.DestinationRule{ 98 | Type: app.DestinationTypeFile, 99 | To: "/path/to/file", 100 | TypeFileOptions: &app.DestinationTypeFileOptions{ 101 | Mode: 0, 102 | Uid: 0, 103 | Gid: 0, 104 | }, 105 | }, 106 | }, 107 | }, 108 | { 109 | title: "type file with options", 110 | value: "path=/path/to/param,type=file,to=/path/to/file,mode=0644,uid=1000,gid=1000", 111 | want: app.Rule{ 112 | ParameterRule: app.ParameterRule{ 113 | Path: "/path/to/param", 114 | Level: app.ParameterLevelStrict, 115 | }, 116 | DestinationRule: app.DestinationRule{ 117 | Type: app.DestinationTypeFile, 118 | To: "/path/to/file", 119 | TypeFileOptions: &app.DestinationTypeFileOptions{ 120 | Mode: 0644, 121 | Uid: 1000, 122 | Gid: 1000, 123 | }, 124 | }, 125 | }, 126 | }, 127 | } 128 | 129 | for _, tt := range tests { 130 | t.Run(tt.title, func(t *testing.T) { 131 | var f RuleFlags 132 | err := f.Set(tt.value) 133 | if err != nil { 134 | t.Fatalf("unexpected error: %s", err) 135 | } 136 | 137 | if len(f.Rules) != 1 { 138 | t.Fatalf("unexpected length: %d", len(f.Rules)) 139 | } 140 | 141 | if diff := cmp.Diff(tt.want, f.Rules[0]); diff != "" { 142 | t.Errorf("unexpected diff:\n%s", diff) 143 | } 144 | }) 145 | } 146 | } 147 | 148 | func TestRuleFlagsSetReturnsError(t *testing.T) { 149 | tests := []struct { 150 | title string 151 | value string 152 | err string 153 | }{ 154 | { 155 | title: "type: required", 156 | value: "path=/path/to/param", 157 | err: "invalid `type`", 158 | }, 159 | { 160 | title: "path: invalid suffix", 161 | value: "path=/path/to/param/**,type=env", 162 | err: "invalid `path` format", 163 | }, 164 | { 165 | title: "path: invalid prefix", 166 | value: "path=path/to/param,type=env", 167 | err: "invalid `path` format", 168 | }, 169 | { 170 | title: "path: end with `/*` is not allowed for `type=file`", 171 | value: "path=/path/to/param/*,type=file,to=/path/to/file", 172 | err: "not allowed for `type=file`", 173 | }, 174 | { 175 | title: "path: end with `/**/*` not allowed for `type=file`", 176 | value: "path=/path/to/param/**/*,type=file,to=/path/to/file", 177 | err: "not allowed for `type=file`", 178 | }, 179 | } 180 | 181 | for _, tt := range tests { 182 | t.Run(tt.title, func(t *testing.T) { 183 | var f RuleFlags 184 | err := f.Set(tt.value) 185 | if err == nil { 186 | t.Fatalf("should be error") 187 | } 188 | 189 | if !strings.Contains(err.Error(), tt.err) { 190 | t.Errorf("unexpected error: '%s' is not contains '%s'", err, tt.err) 191 | } 192 | }) 193 | } 194 | } 195 | 196 | func TestRuleFlagsCheckOptionsCombinations(t *testing.T) { 197 | tests := []struct { 198 | title string 199 | destType app.DestinationType 200 | opts map[string]string 201 | err string 202 | }{ 203 | { 204 | title: "prefix: only for `type=env`", 205 | destType: app.DestinationTypeFile, 206 | opts: map[string]string{ 207 | "to": "/path/to/file", 208 | "prefix": "PREFIX_", 209 | }, 210 | err: "`prefix` is only allowed for `type=env`", 211 | }, 212 | { 213 | title: "entirepath: only for `type=env`", 214 | destType: app.DestinationTypeFile, 215 | opts: map[string]string{ 216 | "to": "/path/to/file", 217 | "entirepath": "true", 218 | }, 219 | err: "`entirepath` is only allowed for `type=env`", 220 | }, 221 | { 222 | title: "entirepath: exclusive with `to`", 223 | destType: app.DestinationTypeEnv, 224 | opts: map[string]string{ 225 | "to": "/path/to/file", 226 | "entirepath": "true", 227 | }, 228 | err: "can't use `to` with `entirepath`", 229 | }, 230 | { 231 | title: "mode: only for `type=file`", 232 | destType: app.DestinationTypeEnv, 233 | opts: map[string]string{ 234 | "mode": "0644", 235 | }, 236 | err: "is only allowed for `type=file`", 237 | }, 238 | { 239 | title: "uid: only for `type=file`", 240 | destType: app.DestinationTypeEnv, 241 | opts: map[string]string{ 242 | "uid": "1000", 243 | }, 244 | err: "is only allowed for `type=file`", 245 | }, 246 | { 247 | title: "gid: only for `type=file`", 248 | destType: app.DestinationTypeEnv, 249 | opts: map[string]string{ 250 | "gid": "1000", 251 | }, 252 | err: "is only allowed for `type=file`", 253 | }, 254 | } 255 | 256 | for _, tt := range tests { 257 | t.Run(tt.title, func(t *testing.T) { 258 | var f RuleFlags 259 | err := f.checkOptionsCombinations(tt.destType, tt.opts) 260 | if err == nil { 261 | t.Fatalf("should be error") 262 | } 263 | 264 | if !strings.Contains(err.Error(), tt.err) { 265 | t.Errorf("unexpected error: '%s' is not contains '%s'", err, tt.err) 266 | } 267 | }) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /lib.go: -------------------------------------------------------------------------------- 1 | package ssmwrap 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/handlename/ssmwrap/v2/internal/app" 8 | ) 9 | 10 | type ExportOptions struct { 11 | Retries int 12 | } 13 | 14 | type ExportRule struct { 15 | // Path of parameter store. 16 | // If `path` ends with no-slash character, only the value of the path will be exported. 17 | // If `path` ends with `/**/*`, all values under the path will be exported. 18 | // If `path` ends with `/*`, only top level values under the path will be exported. 19 | Path string 20 | 21 | // Prefix for exported environment variable. 22 | Prefix string 23 | 24 | // UseEntirePath is flag if export entire path as environment variables name. 25 | // If true, all values under the path will be exported. (/path/to/param -> PATH_TO_PARAM) 26 | // If false, only top level values under the path will be exported. (/path/to/param -> PARAM) 27 | UseEntirePath bool 28 | } 29 | 30 | // Export fetches parameters from SSM and export those to environment variables. 31 | // This is for use ssmwrap as a library. 32 | func Export(ctx context.Context, ers []ExportRule, options ExportOptions) error { 33 | rules := make([]app.Rule, 0, len(ers)) 34 | 35 | for _, er := range ers { 36 | pr, err := app.NewParameterRule(er.Path) 37 | if err != nil { 38 | return fmt.Errorf("failed to create ParameterRule: %w", err) 39 | } 40 | 41 | rules = append(rules, app.Rule{ 42 | ParameterRule: *pr, 43 | DestinationRule: app.DestinationRule{ 44 | Type: app.DestinationTypeEnv, 45 | TypeEnvOptions: &app.DestinationTypeEnvOptions{ 46 | Prefix: er.Prefix, 47 | EntirePath: er.UseEntirePath, 48 | }, 49 | }, 50 | }) 51 | } 52 | 53 | sw := app.NewSSMWrap() 54 | if options.Retries != 0 { 55 | sw.Retries = options.Retries 56 | } 57 | 58 | if err := sw.Export(ctx, rules); err != nil { 59 | return fmt.Errorf("failed to export parameters: %w", err) 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package ssmwrap 2 | 3 | import ( 4 | "log" 5 | "log/slog" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/lmittmann/tint" 11 | "github.com/mattn/go-isatty" 12 | ) 13 | 14 | func InitLogger() { 15 | logger := slog.New( 16 | tint.NewHandler(os.Stderr, &tint.Options{ 17 | AddSource: true, 18 | Level: selectLogLevel(), 19 | TimeFormat: time.DateTime, 20 | NoColor: !(isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())), 21 | }), 22 | ) 23 | slog.SetDefault(logger) 24 | } 25 | 26 | func selectLogLevel() slog.Leveler { 27 | level := strings.ToLower(os.Getenv("LOG_LEVEL")) 28 | if level == "" { 29 | level = "info" 30 | } 31 | 32 | switch level { 33 | case "debug": 34 | return slog.LevelDebug 35 | case "info": 36 | return slog.LevelInfo 37 | case "warn": 38 | return slog.LevelWarn 39 | case "error": 40 | return slog.LevelError 41 | default: 42 | log.Println("invalid log level, using info level.") 43 | return slog.LevelInfo 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ssmwrap_test.go: -------------------------------------------------------------------------------- 1 | package ssmwrap 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | InitLogger() 10 | os.Exit(m.Run()) 11 | } 12 | --------------------------------------------------------------------------------