├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── main.go └── main_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | defaults: &defaults 3 | docker: 4 | - image: circleci/golang:1.16 5 | workflows: 6 | version: 2 7 | build-and-test: 8 | jobs: 9 | - test 10 | - build 11 | jobs: 12 | test: 13 | !!merge <<: *defaults 14 | steps: 15 | - checkout 16 | - run: make test 17 | build: 18 | !!merge <<: *defaults 19 | steps: 20 | - checkout 21 | # Compile, and make sure it's not dynamically linked. 22 | - run: make bin/ssm-env && ! ldd bin/ssm-env 23 | - store_artifacts: 24 | path: bin/ssm-env 25 | destination: ssm-env 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Remind101 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := bin/ssm-env 2 | 3 | ARGS := 4 | 5 | version := $(shell git describe --tags --match 'v*') 6 | 7 | .PHONY: run 8 | run: 9 | CGO_ENABLED=0 go run -ldflags "-X main.version=$(version)" . $(ARGS) 10 | 11 | bin/ssm-env: *.go 12 | CGO_ENABLED=0 go build -ldflags "-X main.version=$(version)" -o $@ . 13 | 14 | .PHONY: test 15 | test: 16 | go test -race $(shell go list ./... | grep -v /vendor/) 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssm-env 2 | 3 | `ssm-env` is a simple UNIX tool to populate env vars from AWS Parameter Store. 4 | 5 | ## Installation 6 | 7 | ```console 8 | $ go get -u github.com/remind101/ssm-env 9 | ``` 10 | 11 | You can most likely find the downloaded binary in `~/go/bin/ssm-env` 12 | 13 | ## Usage 14 | 15 | ```console 16 | ssm-env [-template STRING] [-with-decryption] [-no-fail] COMMAND 17 | ``` 18 | 19 | ## Details 20 | 21 | Given the following environment: 22 | 23 | ``` 24 | RAILS_ENV=production 25 | COOKIE_SECRET=ssm://prod.app.cookie-secret 26 | ``` 27 | 28 | You can run the application using `ssm-env` to automatically populate the `COOKIE_SECRET` env var from SSM: 29 | 30 | ```console 31 | $ ssm-env env 32 | RAILS_ENV=production 33 | COOKIE_SECRET=super-secret 34 | ``` 35 | 36 | You can also configure how the parameter name is determined for an environment variable, by using the `-template` flag: 37 | 38 | ```console 39 | $ export COOKIE_SECRET=xxx 40 | $ ssm-env -template '{{ if eq .Name "COOKIE_SECRET" }}prod.app.cookie-secret{{end}}' env 41 | RAILS_ENV=production 42 | COOKIE_SECRET=super-secret 43 | ``` 44 | 45 | `ssm-env` also supports [versioned SSM](https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-versions.html) params: 46 | 47 | ```console 48 | $ export OLD_SECRET=ssm://secret:1 49 | $ export NEW_SECRET=ssm://secret:2 50 | $ ssm-env env 51 | 52 | OLD_SECRET=super_secret_v1 53 | NEW_SECRET=super_secret_v2 54 | ``` 55 | 56 | ## Usage with Docker 57 | 58 | A common use case is to use `ssm-env` as a Docker ENTRYPOINT. You can copy and paste the following into the top of a Dockerfile: 59 | 60 | ```dockerfile 61 | RUN curl -sSfL -o /usr/local/bin/ssm-env https://github.com/remind101/ssm-env/releases/download/v0.0.5/ssm-env \ 62 | && cd /usr/local/bin \ 63 | && echo "babf40382bcd260f0d8d4575a32d5ec33fb08fefd29f12ffd800fbe738c41021 ssm-env" | sha256sum -c \ 64 | && chmod +x ssm-env 65 | ``` 66 | 67 | Now, any command executed with the Docker image will be funneled through ssm-env. 68 | 69 | ### Alpine Docker Image 70 | 71 | To use `ssm-env` with [Alpine](https://hub.docker.com/_/alpine) Docker images, root certificates need to be added 72 | and the installation command differs, as shown in the `Dockerfile` below: 73 | 74 | ```dockerfile 75 | FROM alpine:latest 76 | 77 | # ...copy code 78 | 79 | # ssm-env: See https://github.com/remind101/ssm-env 80 | RUN apk add curl 81 | RUN curl -sSfL -o /usr/local/bin/ssm-env https://github.com/remind101/ssm-env/releases/download/v0.0.5/ssm-env \ 82 | && cd /usr/local/bin \ 83 | && echo "babf40382bcd260f0d8d4575a32d5ec33fb08fefd29f12ffd800fbe738c41021 ssm-env" | sha256sum -c \ 84 | && chmod +x ssm-env 85 | 86 | # Alpine Linux doesn't include root certificates which ssm-env needs to talk to AWS. 87 | # See https://simplydistributed.wordpress.com/2018/05/22/certificate-error-with-go-http-client-in-alpine-docker/ 88 | RUN apk add --no-cache ca-certificates 89 | 90 | ENTRYPOINT ["/usr/local/bin/ssm-env", "-with-decryption"] 91 | ``` 92 | 93 | ## Usage with Kubernetes 94 | 95 | A simple way to provide AWS credentials to `ssm-env` in containers run in Kubernetes is to use Kubernetes 96 | [Secrets](https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/) and to expose 97 | them as environment variables. There are more secure alternatives to environment variables, but if this is secure 98 | enough for your needs, it provides a low-effort setup path. 99 | 100 | First, store your AWS credentials in a secret called `aws-credentials`: 101 | 102 | ```shell 103 | kubectl create secret generic aws-credentials --from-literal=AWS_ACCESS_KEY_ID='AKIA...' --from-literal=AWS_SECRET_ACCESS_KEY='...' 104 | ``` 105 | 106 | Then, in the container specification in your deployment or pod file, add them as environment variables (alongside 107 | all other environment variables, including those retrieved from SSM): 108 | 109 | ```yaml 110 | containers: 111 | - env: 112 | - name: AWS_ACCESS_KEY_ID 113 | valueFrom: 114 | secretKeyRef: 115 | name: aws-credentials 116 | key: AWS_ACCESS_KEY_ID 117 | - name: AWS_SECRET_ACCESS_KEY 118 | valueFrom: 119 | secretKeyRef: 120 | name: aws-credentials 121 | key: AWS_SECRET_ACCESS_KEY 122 | - name: AWS_REGION 123 | value: us-east-1 124 | - name: SSM_EXAMPLE 125 | value: ssm:///foo/bar 126 | ``` 127 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/remind101/ssm-env 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.40.31 7 | github.com/stretchr/testify v1.7.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.40.31 h1:RqozVt+A4tqH8Nv9ITw8eRWtKLkIUAqqZm2Sh2ayMA4= 2 | github.com/aws/aws-sdk-go v1.40.31/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 6 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 7 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 8 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 9 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 13 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 14 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 15 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 16 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 17 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 20 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 21 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 25 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 26 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 27 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "syscall" 11 | "text/template" 12 | 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/aws/ec2metadata" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | "github.com/aws/aws-sdk-go/service/ssm" 17 | ) 18 | 19 | const ( 20 | // DefaultTemplate is the default template used to determine what the SSM 21 | // parameter name is for an environment variable. 22 | DefaultTemplate = `{{ if hasPrefix .Value "ssm://" }}{{ trimPrefix .Value "ssm://" }}{{ end }}` 23 | 24 | // defaultBatchSize is the default number of parameters to fetch at once. 25 | // The SSM API limits this to a maximum of 10 at the time of writing. 26 | defaultBatchSize = 10 27 | ) 28 | 29 | // TemplateFuncs are helper functions provided to the template. 30 | var TemplateFuncs = template.FuncMap{ 31 | "contains": strings.Contains, 32 | "hasPrefix": strings.HasPrefix, 33 | "hasSuffix": strings.HasSuffix, 34 | "trimPrefix": strings.TrimPrefix, 35 | "trimSuffix": strings.TrimSuffix, 36 | "trimSpace": strings.TrimSpace, 37 | "trimLeft": strings.TrimLeft, 38 | "trimRight": strings.TrimRight, 39 | "trim": strings.Trim, 40 | "title": strings.Title, 41 | "toTitle": strings.ToTitle, 42 | "toLower": strings.ToLower, 43 | "toUpper": strings.ToUpper, 44 | } 45 | 46 | var version string 47 | 48 | func main() { 49 | var ( 50 | template = flag.String("template", DefaultTemplate, "The template used to determine what the SSM parameter name is for an environment variable. When this template returns an empty string, the env variable is not an SSM parameter") 51 | decrypt = flag.Bool("with-decryption", false, "Will attempt to decrypt the parameter, and set the env var as plaintext") 52 | nofail = flag.Bool("no-fail", false, "Don't fail if error retrieving parameter") 53 | print_version = flag.Bool("V", false, "Print the version and exit") 54 | ) 55 | flag.Parse() 56 | args := flag.Args() 57 | 58 | if *print_version { 59 | fmt.Printf("%s\n", version) 60 | 61 | return 62 | } 63 | 64 | if len(args) <= 0 { 65 | flag.Usage() 66 | os.Exit(1) 67 | } 68 | 69 | path, err := exec.LookPath(args[0]) 70 | must(err) 71 | 72 | var os osEnviron 73 | 74 | t, err := parseTemplate(*template) 75 | must(err) 76 | e := &expander{ 77 | batchSize: defaultBatchSize, 78 | t: t, 79 | ssm: &lazySSMClient{}, 80 | os: os, 81 | } 82 | must(e.expandEnviron(*decrypt, *nofail)) 83 | must(syscall.Exec(path, args[0:], os.Environ())) 84 | } 85 | 86 | // lazySSMClient wraps the AWS SDK SSM client such that the AWS session and 87 | // SSM client are not actually initialized until GetParameters is called for 88 | // the first time. 89 | type lazySSMClient struct { 90 | ssm ssmClient 91 | } 92 | 93 | func (c *lazySSMClient) GetParameters(input *ssm.GetParametersInput) (*ssm.GetParametersOutput, error) { 94 | // Initialize the SSM client (and AWS session) if it hasn't been already. 95 | if c.ssm == nil { 96 | sess, err := c.awsSession() 97 | if err != nil { 98 | return nil, err 99 | } 100 | c.ssm = ssm.New(sess) 101 | } 102 | return c.ssm.GetParameters(input) 103 | } 104 | 105 | func (c *lazySSMClient) awsSession() (*session.Session, error) { 106 | sess, err := session.NewSession(&aws.Config{ 107 | CredentialsChainVerboseErrors: aws.Bool(true), 108 | }) 109 | if err != nil { 110 | return nil, err 111 | } 112 | // Clients will throw errors if a region isn't configured, so if one hasn't 113 | // been set already try to look up the region we're running in using the 114 | // EC2 Instance Metadata Endpoint. 115 | if len(aws.StringValue(sess.Config.Region)) == 0 { 116 | meta := ec2metadata.New(sess) 117 | identity, err := meta.GetInstanceIdentityDocument() 118 | if err == nil { 119 | sess.Config.Region = aws.String(identity.Region) 120 | } 121 | // Ignore any errors, the client will emit a missing region error 122 | // in the context of any parameter get calls anyway. 123 | } 124 | return sess, nil 125 | } 126 | 127 | func parseTemplate(templateText string) (*template.Template, error) { 128 | return template.New("template").Funcs(TemplateFuncs).Parse(templateText) 129 | } 130 | 131 | type ssmClient interface { 132 | GetParameters(*ssm.GetParametersInput) (*ssm.GetParametersOutput, error) 133 | } 134 | 135 | type environ interface { 136 | Environ() []string 137 | Setenv(key, vale string) 138 | } 139 | 140 | type osEnviron int 141 | 142 | func (e osEnviron) Environ() []string { 143 | return os.Environ() 144 | } 145 | 146 | func (e osEnviron) Setenv(key, val string) { 147 | os.Setenv(key, val) 148 | } 149 | 150 | type ssmVar struct { 151 | envvar string 152 | parameter string 153 | } 154 | 155 | type expander struct { 156 | t *template.Template 157 | ssm ssmClient 158 | os environ 159 | batchSize int 160 | } 161 | 162 | func (e *expander) parameter(k, v string) (*string, error) { 163 | b := new(bytes.Buffer) 164 | if err := e.t.Execute(b, struct{ Name, Value string }{k, v}); err != nil { 165 | return nil, err 166 | } 167 | 168 | if p := b.String(); p != "" { 169 | return &p, nil 170 | } 171 | 172 | return nil, nil 173 | } 174 | 175 | func (e *expander) expandEnviron(decrypt bool, nofail bool) error { 176 | // Environment variables that point to some SSM parameters. 177 | var ssmVars []ssmVar 178 | 179 | uniqNames := make(map[string]bool) 180 | for _, envvar := range e.os.Environ() { 181 | k, v := splitVar(envvar) 182 | 183 | parameter, err := e.parameter(k, v) 184 | if err != nil { 185 | // TODO: Should this _also_ not error if nofail is passed? 186 | return fmt.Errorf("determining name of parameter: %v", err) 187 | } 188 | 189 | if parameter != nil { 190 | // Ensure that this is a valid SSM parameter that we can actually resolve. 191 | if !strings.HasPrefix(*parameter, "/") && !nofail { 192 | return fmt.Errorf("SSM parameters must have a leading '/' (ssm:///): %s", envvar) 193 | } 194 | 195 | uniqNames[*parameter] = true 196 | ssmVars = append(ssmVars, ssmVar{k, *parameter}) 197 | } 198 | } 199 | 200 | if len(uniqNames) == 0 { 201 | // Nothing to do, no SSM parameters. 202 | return nil 203 | } 204 | 205 | names := make([]string, len(uniqNames)) 206 | i := 0 207 | for k := range uniqNames { 208 | names[i] = k 209 | i++ 210 | } 211 | 212 | for i := 0; i < len(names); i += e.batchSize { 213 | j := i + e.batchSize 214 | if j > len(names) { 215 | j = len(names) 216 | } 217 | 218 | values, err := e.getParameters(names[i:j], decrypt, nofail) 219 | if err != nil { 220 | return err 221 | } 222 | 223 | for _, v := range ssmVars { 224 | val, ok := values[v.parameter] 225 | if ok { 226 | e.os.Setenv(v.envvar, val) 227 | } 228 | } 229 | } 230 | 231 | return nil 232 | } 233 | 234 | func (e *expander) getParameters(names []string, decrypt bool, nofail bool) (map[string]string, error) { 235 | values := make(map[string]string) 236 | 237 | input := &ssm.GetParametersInput{ 238 | WithDecryption: aws.Bool(decrypt), 239 | } 240 | 241 | for _, n := range names { 242 | input.Names = append(input.Names, aws.String(n)) 243 | } 244 | 245 | resp, err := e.ssm.GetParameters(input) 246 | if err != nil && !nofail { 247 | return values, err 248 | } 249 | 250 | if len(resp.InvalidParameters) > 0 { 251 | if !nofail { 252 | return values, newInvalidParametersError(resp) 253 | } 254 | fmt.Fprintf(os.Stderr, "ssm-env: %v\n", newInvalidParametersError(resp)) 255 | } 256 | 257 | for _, p := range resp.Parameters { 258 | var name string 259 | if p.Selector != nil { 260 | name = *p.Name + *p.Selector 261 | } else { 262 | name = *p.Name 263 | } 264 | values[name] = *p.Value 265 | } 266 | 267 | return values, nil 268 | } 269 | 270 | type invalidParametersError struct { 271 | InvalidParameters []string 272 | } 273 | 274 | func newInvalidParametersError(resp *ssm.GetParametersOutput) *invalidParametersError { 275 | e := new(invalidParametersError) 276 | for _, p := range resp.InvalidParameters { 277 | if p == nil { 278 | continue 279 | } 280 | 281 | e.InvalidParameters = append(e.InvalidParameters, *p) 282 | } 283 | return e 284 | } 285 | 286 | func (e *invalidParametersError) Error() string { 287 | return fmt.Sprintf("invalid parameters: %v", e.InvalidParameters) 288 | } 289 | 290 | func splitVar(v string) (key, val string) { 291 | parts := strings.Split(v, "=") 292 | return parts[0], parts[1] 293 | } 294 | 295 | func must(err error) { 296 | if err != nil { 297 | fmt.Fprintf(os.Stderr, "ssm-env: %v\n", err) 298 | os.Exit(1) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "testing" 7 | "text/template" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/ssm" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | func TestExpandEnviron_NoSSMParameters(t *testing.T) { 16 | os := newFakeEnviron() 17 | c := new(mockSSM) 18 | e := expander{ 19 | t: template.Must(parseTemplate(DefaultTemplate)), 20 | os: os, 21 | ssm: c, 22 | batchSize: defaultBatchSize, 23 | } 24 | 25 | decrypt := false 26 | nofail := false 27 | err := e.expandEnviron(decrypt, nofail) 28 | assert.NoError(t, err) 29 | 30 | assert.Equal(t, []string{ 31 | "SHELL=/bin/bash", 32 | "TERM=screen-256color", 33 | }, os.Environ()) 34 | 35 | c.AssertExpectations(t) 36 | } 37 | 38 | func TestExpandEnviron_SimpleSSMParameter(t *testing.T) { 39 | os := newFakeEnviron() 40 | c := new(mockSSM) 41 | e := expander{ 42 | t: template.Must(parseTemplate(DefaultTemplate)), 43 | os: os, 44 | ssm: c, 45 | batchSize: defaultBatchSize, 46 | } 47 | 48 | os.Setenv("SUPER_SECRET", "ssm:///secret") 49 | 50 | c.On("GetParameters", &ssm.GetParametersInput{ 51 | Names: []*string{aws.String("/secret")}, 52 | WithDecryption: aws.Bool(true), 53 | }).Return(&ssm.GetParametersOutput{ 54 | Parameters: []*ssm.Parameter{ 55 | {Name: aws.String("/secret"), Value: aws.String("hehe")}, 56 | }, 57 | }, nil) 58 | 59 | decrypt := true 60 | nofail := false 61 | err := e.expandEnviron(decrypt, nofail) 62 | assert.NoError(t, err) 63 | 64 | assert.Equal(t, []string{ 65 | "SHELL=/bin/bash", 66 | "SUPER_SECRET=hehe", 67 | "TERM=screen-256color", 68 | }, os.Environ()) 69 | 70 | c.AssertExpectations(t) 71 | } 72 | 73 | func TestExpandEnviron_VersionedSSMParameter(t *testing.T) { 74 | os := newFakeEnviron() 75 | c := new(mockSSM) 76 | e := expander{ 77 | t: template.Must(parseTemplate(DefaultTemplate)), 78 | os: os, 79 | ssm: c, 80 | batchSize: defaultBatchSize, 81 | } 82 | 83 | os.Setenv("SUPER_SECRET", "ssm:///secret:1") 84 | 85 | c.On("GetParameters", &ssm.GetParametersInput{ 86 | Names: []*string{aws.String("/secret:1")}, 87 | WithDecryption: aws.Bool(true), 88 | }).Return(&ssm.GetParametersOutput{ 89 | Parameters: []*ssm.Parameter{ 90 | {Name: aws.String("/secret"), Value: aws.String("versioned"), Selector: aws.String(":1")}, 91 | }, 92 | }, nil) 93 | 94 | decrypt := true 95 | nofail := false 96 | err := e.expandEnviron(decrypt, nofail) 97 | assert.NoError(t, err) 98 | 99 | assert.Equal(t, []string{ 100 | "SHELL=/bin/bash", 101 | "SUPER_SECRET=versioned", 102 | "TERM=screen-256color", 103 | }, os.Environ()) 104 | 105 | c.AssertExpectations(t) 106 | } 107 | 108 | func TestExpandEnviron_CustomTemplate(t *testing.T) { 109 | os := newFakeEnviron() 110 | c := new(mockSSM) 111 | e := expander{ 112 | t: template.Must(parseTemplate(`{{ if eq .Name "SUPER_SECRET" }}/secret{{end}}`)), 113 | os: os, 114 | ssm: c, 115 | batchSize: defaultBatchSize, 116 | } 117 | 118 | os.Setenv("SUPER_SECRET", "ssm:///secret") 119 | 120 | c.On("GetParameters", &ssm.GetParametersInput{ 121 | Names: []*string{aws.String("/secret")}, 122 | WithDecryption: aws.Bool(true), 123 | }).Return(&ssm.GetParametersOutput{ 124 | Parameters: []*ssm.Parameter{ 125 | {Name: aws.String("/secret"), Value: aws.String("hehe")}, 126 | }, 127 | }, nil) 128 | 129 | decrypt := true 130 | nofail := false 131 | err := e.expandEnviron(decrypt, nofail) 132 | assert.NoError(t, err) 133 | 134 | assert.Equal(t, []string{ 135 | "SHELL=/bin/bash", 136 | "SUPER_SECRET=hehe", 137 | "TERM=screen-256color", 138 | }, os.Environ()) 139 | 140 | c.AssertExpectations(t) 141 | } 142 | 143 | func TestExpandEnviron_DuplicateSSMParameter(t *testing.T) { 144 | os := newFakeEnviron() 145 | c := new(mockSSM) 146 | e := expander{ 147 | t: template.Must(parseTemplate(DefaultTemplate)), 148 | os: os, 149 | ssm: c, 150 | batchSize: defaultBatchSize, 151 | } 152 | 153 | os.Setenv("SUPER_SECRET_A", "ssm:///secret") 154 | os.Setenv("SUPER_SECRET_B", "ssm:///secret") 155 | 156 | c.On("GetParameters", &ssm.GetParametersInput{ 157 | Names: []*string{aws.String("/secret")}, 158 | WithDecryption: aws.Bool(false), 159 | }).Return(&ssm.GetParametersOutput{ 160 | Parameters: []*ssm.Parameter{ 161 | {Name: aws.String("/secret"), Value: aws.String("hehe")}, 162 | }, 163 | }, nil) 164 | 165 | decrypt := false 166 | nofail := false 167 | err := e.expandEnviron(decrypt, nofail) 168 | assert.NoError(t, err) 169 | 170 | assert.Equal(t, []string{ 171 | "SHELL=/bin/bash", 172 | "SUPER_SECRET_A=hehe", 173 | "SUPER_SECRET_B=hehe", 174 | "TERM=screen-256color", 175 | }, os.Environ()) 176 | 177 | c.AssertExpectations(t) 178 | } 179 | 180 | func TestExpandEnviron_MalformedParametersFail(t *testing.T) { 181 | os := newFakeEnviron() 182 | c := new(mockSSM) 183 | e := expander{ 184 | t: template.Must(parseTemplate(DefaultTemplate)), 185 | os: os, 186 | ssm: c, 187 | batchSize: defaultBatchSize, 188 | } 189 | 190 | os.Setenv("SUPER_SECRET", "ssm://secret") 191 | 192 | decrypt := false 193 | nofail := false 194 | err := e.expandEnviron(decrypt, nofail) 195 | assert.Containsf(t, err.Error(), "SSM parameters must have a leading '/'", "") 196 | } 197 | 198 | func TestExpandEnviron_MalformedParametersNofail(t *testing.T) { 199 | os := newFakeEnviron() 200 | c := new(mockSSM) 201 | e := expander{ 202 | t: template.Must(parseTemplate(DefaultTemplate)), 203 | os: os, 204 | ssm: c, 205 | batchSize: defaultBatchSize, 206 | } 207 | 208 | os.Setenv("SUPER_SECRET", "ssm://secret") 209 | 210 | c.On("GetParameters", &ssm.GetParametersInput{ 211 | Names: []*string{aws.String("secret")}, 212 | WithDecryption: aws.Bool(false), 213 | }).Return(&ssm.GetParametersOutput{ 214 | InvalidParameters: []*string{aws.String("secret")}, 215 | }, nil) 216 | 217 | decrypt := false 218 | nofail := true 219 | err := e.expandEnviron(decrypt, nofail) 220 | 221 | assert.NoError(t, err) 222 | assert.Equal(t, []string{ 223 | "SHELL=/bin/bash", 224 | "SUPER_SECRET=ssm://secret", 225 | "TERM=screen-256color", 226 | }, os.Environ()) 227 | 228 | c.AssertExpectations(t) 229 | } 230 | 231 | func TestExpandEnviron_InvalidParameters(t *testing.T) { 232 | os := newFakeEnviron() 233 | c := new(mockSSM) 234 | e := expander{ 235 | t: template.Must(parseTemplate(DefaultTemplate)), 236 | os: os, 237 | ssm: c, 238 | batchSize: defaultBatchSize, 239 | } 240 | 241 | os.Setenv("SUPER_SECRET", "ssm:///bad.secret") 242 | 243 | c.On("GetParameters", &ssm.GetParametersInput{ 244 | Names: []*string{aws.String("/bad.secret")}, 245 | WithDecryption: aws.Bool(false), 246 | }).Return(&ssm.GetParametersOutput{ 247 | InvalidParameters: []*string{aws.String("/bad.secret")}, 248 | }, nil) 249 | 250 | decrypt := false 251 | nofail := false 252 | err := e.expandEnviron(decrypt, nofail) 253 | assert.Equal(t, &invalidParametersError{InvalidParameters: []string{"/bad.secret"}}, err) 254 | 255 | c.AssertExpectations(t) 256 | } 257 | 258 | func TestExpandEnviron_InvalidParametersNoFail(t *testing.T) { 259 | os := newFakeEnviron() 260 | c := new(mockSSM) 261 | e := expander{ 262 | t: template.Must(parseTemplate(DefaultTemplate)), 263 | os: os, 264 | ssm: c, 265 | batchSize: defaultBatchSize, 266 | } 267 | 268 | os.Setenv("SUPER_SECRET", "ssm:///secret") 269 | 270 | c.On("GetParameters", &ssm.GetParametersInput{ 271 | Names: []*string{aws.String("/secret")}, 272 | WithDecryption: aws.Bool(false), 273 | }).Return(&ssm.GetParametersOutput{ 274 | InvalidParameters: []*string{aws.String("/secret")}, 275 | }, nil) 276 | 277 | decrypt := false 278 | nofail := true 279 | err := e.expandEnviron(decrypt, nofail) 280 | 281 | assert.NoError(t, err) 282 | assert.Equal(t, []string{ 283 | "SHELL=/bin/bash", 284 | "SUPER_SECRET=ssm:///secret", 285 | "TERM=screen-256color", 286 | }, os.Environ()) 287 | 288 | c.AssertExpectations(t) 289 | } 290 | 291 | func TestExpandEnviron_BatchParameters(t *testing.T) { 292 | os := newFakeEnviron() 293 | c := new(mockSSM) 294 | e := expander{ 295 | t: template.Must(parseTemplate(DefaultTemplate)), 296 | os: os, 297 | ssm: c, 298 | batchSize: 1, 299 | } 300 | 301 | os.Setenv("SUPER_SECRET_A", "ssm:///secret-a") 302 | os.Setenv("SUPER_SECRET_B", "ssm:///secret-b") 303 | 304 | c.On("GetParameters", &ssm.GetParametersInput{ 305 | Names: []*string{aws.String("/secret-a")}, 306 | WithDecryption: aws.Bool(false), 307 | }).Return(&ssm.GetParametersOutput{ 308 | Parameters: []*ssm.Parameter{ 309 | {Name: aws.String("/secret-a"), Value: aws.String("val-a")}, 310 | }, 311 | }, nil) 312 | 313 | c.On("GetParameters", &ssm.GetParametersInput{ 314 | Names: []*string{aws.String("/secret-b")}, 315 | WithDecryption: aws.Bool(false), 316 | }).Return(&ssm.GetParametersOutput{ 317 | Parameters: []*ssm.Parameter{ 318 | {Name: aws.String("/secret-b"), Value: aws.String("val-b")}, 319 | }, 320 | }, nil) 321 | 322 | decrypt := false 323 | nofail := false 324 | err := e.expandEnviron(decrypt, nofail) 325 | assert.NoError(t, err) 326 | 327 | assert.Equal(t, []string{ 328 | "SHELL=/bin/bash", 329 | "SUPER_SECRET_A=val-a", 330 | "SUPER_SECRET_B=val-b", 331 | "TERM=screen-256color", 332 | }, os.Environ()) 333 | 334 | c.AssertExpectations(t) 335 | } 336 | 337 | type fakeEnviron map[string]string 338 | 339 | func newFakeEnviron() fakeEnviron { 340 | return fakeEnviron{ 341 | "SHELL": "/bin/bash", 342 | "TERM": "screen-256color", 343 | } 344 | } 345 | 346 | func (e fakeEnviron) Environ() []string { 347 | var env sort.StringSlice 348 | for k, v := range e { 349 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 350 | } 351 | env.Sort() 352 | return env 353 | } 354 | 355 | func (e fakeEnviron) Setenv(key, val string) { 356 | e[key] = val 357 | } 358 | 359 | type mockSSM struct { 360 | mock.Mock 361 | } 362 | 363 | func (m *mockSSM) GetParameters(input *ssm.GetParametersInput) (*ssm.GetParametersOutput, error) { 364 | args := m.Called(input) 365 | return args.Get(0).(*ssm.GetParametersOutput), args.Error(1) 366 | } 367 | --------------------------------------------------------------------------------