├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── command ├── describe-document.go ├── list-documents.go ├── list-instances.go ├── root.go ├── run-cmd.go ├── run-document.go ├── shell.go ├── ssm_opts.go ├── utils.go ├── utils_test.go └── version.go ├── go.mod ├── go.sum ├── main.go ├── manager ├── document-description.go ├── document-description_test.go ├── document-identifier.go ├── document-identifier_test.go ├── instance.go ├── instance_test.go ├── manager.go ├── manager_test.go └── testing.go └── terraform ├── README.md ├── example.tf └── modules └── ssm-example ├── cloud-config.yml ├── main.tf ├── outputs.tf └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | ssm-sh* 2 | *.json 3 | **/.terraform 4 | **/*.tfstate* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | services: 4 | - docker 5 | env: 6 | global: 7 | - DOCKER_REPO=itsdalmo/ssm-sh 8 | - PROJECT_NAME=ssm-sh 9 | matrix: 10 | include: 11 | - os: osx 12 | go: 1.13.x 13 | env: 14 | - TARGET=darwin 15 | - ARCH=amd64 16 | - os: linux 17 | go: 1.13.x 18 | env: 19 | - TARGET=linux 20 | - ARCH=amd64 21 | - os: linux 22 | go: 1.13.x 23 | env: 24 | - TARGET=windows 25 | - ARCH=amd64 26 | - EXT=.exe 27 | notifications: 28 | email: false 29 | script: 30 | - make build-release 31 | deploy: 32 | provider: releases 33 | api_key: 34 | secure: VmFjxvxzQPBgXb9u9XdMxocTvnSWFTW+/HhzOGeVABSaHumNxr1TfqxxxjOh8sHDQqLy65q0msny9JTgBC6FcTcW+GQaMfesirHOUvoMi2ZKJKEN/M6ODtHCV/DenvoSXpzVQO5YcWAOVSfRbB6Uu2CkB5hImrynI/lkn1kbI4uLVHSBDeTkZmTD4dmVtmSWtzUx4Rhryrbfj4fFuPOI98Xy2bsxBlLdtn5cudZbBCafY/62bSxQVqn9b7mUnHc/W5A9828MBJQOADwgS8oRkUEGuMCf4vwYx9ma4FRa3TIMWGjgRmp4QTCXP3XdXrCw/1advZBY5CWD8V84AdAzcFJHor+gJjcTr2IfOPqrdLdJXuMmHzzuxjr04ODfxcfV9fOiJZI0kYtIi+Cbtp/2nSTZlGCOsqsyc2zIZeZjW0fewRa89bu/6n+7w0ceAONeypAshAt2AHf45pV//zPxvJDMs4XV6Hi3ChjWmvmKO3gyd0xrEOaQ6ZLOtd8bVGyMcp4QH805rQvVgyaghTi7Fz61OpIDtXxoEBqhhLJ1SiQBHIL2UGoxKNHc0o9/I/gQT/IQB3oR/EDAV74zhIFzXXvGYJTNZfrqbQg9zyk51uicYTi85QMLrkQJ5dB9i1od1peLrRVPFZN0Xua2jhcG0Jk9BW7RaWec+IlaveuZyRY= 35 | file: "${PROJECT_NAME}-${TARGET}-amd64${EXT}" 36 | skip_cleanup: true 37 | on: 38 | repo: itsdalmo/ssm-sh 39 | tags: true 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.13 as builder 2 | 3 | ADD . /go/src/github.com/itsdalmo/ssm-sh 4 | WORKDIR /go/src/github.com/itsdalmo/ssm-sh 5 | ARG TARGET=linux 6 | ARG ARCH=amd64 7 | RUN make build-release 8 | 9 | FROM alpine:latest as resource 10 | COPY --from=builder /go/src/github.com/itsdalmo/ssm-sh/ssm-sh-linux-amd64 /bin/ssm-sh 11 | RUN apk --no-cache add ca-certificates 12 | ENTRYPOINT ["/bin/ssm-sh"] 13 | CMD ["--help"] 14 | 15 | FROM resource 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kristian 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 | BINARY_NAME = ssm-sh 2 | DOCKER_REPO = itsdalmo/ssm-sh 3 | TARGET ?= darwin 4 | ARCH ?= amd64 5 | EXT ?= "" 6 | 7 | GIT_REF = $(shell git rev-parse --short HEAD) 8 | GIT_TAG = $(if $(TRAVIS_TAG),$(TRAVIS_TAG),ref-$(GIT_REF)) 9 | 10 | LDFLAGS = -ldflags "-X=main.version=$(GIT_TAG)" 11 | SRC = $(shell find . -type f -name '*.go' -not -path "./vendor/*") 12 | 13 | export GO111MODULE=on 14 | 15 | default: test 16 | 17 | run: test 18 | @echo "== Run ==" 19 | go run $(LDFLAGS) main.go 20 | 21 | build: test 22 | @echo "== Build ==" 23 | go build -o $(BINARY_NAME) -v $(LDFLAGS) 24 | 25 | test: 26 | @echo "== Test ==" 27 | gofmt -s -l -w $(SRC) 28 | go vet -v ./... 29 | go test -race -v ./... 30 | 31 | clean: 32 | @echo "== Cleaning ==" 33 | @rm -f ssm-sh* || true 34 | 35 | lint: 36 | @echo "== Lint ==" 37 | golint manager 38 | golint command 39 | 40 | run-docker: 41 | @echo "== Docker run ==" 42 | docker run --rm $(DOCKER_REPO):latest 43 | 44 | build-docker: 45 | @echo "== Docker build ==" 46 | docker build -t $(DOCKER_REPO):latest . 47 | 48 | build-release: test 49 | @echo "== Release build ==" 50 | CGO_ENABLED=0 GOOS=$(TARGET) GOARCH=$(ARCH) go build $(LDFLAGS) -o $(BINARY_NAME)-$(TARGET)-$(ARCH)$(EXT) -v 51 | 52 | .PHONY: default build test build-docker run-docker build-release 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SSM shell 2 | 3 | [![Build Status](https://travis-ci.org/itsdalmo/ssm-sh.svg?branch=master)](https://travis-ci.org/itsdalmo/ssm-sh) 4 | 5 | Little experiment to mimic SSH by using SSM agent to send commands to 6 | remote instances and fetching the output. 7 | 8 | ## Install 9 | 10 | Grab a binary from the [releases](https://github.com/itsdalmo/ssm-sh/releases). 11 | 12 | #### Docker 13 | 14 | There is also a docker image [here](https://hub.docker.com/r/itsdalmo/ssm-sh/). 15 | 16 | #### Manual install 17 | 18 | Have Go installed: 19 | 20 | ```bash 21 | $ which go 22 | /usr/local/bin/go 23 | 24 | $ echo $GOPATH 25 | /Users/dalmo/go 26 | 27 | $ echo $PATH 28 | # Make sure $GOPATH/bin is in your PATH. 29 | ``` 30 | 31 | Get the repository: 32 | 33 | ```bash 34 | go get -u github.com/itsdalmo/ssm-sh 35 | ``` 36 | 37 | If everything was successful, you should have a shiny new binary: 38 | 39 | ```bash 40 | which ssm-sh 41 | # Should point to $GOPATH/bin/ssm-sh 42 | ``` 43 | 44 | ## Usage 45 | 46 | ```bash 47 | $ ssm-sh --help 48 | 49 | Usage: 50 | ssm-sh [OPTIONS] 51 | 52 | Application Options: 53 | -v, --version Print the version and exit. 54 | 55 | AWS Options: 56 | -p, --profile= AWS Profile to use. (If you are not using Vaulted). 57 | -r, --region= Region to target. (default: eu-west-1) 58 | 59 | Help Options: 60 | -h, --help Show this help message 61 | 62 | Available commands: 63 | describe Description a document from ssm. 64 | list List managed instances or documents. (aliases: ls) 65 | run Run a command or document on the targeted instances. 66 | shell Start an interactive shell. (aliases: sh) 67 | ``` 68 | 69 | #### List instances usage 70 | 71 | ```bash 72 | $ ssm-sh list instances --help 73 | 74 | ... 75 | [instances command options] 76 | -f, --filter= Filter the produced list by tag (key=value,..) 77 | -l, --limit= Limit the number of instances printed (default: 50) 78 | -o, --output= Path to a file where the list of instances will be written as JSON. 79 | ``` 80 | 81 | #### List documents usage 82 | ```bash 83 | $ ssm-sh list documents --help 84 | 85 | ... 86 | [documents command options] 87 | -f, --filter= Filter the produced list by property (Name, Owner, DocumentType, PlatformTypes) 88 | -l, --limit= Limit the number of instances printed (default: 50) 89 | ``` 90 | 91 | #### Run cmd/shell usage 92 | 93 | ```bash 94 | $ ssm-sh run cmd --help 95 | 96 | ... 97 | [cmd command options] 98 | -i, --timeout= Seconds to wait for command result before timing out. (default: 30) 99 | -t, --target= One or more instance ids to target 100 | --target-file= Path to a JSON file containing a list of targets. 101 | 102 | SSM options: 103 | -x, --extend-output Extend truncated command outputs by fetching S3 objects containing full ones 104 | -b, --s3-bucket= S3 bucket in which S3 objects containing full command outputs are stored. Required when --extend-output is provided. 105 | -k, --s3-key-prefix= Key prefix of S3 objects containing full command outputs. 106 | ``` 107 | 108 | #### Run document usage 109 | 110 | ```bash 111 | $ ssm-sh run document --help 112 | 113 | ... 114 | [document command options] 115 | -n, --name= Name of document in ssm. 116 | -i, --timeout= Seconds to wait for command result before timing out. (default: 30) 117 | -p, --parameter= Zero or more parameters for the document (name:value) 118 | -t, --target= One or more instance ids to target 119 | --target-file= Path to a JSON file containing a list of targets. 120 | 121 | SSM options: 122 | -x, --extend-output Extend truncated command outputs by fetching S3 objects containing full ones 123 | -b, --s3-bucket= S3 bucket in which S3 objects containing full command outputs are stored. Required when --extend-output is provided. 124 | -k, --s3-key-prefix= Key prefix of S3 objects containing full command outputs. 125 | ``` 126 | 127 | ## Example 128 | 129 | ```bash 130 | $ vaulted -n lab-admin -- ssm-sh list instances --filter Name="*itsdalmo" -o example.json 131 | 132 | Instance ID | Name | State | Image ID | Platform | Version | IP | Status | Last pinged 133 | i-03762678c45546813 | ssm-manager-manual-test-itsdalmo | running | ami-db1688a2 | Amazon Linux | 2.0 | 172.53.17.163 | Online | 2018-02-09 12:37 134 | i-0d04464ff18b5db7d | ssm-manager-manual-test-itsdalmo | running | ami-db1688a2 | Amazon Linux | 2.0 | 172.53.20.172 | Online | 2018-02-09 12:39 135 | 136 | $ vaulted -n lab-admin -- ssm-sh shell --target-file example.json 137 | Initialized with targets: [i-03762678c45546813 i-0d04464ff18b5db7d] 138 | Type 'exit' to exit. Use ctrl-c to abort running commands. 139 | 140 | $ ps aux | grep agent 141 | i-03762678c45546813 - Success: 142 | root 3261 0.0 1.9 243560 19668 ? Ssl Jan27 4:29 /usr/bin/amazon-ssm-agent 143 | root 9058 0.0 0.0 9152 936 ? S 15:02 0:00 grep agent 144 | 145 | i-0d04464ff18b5db7d - Success: 146 | root 3245 0.0 1.9 317292 19876 ? Ssl Feb05 0:27 /usr/bin/amazon-ssm-agent 147 | root 4893 0.0 0.0 9152 924 ? S 15:02 0:00 grep agent 148 | 149 | $ echo $HOSTNAME 150 | i-03762678c45546813 - Success: 151 | ip-172-53-17-163.eu-west-1.compute.internal 152 | 153 | i-0d04464ff18b5db7d - Success: 154 | ip-172-53-20-172.eu-west-1.compute.internal 155 | ``` 156 | 157 | #### Note 158 | 159 | If you don't see any instances listed and still want to test `ssm-sh`, 160 | you can see the [terraform/README.md](./terraform/README.md) for a quick 161 | way of setting up some test instances. 162 | -------------------------------------------------------------------------------- /command/describe-document.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/itsdalmo/ssm-sh/manager" 5 | "github.com/pkg/errors" 6 | "os" 7 | ) 8 | 9 | // DescribeDocumentCommand contains all arguments for describe-document command 10 | type DescribeDocumentCommand struct { 11 | Name string `short:"n" long:"name" description:"Name of document in ssm."` 12 | } 13 | 14 | // Execute describe-documents command 15 | func (command *DescribeDocumentCommand) Execute(args []string) error { 16 | if command.Name == "" { 17 | return errors.New("No document name set") 18 | } 19 | 20 | sess, err := newSession() 21 | if err != nil { 22 | return errors.Wrap(err, "failed to create new aws session") 23 | } 24 | 25 | m := manager.NewManager(sess, Command.AwsOpts.Region, manager.Opts{}) 26 | 27 | document, err := m.DescribeDocument(command.Name) 28 | if err != nil { 29 | return errors.Wrap(err, "failed to describe document") 30 | } 31 | 32 | if err := PrintDocumentDescription(os.Stdout, document); err != nil { 33 | return errors.Wrap(err, "failed to print document") 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /command/list-documents.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ssm" 6 | "github.com/itsdalmo/ssm-sh/manager" 7 | "github.com/pkg/errors" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | // ListDocumentsCommand contains all arguments for list-documents command 13 | type ListDocumentsCommand struct { 14 | Filters []*documentFilter `short:"f" long:"filter" description:"Filter the produced list by property (Name, Owner, DocumentType, PlatformTypes)"` 15 | Limit int64 `short:"l" long:"limit" description:"Limit the number of instances printed" default:"50"` 16 | } 17 | 18 | type documentFilter ssm.DocumentFilter 19 | 20 | // Execute list-documents command 21 | func (command *ListDocumentsCommand) Execute([]string) error { 22 | sess, err := newSession() 23 | if err != nil { 24 | return errors.Wrap(err, "failed to create new session") 25 | } 26 | m := manager.NewManager(sess, Command.AwsOpts.Region, manager.Opts{}) 27 | 28 | var filters []*ssm.DocumentFilter 29 | for _, filter := range command.Filters { 30 | filters = append(filters, &ssm.DocumentFilter{ 31 | Key: filter.Key, 32 | Value: filter.Value, 33 | }) 34 | } 35 | 36 | documents, err := m.ListDocuments(command.Limit, filters) 37 | if err != nil { 38 | return errors.Wrap(err, "failed to list documents") 39 | } 40 | 41 | if err := PrintDocuments(os.Stdout, documents); err != nil { 42 | return errors.Wrap(err, "failed to print documents") 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (d *documentFilter) UnmarshalFlag(input string) error { 49 | parts := strings.Split(input, "=") 50 | if len(parts) != 2 { 51 | return errors.New("expected a key and a value separated by =") 52 | } 53 | 54 | d.Key = aws.String(parts[0]) 55 | d.Value = aws.String(parts[1]) 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /command/list-instances.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | 9 | "github.com/itsdalmo/ssm-sh/manager" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type ListInstancesCommand struct { 14 | Tags []*tag `short:"f" long:"filter" description:"Filter the produced list by tag (key=value,..)"` 15 | Limit int64 `short:"l" long:"limit" description:"Limit the number of instances printed" default:"50"` 16 | Output string `short:"o" long:"output" description:"Path to a file where the list of instances will be written as JSON."` 17 | } 18 | 19 | func (command *ListInstancesCommand) Execute([]string) error { 20 | sess, err := newSession() 21 | if err != nil { 22 | return errors.Wrap(err, "failed to create new session") 23 | } 24 | m := manager.NewManager(sess, Command.AwsOpts.Region, manager.Opts{}) 25 | 26 | var filters []*manager.TagFilter 27 | for _, tag := range command.Tags { 28 | filters = append(filters, &manager.TagFilter{ 29 | Key: tag.Key, 30 | Values: tag.Values, 31 | }) 32 | } 33 | instances, err := m.ListInstances(command.Limit, filters) 34 | if err != nil { 35 | return errors.Wrap(err, "failed to list instances") 36 | } 37 | 38 | if err := PrintInstances(os.Stdout, instances); err != nil { 39 | return errors.Wrap(err, "failed to print instances") 40 | } 41 | 42 | if command.Output != "" { 43 | j, err := json.MarshalIndent(instances, "", " ") 44 | if err != nil { 45 | return errors.Wrap(err, "failed to marshal instances") 46 | } 47 | if err := ioutil.WriteFile(command.Output, j, 0644); err != nil { 48 | return errors.Wrap(err, "failed to write output") 49 | } 50 | } 51 | 52 | return nil 53 | } 54 | 55 | type tag manager.TagFilter 56 | 57 | func (t *tag) UnmarshalFlag(value string) error { 58 | parts := strings.Split(value, "=") 59 | if len(parts) != 2 { 60 | return errors.New("expected a key and a value separated by =") 61 | } 62 | 63 | values := strings.Split(parts[1], ",") 64 | if len(values) < 1 { 65 | return errors.New("expected one or more values separated by ,") 66 | } 67 | 68 | t.Key = parts[0] 69 | t.Values = values 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /command/root.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | var Command RootCommand 4 | 5 | type RootCommand struct { 6 | Version func() `short:"v" long:"version" description:"Print the version and exit."` 7 | List ListCommand `command:"list" alias:"ls" description:"List managed instances or documents."` 8 | Shell ShellCommand `command:"shell" alias:"sh" description:"Start an interactive shell."` 9 | Run RunCommand `command:"run" description:"Run a command or document on the targeted instances."` 10 | Describe DescribeCommand `command:"describe" description:"Description a document from ssm."` 11 | AwsOpts AwsOptions `group:"AWS Options"` 12 | } 13 | 14 | type ListCommand struct { 15 | Instances ListInstancesCommand `command:"instances" alias:"ins" description:"List managed instances."` 16 | Documents ListDocumentsCommand `command:"documents" alias:"doc" description:"List managed documents."` 17 | } 18 | 19 | type RunCommand struct { 20 | RunCmd RunCmdCommand `command:"command" alias:"cmd" description:"Run a command on the targeted instances."` 21 | RunDocument RunDocumentCommand `command:"document" alias:"doc" description:"Runs a document from ssm."` 22 | } 23 | 24 | type DescribeCommand struct { 25 | Describe DescribeDocumentCommand `command:"document" alias:"doc" description:"Description a document from ssm."` 26 | } 27 | 28 | type AwsOptions struct { 29 | Profile string `short:"p" long:"profile" description:"AWS Profile to use. (If you are not using Vaulted)."` 30 | Region string `short:"r" long:"region" description:"Region to target."` 31 | } 32 | 33 | type TargetOptions struct { 34 | Targets []string `short:"t" long:"target" description:"One or more instance ids to target"` 35 | TargetFile string `long:"target-file" description:"Path to a JSON file containing a list of targets."` 36 | } 37 | -------------------------------------------------------------------------------- /command/run-cmd.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/itsdalmo/ssm-sh/manager" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type RunCmdCommand struct { 15 | Timeout int `short:"i" long:"timeout" description:"Seconds to wait for command result before timing out." default:"30"` 16 | SSMOpts SSMOptions `group:"SSM options"` 17 | TargetOpts TargetOptions 18 | } 19 | 20 | func (command *RunCmdCommand) Execute(args []string) error { 21 | sess, err := newSession() 22 | if err != nil { 23 | return errors.Wrap(err, "failed to create new aws session") 24 | } 25 | 26 | opts, err := command.SSMOpts.Parse() 27 | if err != nil { 28 | return err 29 | } 30 | m := manager.NewManager(sess, Command.AwsOpts.Region, *opts) 31 | targets, err := setTargets(command.TargetOpts) 32 | if err != nil { 33 | return errors.Wrap(err, "failed to set targets") 34 | } 35 | fmt.Printf("Use ctrl-c to abort the command early.\n\n") 36 | 37 | // Start the command 38 | cmd := strings.Join(args, " ") 39 | commandID, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": cmd}) 40 | if err != nil { 41 | return errors.Wrap(err, "failed to run command") 42 | } 43 | 44 | // Catch sigterms to gracefully shut down 45 | var interrupts int 46 | abort := interruptHandler() 47 | 48 | // Get output 49 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(command.Timeout)*time.Second) 50 | defer cancel() 51 | 52 | out := make(chan *manager.CommandOutput) 53 | go m.GetCommandOutput(ctx, targets, commandID, out) 54 | 55 | for { 56 | select { 57 | case <-ctx.Done(): 58 | return errors.New("timeout reached") 59 | case <-abort: 60 | interrupts++ 61 | err := m.AbortCommand(targets, commandID) 62 | if err != nil { 63 | return errors.Wrap(err, "failed to abort command on sigterm") 64 | } 65 | if interrupts > 1 { 66 | return errors.New("interrupted by user") 67 | } 68 | case output, open := <-out: 69 | if !open { 70 | return nil 71 | } 72 | err := PrintCommandOutput(os.Stdout, output) 73 | if err != nil { 74 | return errors.Wrap(err, "failed to print output") 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /command/run-document.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/itsdalmo/ssm-sh/manager" 7 | "github.com/pkg/errors" 8 | "os" 9 | "time" 10 | ) 11 | 12 | // RunDocumentCommand contains all arguments for run-document command 13 | type RunDocumentCommand struct { 14 | Name string `short:"n" long:"name" description:"Name of document in ssm."` 15 | Timeout int `short:"i" long:"timeout" description:"Seconds to wait for command result before timing out." default:"30"` 16 | Parameters map[string]string `short:"p" long:"parameter" description:"Zero or more parameters for the document (name:value)"` 17 | SSMOpts SSMOptions `group:"SSM options"` 18 | TargetOpts TargetOptions 19 | } 20 | 21 | // Execute run-document command 22 | func (command *RunDocumentCommand) Execute(args []string) error { 23 | if command.Name == "" { 24 | return errors.New("No document name set to trigger") 25 | } 26 | 27 | sess, err := newSession() 28 | if err != nil { 29 | return errors.Wrap(err, "failed to create new aws session") 30 | } 31 | 32 | opts, err := command.SSMOpts.Parse() 33 | if err != nil { 34 | return err 35 | } 36 | m := manager.NewManager(sess, Command.AwsOpts.Region, *opts) 37 | targets, err := setTargets(command.TargetOpts) 38 | if err != nil { 39 | return errors.Wrap(err, "failed to set targets") 40 | } 41 | fmt.Printf("Use ctrl-c to abort the command early.\n\n") 42 | 43 | // Start the command 44 | commandID, err := m.RunCommand(targets, command.Name, command.Parameters) 45 | if err != nil { 46 | return errors.Wrap(err, "failed to run command") 47 | } 48 | 49 | // Catch sigterms to gracefully shut down 50 | var interrupts int 51 | abort := interruptHandler() 52 | 53 | // Get output 54 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(command.Timeout)*time.Second) 55 | defer cancel() 56 | 57 | out := make(chan *manager.CommandOutput) 58 | go m.GetCommandOutput(ctx, targets, commandID, out) 59 | 60 | for { 61 | select { 62 | case <-ctx.Done(): 63 | return errors.New("timeout reached") 64 | case <-abort: 65 | interrupts++ 66 | err := m.AbortCommand(targets, commandID) 67 | if err != nil { 68 | return errors.Wrap(err, "failed to abort command on sigterm") 69 | } 70 | if interrupts > 1 { 71 | return errors.New("interrupted by user") 72 | } 73 | case output, open := <-out: 74 | if !open { 75 | return nil 76 | } 77 | err := PrintCommandOutput(os.Stdout, output) 78 | if err != nil { 79 | return errors.Wrap(err, "failed to print output") 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /command/shell.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/chzyer/readline" 11 | "github.com/itsdalmo/ssm-sh/manager" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type ShellCommand struct { 16 | SSMOpts SSMOptions `group:"SSM options"` 17 | TargetOpts TargetOptions 18 | } 19 | 20 | func (command *ShellCommand) Execute([]string) error { 21 | sess, err := newSession() 22 | if err != nil { 23 | return errors.Wrap(err, "failed to create new aws session") 24 | } 25 | 26 | opts, err := command.SSMOpts.Parse() 27 | if err != nil { 28 | return err 29 | } 30 | m := manager.NewManager(sess, Command.AwsOpts.Region, *opts) 31 | targets, err := setTargets(command.TargetOpts) 32 | if err != nil { 33 | return errors.Wrap(err, "failed to set targets") 34 | } 35 | fmt.Printf("Type 'exit' to exit. Use ctrl-c to abort running commands.\n\n") 36 | 37 | // (Parent) Context for the main thread and output channel 38 | ctx, cancel := context.WithCancel(context.Background()) 39 | defer cancel() 40 | 41 | // Catch sigterms to gracefully shut down 42 | var interrupts int 43 | abort := interruptHandler() 44 | 45 | // Configure readline 46 | rl, err := readline.NewEx(&readline.Config{ 47 | Prompt: "\033[31m»\033[0m ", 48 | HistoryFile: "/tmp/ssh-sh.tmp", 49 | InterruptPrompt: "^C", 50 | EOFPrompt: "^D", 51 | }) 52 | if err != nil { 53 | panic(err) 54 | } 55 | defer rl.Close() 56 | 57 | for { 58 | cmd, err := rl.Readline() 59 | 60 | if err == readline.ErrInterrupt { 61 | continue 62 | } else if err == io.EOF { 63 | return nil 64 | } 65 | 66 | cmd = strings.TrimSpace(cmd) 67 | if len(cmd) == 0 { 68 | continue 69 | } else if cmd == "exit" { 70 | return nil 71 | } 72 | 73 | // Start command 74 | commandID, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": cmd}) 75 | if err != nil { 76 | return errors.Wrap(err, "failed to Run command") 77 | } 78 | out := make(chan *manager.CommandOutput) 79 | go m.GetCommandOutput(ctx, targets, commandID, out) 80 | 81 | Polling: 82 | for { 83 | select { 84 | case <-abort: 85 | interrupts++ 86 | err := m.AbortCommand(targets, commandID) 87 | if err != nil { 88 | return errors.Wrap(err, "failed to abort command on sigterm") 89 | } 90 | case output, open := <-out: 91 | if output == nil && !open { 92 | break Polling 93 | } 94 | err := PrintCommandOutput(os.Stdout, output) 95 | if err != nil { 96 | return errors.Wrap(err, "failed to print output") 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /command/ssm_opts.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/itsdalmo/ssm-sh/manager" 7 | ) 8 | 9 | type SSMOptions struct { 10 | ExtendOutput bool `short:"x" long:"extend-output" description:"Extend truncated command outputs by fetching S3 objects containing full ones"` 11 | S3Bucket string `short:"b" long:"s3-bucket" description:"S3 bucket in which S3 objects containing full command outputs are stored. Required when --extend-output is provided." default:""` 12 | S3KeyPrefix string `short:"k" long:"s3-key-prefix" description:"Key prefix of S3 objects containing full command outputs." default:""` 13 | } 14 | 15 | func (o SSMOptions) Validate() error { 16 | if o.ExtendOutput && o.S3Bucket == "" { 17 | return fmt.Errorf("--s3-bucket must be a non-empty string when --extend-output is provided") 18 | } 19 | return nil 20 | } 21 | 22 | func (o SSMOptions) Parse() (*manager.Opts, error) { 23 | err := o.Validate() 24 | if err != nil { 25 | return nil, err 26 | } 27 | return &manager.Opts{ 28 | ExtendOutput: o.ExtendOutput, 29 | S3Bucket: o.S3Bucket, 30 | S3KeyPrefix: o.S3KeyPrefix, 31 | }, nil 32 | } 33 | -------------------------------------------------------------------------------- /command/utils.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "os/signal" 11 | "strings" 12 | "text/tabwriter" 13 | "time" 14 | 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | "github.com/fatih/color" 17 | "github.com/itsdalmo/ssm-sh/manager" 18 | ) 19 | 20 | // Create a new AWS session 21 | func newSession() (*session.Session, error) { 22 | opts := session.Options{ 23 | SharedConfigState: session.SharedConfigEnable, 24 | } 25 | if Command.AwsOpts.Profile != "" { 26 | opts.Profile = Command.AwsOpts.Profile 27 | } 28 | sess, err := session.NewSessionWithOptions(opts) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return sess, nil 33 | } 34 | 35 | // Set targets 36 | func setTargets(options TargetOptions) ([]string, error) { 37 | var instances []manager.Instance 38 | var targets []string 39 | if options.TargetFile != "" { 40 | content, err := ioutil.ReadFile(options.TargetFile) 41 | if err != nil { 42 | return nil, err 43 | } 44 | if err := json.Unmarshal(content, &instances); err != nil { 45 | return nil, err 46 | } 47 | for _, instance := range instances { 48 | targets = append(targets, instance.ID()) 49 | } 50 | } 51 | 52 | for _, target := range options.Targets { 53 | targets = append(targets, target) 54 | } 55 | 56 | if len(targets) == 0 { 57 | return nil, errors.New("no targets set") 58 | } 59 | 60 | fmt.Printf("Initialized with targets: %s\n", targets) 61 | 62 | return targets, nil 63 | 64 | } 65 | 66 | func interruptHandler() <-chan bool { 67 | abort := make(chan bool) 68 | sigterm := make(chan os.Signal) 69 | signal.Notify(sigterm, os.Interrupt) 70 | 71 | go func() { 72 | defer signal.Stop(sigterm) 73 | defer close(sigterm) 74 | defer close(abort) 75 | 76 | // Use a threshold for time since last signal 77 | // to avoid multiple SIGTERM when pressing ctrl+c 78 | // on a keyboard. 79 | var last time.Time 80 | threshold := 50 * time.Millisecond 81 | 82 | for range sigterm { 83 | if time.Since(last) < threshold { 84 | continue 85 | } 86 | abort <- true 87 | last = time.Now() 88 | } 89 | }() 90 | return abort 91 | } 92 | 93 | // PrintCommandOutput writes the output from command invocations. 94 | func PrintCommandOutput(wrt io.Writer, output *manager.CommandOutput) error { 95 | header := color.New(color.Bold) 96 | if _, err := header.Fprintf(wrt, "\n%s - %s:\n", output.InstanceID, output.Status); err != nil { 97 | return err 98 | } 99 | if output.Error != nil { 100 | if _, err := fmt.Fprintf(wrt, "%s\n", output.Error); err != nil { 101 | return err 102 | } 103 | } 104 | if _, err := fmt.Fprintf(wrt, "%s\n", output.Output); err != nil { 105 | return err 106 | } 107 | if output.OutputUrl != "" { 108 | if _, err := fmt.Fprintf(wrt, "(Output URL: %s)\n", output.OutputUrl); err != nil { 109 | return err 110 | } 111 | } 112 | return nil 113 | } 114 | 115 | // PrintInstances writes the output from ListInstances. 116 | func PrintInstances(wrt io.Writer, instances []*manager.Instance) error { 117 | w := tabwriter.NewWriter(wrt, 0, 8, 1, ' ', 0) 118 | header := []string{ 119 | "Instance ID", 120 | "Name", 121 | "State", 122 | "Image ID", 123 | "Platform", 124 | "Version", 125 | "IP", 126 | "Status", 127 | "Last pinged", 128 | } 129 | 130 | if _, err := fmt.Fprintln(w, strings.Join(header, "\t|\t")); err != nil { 131 | return err 132 | } 133 | for _, instance := range instances { 134 | if _, err := fmt.Fprintln(w, instance.TabString()); err != nil { 135 | return err 136 | } 137 | } 138 | err := w.Flush() 139 | return err 140 | } 141 | 142 | // PrintDocuments writes the output from ListDocuments. 143 | func PrintDocuments(wrt io.Writer, documents []*manager.DocumentIdentifier) error { 144 | w := tabwriter.NewWriter(wrt, 0, 8, 1, ' ', 0) 145 | header := []string{ 146 | "Name", 147 | "Owner", 148 | "Document Version", 149 | "Document Format", 150 | "Document Type", 151 | "Schema Version", 152 | "Target Type", 153 | } 154 | 155 | if _, err := fmt.Fprintln(w, strings.Join(header, "\t|\t")); err != nil { 156 | return err 157 | } 158 | for _, document := range documents { 159 | if _, err := fmt.Fprintln(w, document.TabString()); err != nil { 160 | return err 161 | } 162 | } 163 | err := w.Flush() 164 | return err 165 | 166 | } 167 | 168 | // PrintDocumentDescription writes the output from DescribeDocument. 169 | func PrintDocumentDescription(wrt io.Writer, document *manager.DocumentDescription) error { 170 | w := tabwriter.NewWriter(wrt, 0, 8, 1, ' ', 0) 171 | 172 | header := []string{ 173 | "Name", 174 | "Description", 175 | "Owner", 176 | "Document Version", 177 | "Document Format", 178 | "Document Type", 179 | "Schema Version", 180 | "Target Type", 181 | } 182 | 183 | if _, err := fmt.Fprintln(w, strings.Join(header, "\t|\t")); err != nil { 184 | return err 185 | } 186 | if _, err := fmt.Fprintln(w, document.TabString()); err != nil { 187 | return err 188 | } 189 | 190 | if len(document.Parameters) > 0 { 191 | if err := w.Flush(); err != nil { 192 | return err 193 | } 194 | 195 | fmt.Fprintf(wrt, "\nParameters:\n") 196 | parameterHeader := []string{ 197 | "Name", 198 | "Type", 199 | "DefaultValue", 200 | "Description", 201 | } 202 | if _, err := fmt.Fprintln(w, strings.Join(parameterHeader, "\t|\t")); err != nil { 203 | return err 204 | } 205 | if _, err := fmt.Fprintln(w, document.ParametersTabString()); err != nil { 206 | return err 207 | } 208 | } 209 | 210 | err := w.Flush() 211 | return err 212 | } 213 | 214 | // WriteInstances writes the output of ListInstances to a file as JSON. 215 | func WriteInstances(wrt io.Writer, instances []*manager.Instance) error { 216 | w := json.NewEncoder(wrt) 217 | err := w.Encode(instances) 218 | return err 219 | } 220 | -------------------------------------------------------------------------------- /command/utils_test.go: -------------------------------------------------------------------------------- 1 | package command_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/itsdalmo/ssm-sh/command" 11 | "github.com/itsdalmo/ssm-sh/manager" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestPrintInstances(t *testing.T) { 16 | input := []*manager.Instance{ 17 | { 18 | InstanceID: "i-00000000000000001", 19 | Name: "instance 1", 20 | State: "running", 21 | ImageID: "ami-db000001", 22 | PlatformName: "Amazon Linux", 23 | PlatformVersion: "1.0", 24 | IPAddress: "10.0.0.1", 25 | PingStatus: "Online", 26 | LastPingDateTime: time.Date(2018, time.January, 27, 13, 32, 0, 0, time.UTC), 27 | }, 28 | { 29 | InstanceID: "i-00000000000000002", 30 | Name: "instance 2", 31 | State: "running", 32 | ImageID: "ami-db000002", 33 | PlatformName: "Amazon Linux 2", 34 | PlatformVersion: "2.0", 35 | IPAddress: "10.0.0.100", 36 | PingStatus: "Online", 37 | LastPingDateTime: time.Date(2018, time.January, 30, 13, 32, 0, 0, time.UTC), 38 | }, 39 | } 40 | 41 | t.Run("Print works", func(t *testing.T) { 42 | expected := strings.TrimSpace(` 43 | Instance ID | Name | State | Image ID | Platform | Version | IP | Status | Last pinged 44 | i-00000000000000001 | instance 1 | running | ami-db000001 | Amazon Linux | 1.0 | 10.0.0.1 | Online | 2018-01-27 13:32 45 | i-00000000000000002 | instance 2 | running | ami-db000002 | Amazon Linux 2 | 2.0 | 10.0.0.100 | Online | 2018-01-30 13:32 46 | `) 47 | 48 | b := new(bytes.Buffer) 49 | err := command.PrintInstances(b, input) 50 | actual := strings.TrimSpace(b.String()) 51 | assert.Nil(t, err) 52 | assert.NotNil(t, actual) 53 | assert.Equal(t, expected, actual) 54 | }) 55 | } 56 | 57 | func TestPrintCommandOutput(t *testing.T) { 58 | input := []*manager.CommandOutput{ 59 | { 60 | InstanceID: "i-00000000000000001", 61 | Status: "Success", 62 | Output: "Standard output", 63 | Error: nil, 64 | }, 65 | { 66 | InstanceID: "i-00000000000000001", 67 | Status: "Success", 68 | Output: "Extended standard output", 69 | OutputUrl: "https://s3-ap-northeast-1.amazonaws.com/mybucket/foobar/c0896747-af2b-4359-bc34-0f951ce02007/i-00000000000000001/awsrunShellScript/0.awsrunShellScript/stdout", 70 | Error: nil, 71 | }, 72 | { 73 | InstanceID: "i-00000000000000002", 74 | Status: "Failed", 75 | Output: "Standard error", 76 | Error: nil, 77 | }, 78 | { 79 | InstanceID: "i-00000000000000003", 80 | Status: "Error", 81 | Output: "", 82 | Error: errors.New("error"), 83 | }, 84 | } 85 | 86 | t.Run("Print works", func(t *testing.T) { 87 | expected := strings.TrimSpace(` 88 | i-00000000000000001 - Success: 89 | Standard output 90 | 91 | i-00000000000000001 - Success: 92 | Extended standard output 93 | (Output URL: https://s3-ap-northeast-1.amazonaws.com/mybucket/foobar/c0896747-af2b-4359-bc34-0f951ce02007/i-00000000000000001/awsrunShellScript/0.awsrunShellScript/stdout) 94 | 95 | i-00000000000000002 - Failed: 96 | Standard error 97 | 98 | i-00000000000000003 - Error: 99 | error 100 | `) 101 | 102 | b := new(bytes.Buffer) 103 | for _, instance := range input { 104 | err := command.PrintCommandOutput(b, instance) 105 | assert.Nil(t, err) 106 | } 107 | actual := strings.TrimSpace(b.String()) 108 | assert.NotNil(t, actual) 109 | assert.Equal(t, expected, actual) 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /command/version.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // CommandVersion (set from main.go) 9 | var CommandVersion string 10 | 11 | func init() { 12 | Command.Version = func() { 13 | fmt.Println(CommandVersion) 14 | os.Exit(0) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itsdalmo/ssm-sh 2 | 3 | require ( 4 | github.com/aws/aws-sdk-go v1.25.48 5 | github.com/chzyer/logex v1.1.10 // indirect 6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/fatih/color v1.7.0 10 | github.com/jessevdk/go-flags v1.4.0 11 | github.com/mattn/go-colorable v0.1.4 // indirect 12 | github.com/mattn/go-isatty v0.0.10 // indirect 13 | github.com/pkg/errors v0.8.1 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | github.com/stretchr/testify v1.2.2 16 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc // indirect 17 | golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab // indirect 18 | golang.org/x/text v0.3.0 // indirect 19 | ) 20 | 21 | go 1.13 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-sdk-go v1.25.48 h1:J82DYDGZHOKHdhx6hD24Tm30c2C3GchYGfN0mf9iKUk= 2 | github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 3 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 4 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 5 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 6 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 7 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 8 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 12 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 13 | github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= 14 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 15 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 16 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 17 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 18 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 19 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 20 | github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= 21 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 22 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 23 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 27 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 28 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc h1:ZMCWScCvS2fUVFw8LOpxyUUW5qiviqr4Dg5NdjLeiLU= 29 | golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 30 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab h1:FvshnhkKW+LO3HWHodML8kuVX8rnJTxKm9dFPuI68UM= 33 | golang.org/x/sys v0.0.0-20191206220618-eeba5f6aabab/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 34 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 35 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 36 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/itsdalmo/ssm-sh/command" 7 | "github.com/jessevdk/go-flags" 8 | ) 9 | 10 | // Version is set on build by the Git release tag. 11 | var version = "unknown" 12 | 13 | func main() { 14 | command.CommandVersion = version 15 | _, err := flags.Parse(&command.Command) 16 | if err != nil { 17 | if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { 18 | os.Exit(0) 19 | } else { 20 | os.Exit(1) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /manager/document-description.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ssm" 6 | "strings" 7 | ) 8 | 9 | // NewDocumentDescription creates a new Document from ssm.DocumentIdentifier. 10 | func NewDocumentDescription(ssmDocument *ssm.DocumentDescription) *DocumentDescription { 11 | var parameters []*DocumentParameter 12 | 13 | for _, parameter := range ssmDocument.Parameters { 14 | parameters = append(parameters, &DocumentParameter{ 15 | aws.StringValue(parameter.Name), 16 | aws.StringValue(parameter.Description), 17 | aws.StringValue(parameter.DefaultValue), 18 | aws.StringValue(parameter.Type), 19 | }) 20 | } 21 | 22 | return &DocumentDescription{ 23 | Name: aws.StringValue(ssmDocument.Name), 24 | Description: aws.StringValue(ssmDocument.Description), 25 | Owner: aws.StringValue(ssmDocument.Owner), 26 | DocumentVersion: aws.StringValue(ssmDocument.DocumentVersion), 27 | DocumentFormat: aws.StringValue(ssmDocument.DocumentFormat), 28 | DocumentType: aws.StringValue(ssmDocument.DocumentType), 29 | SchemaVersion: aws.StringValue(ssmDocument.SchemaVersion), 30 | TargetType: aws.StringValue(ssmDocument.TargetType), 31 | Parameters: parameters, 32 | } 33 | } 34 | 35 | // DocumentDescription describes relevant information about a SSM Document 36 | type DocumentDescription struct { 37 | Name string `json:"name"` 38 | Description string `json:"description"` 39 | Owner string `json:"owner"` 40 | DocumentVersion string `json:"documentVersion"` 41 | DocumentFormat string `json:"documentFormat"` 42 | DocumentType string `json:"documentType"` 43 | SchemaVersion string `json:"schemaVersion"` 44 | TargetType string `json:"targetType"` 45 | Parameters []*DocumentParameter 46 | } 47 | 48 | // DocumentParameter describes relevant information about a SSM Document Parameter 49 | type DocumentParameter struct { 50 | Name string 51 | Description string 52 | DefaultValue string 53 | Type string 54 | } 55 | 56 | // ParametersTabString returns all parameter values separated by "\t|\t" for 57 | // an document. Use with tabwriter to output a table of parameters. 58 | func (d *DocumentDescription) ParametersTabString() string { 59 | var del = "|" 60 | var tab = "\t" 61 | 62 | var newLine = "\n" 63 | var fields []string 64 | var line []string 65 | 66 | for _, parameter := range d.Parameters { 67 | line = []string{ 68 | parameter.Name, 69 | parameter.Type, 70 | parameter.DefaultValue, 71 | parameter.Description, 72 | } 73 | fields = append(fields, strings.Join(line, tab+del+tab)) 74 | } 75 | return strings.Join(fields, newLine) 76 | } 77 | 78 | // TabString returns all field values separated by "\t|\t" for 79 | // an document. Use with tabwriter to output a table of documents. 80 | func (d *DocumentDescription) TabString() string { 81 | var del = "|" 82 | var tab = "\t" 83 | 84 | fields := []string{ 85 | d.Name, 86 | d.Description, 87 | d.Owner, 88 | d.DocumentVersion, 89 | d.DocumentFormat, 90 | d.DocumentType, 91 | d.SchemaVersion, 92 | d.TargetType, 93 | } 94 | return strings.Join(fields, tab+del+tab) 95 | } 96 | -------------------------------------------------------------------------------- /manager/document-description_test.go: -------------------------------------------------------------------------------- 1 | package manager_test 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ssm" 6 | "github.com/itsdalmo/ssm-sh/manager" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestDocumentDescription(t *testing.T) { 12 | ssmDocumentDescriptionWithoutParameters := &ssm.DocumentDescription{ 13 | Name: aws.String("AWS-RunShellScript"), 14 | Description: aws.String("Run a shell script or specify the commands to run."), 15 | Owner: aws.String("Amazon"), 16 | DocumentVersion: aws.String("1"), 17 | DocumentFormat: aws.String("JSON"), 18 | DocumentType: aws.String("Command"), 19 | SchemaVersion: aws.String("1.2"), 20 | TargetType: aws.String("Linux"), 21 | } 22 | 23 | ssmDocumentDescriptionWithParameters := &ssm.DocumentDescription{ 24 | Name: aws.String("AWS-RunShellScript"), 25 | Description: aws.String("Run a shell script or specify the commands to run."), 26 | Owner: aws.String("Amazon"), 27 | DocumentVersion: aws.String("1"), 28 | DocumentFormat: aws.String("JSON"), 29 | DocumentType: aws.String("Command"), 30 | SchemaVersion: aws.String("1.2"), 31 | TargetType: aws.String("Linux"), 32 | Parameters: []*ssm.DocumentParameter{ 33 | { 34 | Name: aws.String("commands"), 35 | Description: aws.String("Specify a shell script or a command to run"), 36 | DefaultValue: aws.String(""), 37 | Type: aws.String("StringList"), 38 | }, 39 | { 40 | Name: aws.String("executionTimeout"), 41 | Description: aws.String("The time in seconds for a command to complete"), 42 | DefaultValue: aws.String("3600"), 43 | Type: aws.String("String"), 44 | }, 45 | }, 46 | } 47 | 48 | outputWithoutParameters := &manager.DocumentDescription{ 49 | Name: "AWS-RunShellScript", 50 | Description: "Run a shell script or specify the commands to run.", 51 | Owner: "Amazon", 52 | DocumentVersion: "1", 53 | DocumentFormat: "JSON", 54 | DocumentType: "Command", 55 | SchemaVersion: "1.2", 56 | TargetType: "Linux", 57 | } 58 | 59 | outputWithParameters := &manager.DocumentDescription{ 60 | Name: "AWS-RunShellScript", 61 | Description: "Run a shell script or specify the commands to run.", 62 | Owner: "Amazon", 63 | DocumentVersion: "1", 64 | DocumentFormat: "JSON", 65 | DocumentType: "Command", 66 | SchemaVersion: "1.2", 67 | TargetType: "Linux", 68 | Parameters: []*manager.DocumentParameter{ 69 | { 70 | Name: "commands", 71 | Description: "Specify a shell script or a command to run", 72 | DefaultValue: "", 73 | Type: "StringList", 74 | }, 75 | { 76 | Name: "executionTimeout", 77 | Description: "The time in seconds for a command to complete", 78 | DefaultValue: "3600", 79 | Type: "String", 80 | }, 81 | }, 82 | } 83 | 84 | t.Run("NewDocumentDescription works", func(t *testing.T) { 85 | expected := outputWithoutParameters 86 | actual := manager.NewDocumentDescription(ssmDocumentDescriptionWithoutParameters) 87 | assert.Equal(t, expected, actual) 88 | }) 89 | 90 | t.Run("Instance TabString works", func(t *testing.T) { 91 | expected := "AWS-RunShellScript\t|\tRun a shell script or specify the commands to run.\t|\tAmazon\t|\t1\t|\tJSON\t|\tCommand\t|\t1.2\t|\tLinux" 92 | actual := outputWithoutParameters.TabString() 93 | assert.Equal(t, expected, actual) 94 | }) 95 | 96 | t.Run("NewDocumentDescription works with parameters", func(t *testing.T) { 97 | expected := outputWithParameters 98 | actual := manager.NewDocumentDescription(ssmDocumentDescriptionWithParameters) 99 | assert.Equal(t, expected, actual) 100 | }) 101 | 102 | t.Run("Instance ParametersTabString works", func(t *testing.T) { 103 | expected := "commands\t|\tStringList\t|\t\t|\tSpecify a shell script or a command to run\nexecutionTimeout\t|\tString\t|\t3600\t|\tThe time in seconds for a command to complete" 104 | actual := outputWithParameters.ParametersTabString() 105 | assert.Equal(t, expected, actual) 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /manager/document-identifier.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ssm" 6 | "strings" 7 | ) 8 | 9 | // NewDocumentIdentifier creates a new Document from ssm.DocumentIdentifier. 10 | func NewDocumentIdentifier(ssmDocument *ssm.DocumentIdentifier) *DocumentIdentifier { 11 | return &DocumentIdentifier{ 12 | Name: aws.StringValue(ssmDocument.Name), 13 | Owner: aws.StringValue(ssmDocument.Owner), 14 | DocumentVersion: aws.StringValue(ssmDocument.DocumentVersion), 15 | DocumentFormat: aws.StringValue(ssmDocument.DocumentFormat), 16 | DocumentType: aws.StringValue(ssmDocument.DocumentType), 17 | SchemaVersion: aws.StringValue(ssmDocument.SchemaVersion), 18 | TargetType: aws.StringValue(ssmDocument.TargetType), 19 | } 20 | } 21 | 22 | // DocumentIdentifier describes relevant information about a SSM Document 23 | type DocumentIdentifier struct { 24 | Name string `json:"name"` 25 | Owner string `json:"owner"` 26 | DocumentVersion string `json:"documentVersion"` 27 | DocumentFormat string `json:"documentFormat"` 28 | DocumentType string `json:"documentType"` 29 | SchemaVersion string `json:"schemaVersion"` 30 | TargetType string `json:"targetType"` 31 | } 32 | 33 | // TabString returns all field values separated by "\t|\t" for 34 | // an document. Use with tabwriter to output a table of documents. 35 | func (d *DocumentIdentifier) TabString() string { 36 | var del = "|" 37 | var tab = "\t" 38 | 39 | fields := []string{ 40 | d.Name, 41 | d.Owner, 42 | d.DocumentVersion, 43 | d.DocumentFormat, 44 | d.DocumentType, 45 | d.SchemaVersion, 46 | d.TargetType, 47 | } 48 | return strings.Join(fields, tab+del+tab) 49 | } 50 | -------------------------------------------------------------------------------- /manager/document-identifier_test.go: -------------------------------------------------------------------------------- 1 | package manager_test 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ssm" 6 | "github.com/itsdalmo/ssm-sh/manager" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestDocumentIdentifier(t *testing.T) { 12 | ssmDocumentIdentifier := &ssm.DocumentIdentifier{ 13 | Name: aws.String("AWS-RunShellScript"), 14 | Owner: aws.String("Amazon"), 15 | DocumentVersion: aws.String("1"), 16 | DocumentFormat: aws.String("JSON"), 17 | DocumentType: aws.String("Command"), 18 | SchemaVersion: aws.String("1.2"), 19 | TargetType: aws.String("Linux"), 20 | } 21 | 22 | output := &manager.DocumentIdentifier{ 23 | Name: "AWS-RunShellScript", 24 | Owner: "Amazon", 25 | DocumentVersion: "1", 26 | DocumentFormat: "JSON", 27 | DocumentType: "Command", 28 | SchemaVersion: "1.2", 29 | TargetType: "Linux", 30 | } 31 | 32 | t.Run("NewDocumentIdentifier works", func(t *testing.T) { 33 | expected := output 34 | actual := manager.NewDocumentIdentifier(ssmDocumentIdentifier) 35 | assert.Equal(t, expected, actual) 36 | }) 37 | 38 | t.Run("Instance TabString works", func(t *testing.T) { 39 | expected := "AWS-RunShellScript\t|\tAmazon\t|\t1\t|\tJSON\t|\tCommand\t|\t1.2\t|\tLinux" 40 | actual := output.TabString() 41 | assert.Equal(t, expected, actual) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /manager/instance.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ec2" 6 | "github.com/aws/aws-sdk-go/service/ssm" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // NewInstance creates a new Instance from ssm.InstanceInformation. 12 | func NewInstance(ssmInstance *ssm.InstanceInformation, ec2Instance *ec2.Instance) *Instance { 13 | var name string 14 | for _, tag := range ec2Instance.Tags { 15 | if aws.StringValue(tag.Key) == "Name" { 16 | name = aws.StringValue(tag.Value) 17 | } 18 | } 19 | return &Instance{ 20 | InstanceID: aws.StringValue(ssmInstance.InstanceId), 21 | Name: name, 22 | State: aws.StringValue(ec2Instance.State.Name), 23 | ImageID: aws.StringValue(ec2Instance.ImageId), 24 | PlatformName: aws.StringValue(ssmInstance.PlatformName), 25 | PlatformVersion: aws.StringValue(ssmInstance.PlatformVersion), 26 | IPAddress: aws.StringValue(ssmInstance.IPAddress), 27 | PingStatus: aws.StringValue(ssmInstance.PingStatus), 28 | LastPingDateTime: aws.TimeValue(ssmInstance.LastPingDateTime), 29 | } 30 | } 31 | 32 | // Instance describes relevant information about an instance-id 33 | // as collected from SSM and EC2 endpoints. And does not user pointers 34 | // for all values. 35 | type Instance struct { 36 | InstanceID string `json:"instanceId"` 37 | Name string `json:"name"` 38 | State string `json:"state"` 39 | ImageID string `json:"imageId"` 40 | PlatformName string `json:"platformName"` 41 | PlatformVersion string `json:"platformVersion"` 42 | IPAddress string `json:"ipAddress"` 43 | PingStatus string `json:"pingStatus"` 44 | LastPingDateTime time.Time `json:"lastPingDateTime"` 45 | } 46 | 47 | // ID returns the InstanceID of an Instance. 48 | func (i *Instance) ID() string { 49 | return i.InstanceID 50 | } 51 | 52 | // TabString returns all field values separated by "\t|\t" for 53 | // an instance. Use with tabwriter to output a table of instances. 54 | func (i *Instance) TabString() string { 55 | var del = "|" 56 | var tab = "\t" 57 | 58 | fields := []string{ 59 | i.InstanceID, 60 | i.Name, 61 | i.State, 62 | i.ImageID, 63 | i.PlatformName, 64 | i.PlatformVersion, 65 | i.IPAddress, 66 | i.PingStatus, 67 | i.LastPingDateTime.Format("2006-01-02 15:04"), 68 | } 69 | return strings.Join(fields, tab+del+tab) 70 | } 71 | -------------------------------------------------------------------------------- /manager/instance_test.go: -------------------------------------------------------------------------------- 1 | package manager_test 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/ec2" 6 | "github.com/aws/aws-sdk-go/service/ssm" 7 | "github.com/itsdalmo/ssm-sh/manager" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestInstance(t *testing.T) { 14 | ssmInput := &ssm.InstanceInformation{ 15 | InstanceId: aws.String("i-00000000000000001"), 16 | PlatformName: aws.String("Amazon Linux"), 17 | PlatformVersion: aws.String("1.0"), 18 | IPAddress: aws.String("10.0.0.1"), 19 | PingStatus: aws.String("Online"), 20 | LastPingDateTime: aws.Time(time.Date(2018, time.January, 27, 13, 32, 0, 0, time.UTC)), 21 | } 22 | 23 | ec2Input := &ec2.Instance{ 24 | ImageId: aws.String("ami-db000001"), 25 | State: &ec2.InstanceState{Name: aws.String("running")}, 26 | Tags: []*ec2.Tag{ 27 | { 28 | Key: aws.String("Name"), 29 | Value: aws.String("instance 1"), 30 | }, 31 | }, 32 | } 33 | 34 | output := &manager.Instance{ 35 | InstanceID: "i-00000000000000001", 36 | Name: "instance 1", 37 | State: "running", 38 | ImageID: "ami-db000001", 39 | PlatformName: "Amazon Linux", 40 | PlatformVersion: "1.0", 41 | IPAddress: "10.0.0.1", 42 | PingStatus: "Online", 43 | LastPingDateTime: time.Date(2018, time.January, 27, 13, 32, 0, 0, time.UTC), 44 | } 45 | 46 | t.Run("NewInstance works", func(t *testing.T) { 47 | expected := output 48 | actual := manager.NewInstance(ssmInput, ec2Input) 49 | assert.Equal(t, expected, actual) 50 | }) 51 | 52 | t.Run("Instance Id works", func(t *testing.T) { 53 | expected := "i-00000000000000001" 54 | actual := output.ID() 55 | assert.Equal(t, expected, actual) 56 | }) 57 | 58 | t.Run("Instance TabString works", func(t *testing.T) { 59 | expected := "i-00000000000000001\t|\tinstance 1\t|\trunning\t|\tami-db000001\t|\tAmazon Linux\t|\t1.0\t|\t10.0.0.1\t|\tOnline\t|\t2018-01-27 13:32" 60 | actual := output.TabString() 61 | assert.Equal(t, expected, actual) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/ec2" 16 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 17 | "github.com/aws/aws-sdk-go/service/s3" 18 | "github.com/aws/aws-sdk-go/service/s3/s3iface" 19 | "github.com/aws/aws-sdk-go/service/ssm" 20 | "github.com/aws/aws-sdk-go/service/ssm/ssmiface" 21 | "github.com/pkg/errors" 22 | ) 23 | 24 | // TagFilter represents a key=value pair for AWS EC2 tags. 25 | type TagFilter struct { 26 | Key string 27 | Values []string 28 | } 29 | 30 | // Filter returns the ec2.Filter representation of the TagFilter. 31 | func (t *TagFilter) Filter() *ec2.Filter { 32 | return &ec2.Filter{ 33 | Name: aws.String(fmt.Sprintf("tag:%s", t.Key)), 34 | Values: aws.StringSlice(t.Values), 35 | } 36 | } 37 | 38 | // CommandOutput is the return type transmitted over a channel when fetching output. 39 | type CommandOutput struct { 40 | InstanceID string 41 | Status string 42 | Output string 43 | OutputUrl string 44 | Error error 45 | } 46 | 47 | // Manager handles the clients interfacing with AWS. 48 | type Manager struct { 49 | ssmClient ssmiface.SSMAPI 50 | s3Client s3iface.S3API 51 | ec2Client ec2iface.EC2API 52 | extendOutput bool 53 | region string 54 | s3Bucket string 55 | s3KeyPrefix string 56 | } 57 | 58 | type Opts struct { 59 | ExtendOutput bool 60 | S3Bucket string 61 | S3KeyPrefix string 62 | } 63 | 64 | // NewManager creates a new Manager from an AWS session and region. 65 | func NewManager(sess *session.Session, region string, opts Opts) *Manager { 66 | awsCfg := &aws.Config{} 67 | if region != "" { 68 | awsCfg.Region = aws.String(region) 69 | } 70 | m := &Manager{ 71 | ssmClient: ssm.New(sess, awsCfg), 72 | s3Client: s3.New(sess, awsCfg), 73 | ec2Client: ec2.New(sess, awsCfg), 74 | region: region, 75 | } 76 | m.extendOutput = opts.ExtendOutput 77 | m.s3Bucket = opts.S3Bucket 78 | m.s3KeyPrefix = opts.S3KeyPrefix 79 | return m 80 | } 81 | 82 | // NewTestManager creates a new manager for testing purposes. 83 | func NewTestManager(ssm ssmiface.SSMAPI, s3 s3iface.S3API, ec2 ec2iface.EC2API) *Manager { 84 | return &Manager{ 85 | ssmClient: ssm, 86 | s3Client: s3, 87 | ec2Client: ec2, 88 | region: "eu-west-1", 89 | } 90 | } 91 | 92 | // ListInstances fetches a list of instances managed by SSM. Paginates until all responses have been collected. 93 | func (m *Manager) ListInstances(limit int64, tagFilters []*TagFilter) ([]*Instance, error) { 94 | var out []*Instance 95 | 96 | input := &ssm.DescribeInstanceInformationInput{ 97 | MaxResults: &limit, 98 | } 99 | 100 | for { 101 | response, err := m.ssmClient.DescribeInstanceInformation(input) 102 | if err != nil { 103 | return nil, errors.Wrap(err, "failed to describe instance information") 104 | } 105 | ssmInstances, ec2Instances, err := m.describeInstances(response.InstanceInformationList, tagFilters) 106 | if err != nil { 107 | return nil, errors.Wrap(err, "failed to retrieve ec2 instance information") 108 | } 109 | 110 | // NOTE: ec2Info will be a shorter list when filtering is applied. 111 | for k := range ec2Instances { 112 | out = append(out, NewInstance(ssmInstances[k], ec2Instances[k])) 113 | } 114 | if response.NextToken == nil { 115 | break 116 | } 117 | input.NextToken = response.NextToken 118 | } 119 | 120 | return out, nil 121 | } 122 | 123 | // ListDocuments fetches a list of documents managed by SSM. Paginates until all responses have been collected. 124 | func (m *Manager) ListDocuments(limit int64, documentFilters []*ssm.DocumentFilter) ([]*DocumentIdentifier, error) { 125 | var out []*DocumentIdentifier 126 | 127 | input := &ssm.ListDocumentsInput{ 128 | MaxResults: &limit, 129 | DocumentFilterList: documentFilters, 130 | } 131 | 132 | for { 133 | response, err := m.ssmClient.ListDocuments(input) 134 | if err != nil { 135 | return nil, errors.Wrap(err, "failed to list document") 136 | } 137 | 138 | for _, document := range response.DocumentIdentifiers { 139 | out = append(out, NewDocumentIdentifier(document)) 140 | } 141 | if response.NextToken == nil { 142 | break 143 | } 144 | input.NextToken = response.NextToken 145 | } 146 | 147 | return out, nil 148 | } 149 | 150 | // DescribeDocument lists information for a specific document managed by SSM. 151 | func (m *Manager) DescribeDocument(name string) (*DocumentDescription, error) { 152 | 153 | input := &ssm.DescribeDocumentInput{ 154 | Name: aws.String(name), 155 | } 156 | 157 | response, err := m.ssmClient.DescribeDocument(input) 158 | if err != nil { 159 | return nil, errors.Wrap(err, "failed to describe document") 160 | } 161 | 162 | document := NewDocumentDescription(response.Document) 163 | 164 | return document, nil 165 | } 166 | 167 | // describeInstances retrieves additional information about SSM managed instances from EC2. 168 | func (m *Manager) describeInstances(instances []*ssm.InstanceInformation, tagFilters []*TagFilter) (map[string]*ssm.InstanceInformation, map[string]*ec2.Instance, error) { 169 | var ids []*string 170 | var filters []*ec2.Filter 171 | 172 | org := make(map[string]*ssm.InstanceInformation) 173 | out := make(map[string]*ec2.Instance) 174 | 175 | for _, instance := range instances { 176 | org[aws.StringValue(instance.InstanceId)] = instance 177 | ids = append(ids, instance.InstanceId) 178 | } 179 | 180 | filters = append(filters, &ec2.Filter{ 181 | Name: aws.String("instance-id"), 182 | Values: ids, 183 | }) 184 | 185 | for _, f := range tagFilters { 186 | filters = append(filters, f.Filter()) 187 | } 188 | 189 | input := &ec2.DescribeInstancesInput{ 190 | Filters: filters, 191 | } 192 | 193 | for { 194 | response, err := m.ec2Client.DescribeInstances(input) 195 | if err != nil { 196 | return nil, nil, err 197 | } 198 | for _, reservation := range response.Reservations { 199 | for _, instance := range reservation.Instances { 200 | id := aws.StringValue(instance.InstanceId) 201 | out[id] = instance 202 | } 203 | } 204 | if response.NextToken == nil { 205 | break 206 | } 207 | input.NextToken = response.NextToken 208 | } 209 | 210 | return org, out, nil 211 | } 212 | 213 | // RunCommand on the given instance ids. 214 | func (m *Manager) RunCommand(instanceIds []string, name string, parameters map[string]string) (string, error) { 215 | 216 | var params map[string][]*string 217 | 218 | if len(parameters) > 0 { 219 | params = make(map[string][]*string) 220 | for k, v := range parameters { 221 | params[k] = aws.StringSlice([]string{v}) 222 | } 223 | } 224 | 225 | input := &ssm.SendCommandInput{ 226 | InstanceIds: aws.StringSlice(instanceIds), 227 | DocumentName: aws.String(name), 228 | Comment: aws.String("Document triggered through ssm-sh."), 229 | Parameters: params, 230 | } 231 | if m.s3Bucket != "" { 232 | input.OutputS3BucketName = aws.String(m.s3Bucket) 233 | } 234 | if m.s3KeyPrefix != "" { 235 | input.OutputS3KeyPrefix = aws.String(m.s3KeyPrefix) 236 | } 237 | res, err := m.ssmClient.SendCommand(input) 238 | if err != nil { 239 | return "", err 240 | } 241 | 242 | return aws.StringValue(res.Command.CommandId), nil 243 | } 244 | 245 | // AbortCommand command on the given instance ids. 246 | func (m *Manager) AbortCommand(instanceIds []string, commandID string) error { 247 | _, err := m.ssmClient.CancelCommand(&ssm.CancelCommandInput{ 248 | CommandId: aws.String(commandID), 249 | InstanceIds: aws.StringSlice(instanceIds), 250 | }) 251 | if err != nil { 252 | return err 253 | } 254 | return nil 255 | } 256 | 257 | // GetCommandOutput fetches the results from a command invocation for all specified instanceIds and 258 | // closes the receiving channel before exiting. 259 | func (m *Manager) GetCommandOutput(ctx context.Context, instanceIds []string, commandID string, out chan<- *CommandOutput) { 260 | defer close(out) 261 | var wg sync.WaitGroup 262 | 263 | for _, id := range instanceIds { 264 | wg.Add(1) 265 | go m.pollInstanceOutput(ctx, id, commandID, out, &wg) 266 | } 267 | 268 | wg.Wait() 269 | return 270 | } 271 | 272 | // Fetch output from a command invocation on an instance. 273 | func (m *Manager) pollInstanceOutput(ctx context.Context, instanceID string, commandID string, c chan<- *CommandOutput, wg *sync.WaitGroup) { 274 | defer wg.Done() 275 | retry := time.NewTicker(time.Millisecond * time.Duration(500)) 276 | 277 | for { 278 | select { 279 | case <-ctx.Done(): 280 | // Main thread is no longer waiting for output 281 | return 282 | case <-retry.C: 283 | // Time to retry at the given frequency 284 | result, err := m.ssmClient.GetCommandInvocation(&ssm.GetCommandInvocationInput{ 285 | CommandId: aws.String(commandID), 286 | InstanceId: aws.String(instanceID), 287 | }) 288 | if out, ok := m.newCommandOutput(result, err); ok { 289 | c <- out 290 | return 291 | } 292 | } 293 | } 294 | } 295 | 296 | func (m *Manager) newCommandOutput(result *ssm.GetCommandInvocationOutput, err error) (*CommandOutput, bool) { 297 | out := &CommandOutput{ 298 | InstanceID: aws.StringValue(result.InstanceId), 299 | Status: aws.StringValue(result.StatusDetails), 300 | Output: "", 301 | Error: err, 302 | } 303 | 304 | if err != nil { 305 | return out, true 306 | } 307 | 308 | switch out.Status { 309 | case "Pending", "InProgress", "Delayed": 310 | return out, false 311 | case "Cancelled": 312 | out.Output = "Command was cancelled" 313 | return out, true 314 | case "Success": 315 | out.Output = aws.StringValue(result.StandardOutputContent) 316 | out.OutputUrl = aws.StringValue(result.StandardOutputUrl) 317 | if m.extendOutput { 318 | return m.extendTruncatedOutput(*out), true 319 | } 320 | return out, true 321 | case "Failed": 322 | out.Output = aws.StringValue(result.StandardErrorContent) 323 | out.OutputUrl = aws.StringValue(result.StandardErrorUrl) 324 | if m.extendOutput { 325 | return m.extendTruncatedOutput(*out), true 326 | } 327 | return out, true 328 | default: 329 | out.Error = fmt.Errorf("Unrecoverable status: %s", out.Status) 330 | return out, true 331 | } 332 | } 333 | 334 | func (m *Manager) extendTruncatedOutput(out CommandOutput) *CommandOutput { 335 | const truncationMarker = "--output truncated--" 336 | if strings.Contains(out.Output, truncationMarker) { 337 | s3out, err := m.readOutput(out.OutputUrl) 338 | if err != nil { 339 | out.Error = errors.Wrap(err, "failed to fetch extended output") 340 | } 341 | out.Output = s3out 342 | return &out 343 | } 344 | return &out 345 | } 346 | 347 | func (m *Manager) readOutput(url string) (string, error) { 348 | regex := regexp.MustCompile(`://s3[\-a-z0-9]*\.amazonaws.com/([^/]+)/(.+)|://([^.]+)\.s3\.amazonaws\.com/(.+)`) 349 | matches := regex.FindStringSubmatch(url) 350 | if len(matches) == 0 { 351 | return "", errors.Errorf("failed due to unexpected s3 url pattern: %s", url) 352 | } 353 | bucket := matches[1] 354 | key := matches[2] 355 | out, err := m.readS3Object(bucket, key) 356 | if err != nil { 357 | return "", errors.Wrapf(err, "failed to fetch s3 object: %s", url) 358 | } 359 | return out, nil 360 | } 361 | 362 | func (m *Manager) readS3Object(bucket, key string) (string, error) { 363 | output, err := m.s3Client.GetObject(&s3.GetObjectInput{ 364 | Bucket: aws.String(bucket), 365 | Key: aws.String(key), 366 | }) 367 | if err != nil { 368 | return "", err 369 | } 370 | 371 | defer output.Body.Close() 372 | b := bytes.NewBuffer(nil) 373 | 374 | if _, err := io.Copy(b, output.Body); err != nil { 375 | return "", err 376 | } 377 | return b.String(), nil 378 | } 379 | -------------------------------------------------------------------------------- /manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager_test 2 | 3 | import ( 4 | "context" 5 | "github.com/aws/aws-sdk-go/aws" 6 | "github.com/aws/aws-sdk-go/service/ec2" 7 | "github.com/aws/aws-sdk-go/service/ssm" 8 | "github.com/itsdalmo/ssm-sh/manager" 9 | "github.com/stretchr/testify/assert" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | var ( 15 | ssmInstances = []*ssm.InstanceInformation{ 16 | { 17 | InstanceId: aws.String("i-00000000000000001"), 18 | PlatformName: aws.String("Amazon Linux"), 19 | PlatformVersion: aws.String("1.0"), 20 | IPAddress: aws.String("10.0.0.1"), 21 | PingStatus: aws.String("Online"), 22 | LastPingDateTime: aws.Time(time.Date(2018, time.January, 27, 13, 32, 0, 0, time.UTC)), 23 | }, 24 | { 25 | InstanceId: aws.String("i-00000000000000002"), 26 | PlatformName: aws.String("Amazon Linux 2"), 27 | PlatformVersion: aws.String("2.0"), 28 | IPAddress: aws.String("10.0.0.100"), 29 | PingStatus: aws.String("Online"), 30 | LastPingDateTime: aws.Time(time.Date(2018, time.January, 30, 13, 32, 0, 0, time.UTC)), 31 | }, 32 | } 33 | 34 | ec2Instances = map[string]*ec2.Instance{ 35 | "i-00000000000000001": { 36 | InstanceId: aws.String("i-00000000000000001"), 37 | ImageId: aws.String("ami-db000001"), 38 | State: &ec2.InstanceState{Name: aws.String("running")}, 39 | Tags: []*ec2.Tag{ 40 | { 41 | Key: aws.String("Name"), 42 | Value: aws.String("instance 1"), 43 | }, 44 | }, 45 | }, 46 | "i-00000000000000002": { 47 | InstanceId: aws.String("i-00000000000000002"), 48 | ImageId: aws.String("ami-db000002"), 49 | State: &ec2.InstanceState{Name: aws.String("running")}, 50 | Tags: []*ec2.Tag{ 51 | { 52 | Key: aws.String("Name"), 53 | Value: aws.String("instance 2"), 54 | }, 55 | }, 56 | }, 57 | } 58 | 59 | ssmDocumentIdentifiers = []*ssm.DocumentIdentifier{ 60 | { 61 | Name: aws.String("AWS-RunShellScript"), 62 | Owner: aws.String("Amazon"), 63 | DocumentVersion: aws.String("1"), 64 | DocumentFormat: aws.String("JSON"), 65 | DocumentType: aws.String("Command"), 66 | SchemaVersion: aws.String("1.2"), 67 | TargetType: aws.String("Linux"), 68 | }, 69 | { 70 | Name: aws.String("Custom"), 71 | Owner: aws.String("test-user"), 72 | DocumentVersion: aws.String("2"), 73 | DocumentFormat: aws.String("YAML"), 74 | DocumentType: aws.String("Command"), 75 | SchemaVersion: aws.String("1.0"), 76 | TargetType: aws.String("Windows"), 77 | }, 78 | } 79 | 80 | ssmDocumentDescription = &ssm.DocumentDescription{ 81 | Name: aws.String("AWS-RunShellScript"), 82 | Description: aws.String("Run a shell script or specify the commands to run."), 83 | Owner: aws.String("Amazon"), 84 | DocumentVersion: aws.String("1"), 85 | DocumentFormat: aws.String("JSON"), 86 | DocumentType: aws.String("Command"), 87 | SchemaVersion: aws.String("1.2"), 88 | TargetType: aws.String("Linux"), 89 | Parameters: []*ssm.DocumentParameter{ 90 | { 91 | Name: aws.String("commands"), 92 | Description: aws.String("Specify a shell script or a command to run"), 93 | DefaultValue: aws.String(""), 94 | Type: aws.String("StringList"), 95 | }, 96 | { 97 | Name: aws.String("executionTimeout"), 98 | Description: aws.String("The time in seconds for a command to complete"), 99 | DefaultValue: aws.String("3600"), 100 | Type: aws.String("String"), 101 | }, 102 | }, 103 | } 104 | 105 | outputInstances = []*manager.Instance{ 106 | { 107 | InstanceID: "i-00000000000000001", 108 | Name: "instance 1", 109 | State: "running", 110 | ImageID: "ami-db000001", 111 | PlatformName: "Amazon Linux", 112 | PlatformVersion: "1.0", 113 | IPAddress: "10.0.0.1", 114 | PingStatus: "Online", 115 | LastPingDateTime: time.Date(2018, time.January, 27, 13, 32, 0, 0, time.UTC), 116 | }, 117 | { 118 | InstanceID: "i-00000000000000002", 119 | Name: "instance 2", 120 | State: "running", 121 | ImageID: "ami-db000002", 122 | PlatformName: "Amazon Linux 2", 123 | PlatformVersion: "2.0", 124 | IPAddress: "10.0.0.100", 125 | PingStatus: "Online", 126 | LastPingDateTime: time.Date(2018, time.January, 30, 13, 32, 0, 0, time.UTC), 127 | }, 128 | } 129 | 130 | outputDocumentIdentifiers = []*manager.DocumentIdentifier{ 131 | { 132 | Name: "AWS-RunShellScript", 133 | Owner: "Amazon", 134 | DocumentVersion: "1", 135 | DocumentFormat: "JSON", 136 | DocumentType: "Command", 137 | SchemaVersion: "1.2", 138 | TargetType: "Linux", 139 | }, 140 | { 141 | Name: "Custom", 142 | Owner: "test-user", 143 | DocumentVersion: "2", 144 | DocumentFormat: "YAML", 145 | DocumentType: "Command", 146 | SchemaVersion: "1.0", 147 | TargetType: "Windows", 148 | }, 149 | } 150 | 151 | outputDocumentDescription = &manager.DocumentDescription{ 152 | Name: "AWS-RunShellScript", 153 | Description: "Run a shell script or specify the commands to run.", 154 | Owner: "Amazon", 155 | DocumentVersion: "1", 156 | DocumentFormat: "JSON", 157 | DocumentType: "Command", 158 | SchemaVersion: "1.2", 159 | TargetType: "Linux", 160 | Parameters: []*manager.DocumentParameter{ 161 | { 162 | Name: "commands", 163 | Description: "Specify a shell script or a command to run", 164 | DefaultValue: "", 165 | Type: "StringList", 166 | }, 167 | { 168 | Name: "executionTimeout", 169 | Description: "The time in seconds for a command to complete", 170 | DefaultValue: "3600", 171 | Type: "String", 172 | }, 173 | }, 174 | } 175 | ) 176 | 177 | func TestList(t *testing.T) { 178 | ssmMock := &manager.MockSSM{ 179 | Error: false, 180 | NextToken: "", 181 | CommandStatus: "Success", 182 | CommandHistory: map[string]*struct { 183 | Command *ssm.Command 184 | Status string 185 | }{}, 186 | Instances: ssmInstances, 187 | } 188 | s3Mock := &manager.MockS3{ 189 | Error: false, 190 | } 191 | ec2Mock := &manager.MockEC2{ 192 | Error: false, 193 | Instances: ec2Instances, 194 | } 195 | 196 | m := manager.NewTestManager(ssmMock, s3Mock, ec2Mock) 197 | 198 | t.Run("Get managed instances works", func(t *testing.T) { 199 | expected := outputInstances 200 | actual, err := m.ListInstances(50, nil) 201 | assert.Nil(t, err) 202 | assert.NotNil(t, actual) 203 | assert.ElementsMatch(t, expected, actual) 204 | }) 205 | 206 | t.Run("Limit number of instances works", func(t *testing.T) { 207 | expected := outputInstances[:1] 208 | actual, err := m.ListInstances(1, nil) 209 | assert.Nil(t, err) 210 | assert.NotNil(t, actual) 211 | assert.ElementsMatch(t, expected, actual) 212 | }) 213 | 214 | t.Run("Pagination works", func(t *testing.T) { 215 | ssmMock.NextToken = "next" 216 | defer func() { 217 | ssmMock.NextToken = "" 218 | }() 219 | 220 | expected := outputInstances 221 | actual, err := m.ListInstances(50, nil) 222 | assert.Nil(t, err) 223 | assert.NotNil(t, actual) 224 | assert.ElementsMatch(t, expected, actual) 225 | }) 226 | 227 | t.Run("TagFilter works", func(t *testing.T) { 228 | expected := outputInstances[:1] 229 | actual, err := m.ListInstances(50, []*manager.TagFilter{ 230 | { 231 | Key: "Name", 232 | Values: []string{ 233 | "1", 234 | }, 235 | }, 236 | }) 237 | assert.Nil(t, err) 238 | assert.NotNil(t, actual) 239 | assert.ElementsMatch(t, expected, actual) 240 | }) 241 | 242 | t.Run("Errors are propagated", func(t *testing.T) { 243 | ssmMock.Error = true 244 | defer func() { 245 | ssmMock.Error = false 246 | }() 247 | 248 | actual, err := m.ListInstances(50, nil) 249 | assert.NotNil(t, err) 250 | assert.EqualError(t, err, "failed to describe instance information: expected") 251 | assert.Nil(t, actual) 252 | }) 253 | } 254 | 255 | func TestListDocumentsCommand(t *testing.T) { 256 | ssmMock := &manager.MockSSM{ 257 | Error: false, 258 | NextToken: "", 259 | CommandStatus: "Success", 260 | CommandHistory: map[string]*struct { 261 | Command *ssm.Command 262 | Status string 263 | }{}, 264 | Documents: ssmDocumentIdentifiers, 265 | } 266 | 267 | m := manager.NewTestManager(ssmMock, nil, nil) 268 | 269 | t.Run("List documents works", func(t *testing.T) { 270 | expected := outputDocumentIdentifiers 271 | actual, err := m.ListDocuments(50, nil) 272 | assert.Nil(t, err) 273 | assert.NotNil(t, actual) 274 | assert.Equal(t, expected, actual) 275 | }) 276 | 277 | t.Run("Limit number of documents works", func(t *testing.T) { 278 | expected := outputDocumentIdentifiers[:1] 279 | actual, err := m.ListDocuments(1, nil) 280 | assert.Nil(t, err) 281 | assert.NotNil(t, actual) 282 | assert.Equal(t, expected, actual) 283 | }) 284 | 285 | t.Run("Pagination works", func(t *testing.T) { 286 | ssmMock.NextToken = "next" 287 | defer func() { 288 | ssmMock.NextToken = "" 289 | }() 290 | 291 | expected := outputDocumentIdentifiers 292 | actual, err := m.ListDocuments(50, nil) 293 | assert.Nil(t, err) 294 | assert.NotNil(t, actual) 295 | assert.Equal(t, expected, actual) 296 | }) 297 | 298 | t.Run("Filter works", func(t *testing.T) { 299 | expected := outputDocumentIdentifiers[:1] 300 | actual, err := m.ListDocuments(50, []*ssm.DocumentFilter{ 301 | { 302 | Key: aws.String("Owner"), 303 | Value: aws.String("Amazon"), 304 | }, 305 | }) 306 | assert.Nil(t, err) 307 | assert.NotNil(t, actual) 308 | assert.Equal(t, expected, actual) 309 | }) 310 | 311 | t.Run("Errors are propagated", func(t *testing.T) { 312 | ssmMock.Error = true 313 | defer func() { 314 | ssmMock.Error = false 315 | }() 316 | 317 | actual, err := m.ListDocuments(50, nil) 318 | assert.NotNil(t, err) 319 | assert.EqualError(t, err, "failed to list document: expected") 320 | assert.Nil(t, actual) 321 | }) 322 | } 323 | 324 | func TestDescribeDocumentsCommand(t *testing.T) { 325 | ssmMock := &manager.MockSSM{ 326 | Error: false, 327 | NextToken: "", 328 | CommandStatus: "Success", 329 | CommandHistory: map[string]*struct { 330 | Command *ssm.Command 331 | Status string 332 | }{}, 333 | DocumentDescription: ssmDocumentDescription, 334 | } 335 | 336 | m := manager.NewTestManager(ssmMock, nil, nil) 337 | 338 | t.Run("Describe documents works", func(t *testing.T) { 339 | expected := outputDocumentDescription 340 | actual, err := m.DescribeDocument("AWS-RunShellScript") 341 | assert.Nil(t, err) 342 | assert.NotNil(t, actual) 343 | assert.Equal(t, expected, actual) 344 | }) 345 | 346 | t.Run("Incorrect name failes", func(t *testing.T) { 347 | actual, err := m.DescribeDocument("Does-not-exist") 348 | assert.NotNil(t, err) 349 | assert.EqualError(t, err, "failed to describe document: expected") 350 | assert.Nil(t, actual) 351 | }) 352 | } 353 | 354 | /* 355 | func TestDescribeDocumentCommand(t *testing.T) { 356 | }*/ 357 | 358 | func TestRunCommand(t *testing.T) { 359 | ssmMock := &manager.MockSSM{ 360 | Error: false, 361 | NextToken: "", 362 | CommandStatus: "Success", 363 | CommandHistory: map[string]*struct { 364 | Command *ssm.Command 365 | Status string 366 | }{}, 367 | Instances: ssmInstances, 368 | } 369 | s3Mock := &manager.MockS3{ 370 | Error: false, 371 | } 372 | ec2Mock := &manager.MockEC2{ 373 | Error: false, 374 | Instances: ec2Instances, 375 | } 376 | 377 | m := manager.NewTestManager(ssmMock, s3Mock, ec2Mock) 378 | 379 | var targets []string 380 | for _, instance := range ssmMock.Instances { 381 | targets = append(targets, aws.StringValue(instance.InstanceId)) 382 | } 383 | 384 | t.Run("Run works", func(t *testing.T) { 385 | expected := "command-1" 386 | actual, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 387 | assert.Nil(t, err) 388 | assert.NotNil(t, actual) 389 | assert.Equal(t, expected, actual) 390 | }) 391 | 392 | t.Run("Errors are propagated", func(t *testing.T) { 393 | ssmMock.Error = true 394 | defer func() { 395 | ssmMock.Error = false 396 | }() 397 | 398 | actual, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 399 | assert.NotNil(t, err) 400 | assert.EqualError(t, err, "expected") 401 | assert.Equal(t, "", actual) 402 | }) 403 | } 404 | 405 | func TestAbortCommand(t *testing.T) { 406 | ssmMock := &manager.MockSSM{ 407 | Error: false, 408 | NextToken: "", 409 | CommandStatus: "Success", 410 | CommandHistory: map[string]*struct { 411 | Command *ssm.Command 412 | Status string 413 | }{}, 414 | Instances: ssmInstances, 415 | } 416 | s3Mock := &manager.MockS3{ 417 | Error: false, 418 | } 419 | ec2Mock := &manager.MockEC2{ 420 | Error: false, 421 | Instances: ec2Instances, 422 | } 423 | 424 | m := manager.NewTestManager(ssmMock, s3Mock, ec2Mock) 425 | 426 | var targets []string 427 | for _, instance := range ssmMock.Instances { 428 | targets = append(targets, aws.StringValue(instance.InstanceId)) 429 | } 430 | 431 | t.Run("Abort works", func(t *testing.T) { 432 | id, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 433 | assert.Nil(t, err) 434 | err = m.AbortCommand(targets, id) 435 | assert.Nil(t, err) 436 | }) 437 | 438 | t.Run("Invalid command id errors are propagated", func(t *testing.T) { 439 | _, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 440 | assert.Nil(t, err) 441 | err = m.AbortCommand(targets, "invalid") 442 | assert.NotNil(t, err) 443 | assert.EqualError(t, err, "invalid commandId") 444 | }) 445 | 446 | t.Run("Errors are propagated", func(t *testing.T) { 447 | ssmMock.Error = true 448 | defer func() { 449 | ssmMock.Error = false 450 | }() 451 | 452 | err := m.AbortCommand(targets, "na") 453 | assert.NotNil(t, err) 454 | assert.EqualError(t, err, "expected") 455 | }) 456 | } 457 | 458 | func TestOutput(t *testing.T) { 459 | ssmMock := &manager.MockSSM{ 460 | Error: false, 461 | NextToken: "", 462 | CommandStatus: "Success", 463 | CommandHistory: map[string]*struct { 464 | Command *ssm.Command 465 | Status string 466 | }{}, 467 | Instances: ssmInstances, 468 | } 469 | s3Mock := &manager.MockS3{ 470 | Error: false, 471 | } 472 | ec2Mock := &manager.MockEC2{ 473 | Error: false, 474 | Instances: ec2Instances, 475 | } 476 | 477 | m := manager.NewTestManager(ssmMock, s3Mock, ec2Mock) 478 | 479 | var targets []string 480 | for _, instance := range ssmMock.Instances { 481 | targets = append(targets, aws.StringValue(instance.InstanceId)) 482 | } 483 | 484 | t.Run("Get output works with standard out", func(t *testing.T) { 485 | id, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 486 | assert.Nil(t, err) 487 | 488 | ctx := context.Background() 489 | out := make(chan *manager.CommandOutput) 490 | go m.GetCommandOutput(ctx, targets, id, out) 491 | 492 | var actual []string 493 | 494 | for o := range out { 495 | assert.Nil(t, o.Error) 496 | assert.Equal(t, "Success", o.Status) 497 | assert.Equal(t, "example standard output", o.Output) 498 | actual = append(actual, o.InstanceID) 499 | } 500 | assert.Equal(t, len(targets), len(actual)) 501 | }) 502 | 503 | t.Run("Get output works with standard error", func(t *testing.T) { 504 | ssmMock.CommandStatus = "Failed" 505 | defer func() { 506 | ssmMock.CommandStatus = "Success" 507 | }() 508 | 509 | id, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 510 | assert.Nil(t, err) 511 | 512 | ctx := context.Background() 513 | out := make(chan *manager.CommandOutput) 514 | go m.GetCommandOutput(ctx, targets, id, out) 515 | 516 | for o := range out { 517 | assert.Nil(t, o.Error) 518 | assert.Equal(t, "Failed", o.Status) 519 | assert.Equal(t, "example standard error", o.Output) 520 | } 521 | }) 522 | 523 | t.Run("Get output is aborted if the context is done", func(t *testing.T) { 524 | id, err := m.RunCommand(targets, "AWS-RunShellScript", map[string]string{"commands": "ls -la"}) 525 | assert.Nil(t, err) 526 | 527 | ctx, cancel := context.WithCancel(context.Background()) 528 | out := make(chan *manager.CommandOutput) 529 | 530 | cancel() 531 | go m.GetCommandOutput(ctx, targets, id, out) 532 | 533 | var actual []string 534 | 535 | for o := range out { 536 | actual = append(actual, o.InstanceID) 537 | } 538 | assert.Equal(t, 0, len(actual)) 539 | }) 540 | } 541 | -------------------------------------------------------------------------------- /manager/testing.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/ec2" 8 | "github.com/aws/aws-sdk-go/service/ec2/ec2iface" 9 | "github.com/aws/aws-sdk-go/service/s3" 10 | "github.com/aws/aws-sdk-go/service/s3/s3iface" 11 | "github.com/aws/aws-sdk-go/service/ssm" 12 | "github.com/aws/aws-sdk-go/service/ssm/ssmiface" 13 | "io/ioutil" 14 | "strings" 15 | "sync" 16 | ) 17 | 18 | type MockEC2 struct { 19 | ec2iface.EC2API 20 | Instances map[string]*ec2.Instance 21 | Error bool 22 | } 23 | 24 | func (mock *MockEC2) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) { 25 | if mock.Error { 26 | return nil, errors.New("expected") 27 | } 28 | 29 | var out []*ec2.Instance 30 | var tmp []*ec2.Instance 31 | var ids []string 32 | var nameFilters []string 33 | 34 | for _, filter := range input.Filters { 35 | key := aws.StringValue(filter.Name) 36 | if key == "instance-id" { 37 | ids = aws.StringValueSlice(filter.Values) 38 | } else if key == "tag:Name" { 39 | nameFilters = aws.StringValueSlice(filter.Values) 40 | } 41 | } 42 | 43 | // Filter instance ids if a list was provided. If not, we 44 | // provide the entire list of instances. 45 | if ids != nil { 46 | for _, id := range ids { 47 | instance, ok := mock.Instances[id] 48 | if !ok { 49 | return nil, errors.New("instance id does not exist") 50 | } 51 | tmp = append(tmp, instance) 52 | } 53 | 54 | } else { 55 | for _, instance := range mock.Instances { 56 | tmp = append(tmp, instance) 57 | } 58 | } 59 | 60 | // If a tag filter was supplied (only Name is supported for testing), 61 | // filter instances which don't match. 62 | if nameFilters != nil && len(nameFilters) > 0 { 63 | for _, instance := range tmp { 64 | for _, tag := range instance.Tags { 65 | // Look for Name tag. 66 | if aws.StringValue(tag.Key) != "Name" { 67 | continue 68 | } 69 | // Once it is found, check whether it contains 70 | // any of the name filters (simple contains). 71 | name := aws.StringValue(tag.Value) 72 | for _, filter := range nameFilters { 73 | if strings.Contains(name, filter) { 74 | out = append(out, instance) 75 | } 76 | } 77 | } 78 | } 79 | 80 | } else { 81 | out = tmp 82 | } 83 | 84 | // NOTE: It should not matter if we have multiple reservations 85 | // and/or multiple instances per reservation. 86 | return &ec2.DescribeInstancesOutput{ 87 | Reservations: []*ec2.Reservation{ 88 | { 89 | Instances: out, 90 | }, 91 | }, 92 | NextToken: nil, 93 | }, nil 94 | } 95 | 96 | type MockSSM struct { 97 | ssmiface.SSMAPI 98 | Instances []*ssm.InstanceInformation 99 | Documents []*ssm.DocumentIdentifier 100 | DocumentDescription *ssm.DocumentDescription 101 | NextToken string 102 | CommandStatus string 103 | CommandHistory map[string]*struct { 104 | Command *ssm.Command 105 | Status string 106 | } 107 | Error bool 108 | async sync.Mutex 109 | } 110 | 111 | func (mock *MockSSM) DescribeInstanceInformation(input *ssm.DescribeInstanceInformationInput) (*ssm.DescribeInstanceInformationOutput, error) { 112 | if mock.Error { 113 | return nil, errors.New("expected") 114 | } 115 | 116 | output := mock.Instances 117 | if input.MaxResults != nil { 118 | if i := int(*input.MaxResults); i < len(mock.Instances) { 119 | output = mock.Instances[:i] 120 | } 121 | } 122 | 123 | if mock.NextToken != "" { 124 | switch { 125 | case input.NextToken == nil: 126 | // Give an empty list on first response 127 | return &ssm.DescribeInstanceInformationOutput{ 128 | InstanceInformationList: []*ssm.InstanceInformation{}, 129 | NextToken: aws.String(mock.NextToken), 130 | }, nil 131 | case *input.NextToken == mock.NextToken: 132 | return &ssm.DescribeInstanceInformationOutput{ 133 | InstanceInformationList: output, 134 | NextToken: nil, 135 | }, nil 136 | default: 137 | return nil, errors.New("Wrong token") 138 | } 139 | 140 | } 141 | return &ssm.DescribeInstanceInformationOutput{ 142 | InstanceInformationList: output, 143 | NextToken: nil, 144 | }, nil 145 | } 146 | 147 | func (mock *MockSSM) ListDocuments(input *ssm.ListDocumentsInput) (*ssm.ListDocumentsOutput, error) { 148 | if mock.Error { 149 | return nil, errors.New("expected") 150 | } 151 | 152 | var output []*ssm.DocumentIdentifier 153 | 154 | // filter output based on DocumentFilterList 155 | // only "Owner" is supported for testing 156 | if len(input.DocumentFilterList) > 0 { 157 | for _, filter := range input.DocumentFilterList { 158 | if aws.StringValue(filter.Key) == "Owner" { 159 | for _, document := range mock.Documents { 160 | if aws.StringValue(document.Owner) == aws.StringValue(filter.Value) { 161 | output = append(output, document) 162 | } 163 | } 164 | } 165 | } 166 | } else { 167 | output = mock.Documents 168 | } 169 | 170 | if input.MaxResults != nil { 171 | if i := int(*input.MaxResults); i < len(mock.Documents) { 172 | output = mock.Documents[:i] 173 | } 174 | } 175 | 176 | if mock.NextToken != "" { 177 | switch { 178 | case input.NextToken == nil: 179 | // Give an empty list on first response 180 | return &ssm.ListDocumentsOutput{ 181 | DocumentIdentifiers: []*ssm.DocumentIdentifier{}, 182 | NextToken: aws.String(mock.NextToken), 183 | }, nil 184 | case *input.NextToken == mock.NextToken: 185 | return &ssm.ListDocumentsOutput{ 186 | DocumentIdentifiers: output, 187 | NextToken: nil, 188 | }, nil 189 | default: 190 | return nil, errors.New("Wrong token") 191 | } 192 | 193 | } 194 | return &ssm.ListDocumentsOutput{ 195 | DocumentIdentifiers: output, 196 | NextToken: nil, 197 | }, nil 198 | } 199 | 200 | func (mock *MockSSM) DescribeDocument(input *ssm.DescribeDocumentInput) (*ssm.DescribeDocumentOutput, error) { 201 | if mock.Error { 202 | return nil, errors.New("expected") 203 | } 204 | 205 | if aws.StringValue(input.Name) != aws.StringValue(mock.DocumentDescription.Name) { 206 | return nil, errors.New("expected") 207 | } 208 | 209 | return &ssm.DescribeDocumentOutput{ 210 | Document: mock.DocumentDescription, 211 | }, nil 212 | } 213 | 214 | func (mock *MockSSM) SendCommand(input *ssm.SendCommandInput) (*ssm.SendCommandOutput, error) { 215 | if mock.Error { 216 | return nil, errors.New("expected") 217 | } 218 | 219 | // Validate required input and intended behavior. 220 | if input.DocumentName == nil { 221 | return nil, errors.New("Missing comment") 222 | } 223 | 224 | if input.InstanceIds == nil || len(input.InstanceIds) == 0 { 225 | return nil, errors.New("Missing InstanceIds") 226 | } 227 | 228 | if input.Parameters == nil { 229 | return nil, errors.New("Missing parameters") 230 | } 231 | 232 | _, ok := input.Parameters["commands"] 233 | if !ok { 234 | return nil, errors.New("Missing commands in Parameters") 235 | } 236 | 237 | mock.async.Lock() 238 | defer mock.async.Unlock() 239 | 240 | id := fmt.Sprintf("command-%d", len(mock.CommandHistory)+1) 241 | command := &ssm.Command{ 242 | CommandId: aws.String(id), 243 | Comment: input.Comment, 244 | DocumentName: input.DocumentName, 245 | InstanceIds: input.InstanceIds, 246 | OutputS3BucketName: input.OutputS3BucketName, 247 | OutputS3KeyPrefix: input.OutputS3KeyPrefix, 248 | } 249 | mock.CommandHistory[id] = &struct { 250 | Command *ssm.Command 251 | Status string 252 | }{ 253 | Command: command, 254 | Status: mock.CommandStatus, 255 | } 256 | return &ssm.SendCommandOutput{Command: command}, nil 257 | } 258 | 259 | func (mock *MockSSM) CancelCommand(input *ssm.CancelCommandInput) (*ssm.CancelCommandOutput, error) { 260 | if mock.Error { 261 | return nil, errors.New("expected") 262 | } 263 | 264 | if input.CommandId == nil { 265 | return nil, errors.New("Missing CommandId") 266 | } 267 | 268 | if input.InstanceIds == nil || len(input.InstanceIds) == 0 { 269 | return nil, errors.New("Missing InstanceIds") 270 | } 271 | 272 | mock.async.Lock() 273 | defer mock.async.Unlock() 274 | 275 | id := aws.StringValue(input.CommandId) 276 | cmd, ok := mock.CommandHistory[id] 277 | if !ok { 278 | return nil, errors.New("invalid commandId") 279 | } 280 | cmd.Status = "Cancelled" 281 | 282 | return &ssm.CancelCommandOutput{}, nil 283 | } 284 | 285 | func (mock *MockSSM) GetCommandInvocation(input *ssm.GetCommandInvocationInput) (*ssm.GetCommandInvocationOutput, error) { 286 | if mock.Error { 287 | return nil, errors.New("expected") 288 | } 289 | 290 | if input.CommandId == nil { 291 | return nil, errors.New("Missing CommandId") 292 | } 293 | 294 | if input.InstanceId == nil { 295 | return nil, errors.New("Missing InstanceId") 296 | } 297 | 298 | mock.async.Lock() 299 | defer mock.async.Unlock() 300 | 301 | id := aws.StringValue(input.CommandId) 302 | cmd, ok := mock.CommandHistory[id] 303 | if !ok { 304 | return nil, errors.New("invalid commandId") 305 | } 306 | 307 | return &ssm.GetCommandInvocationOutput{ 308 | InstanceId: input.InstanceId, 309 | StatusDetails: aws.String(cmd.Status), 310 | StandardOutputContent: aws.String("example standard output"), 311 | StandardErrorContent: aws.String("example standard error"), 312 | }, nil 313 | } 314 | 315 | type MockS3 struct { 316 | s3iface.S3API 317 | Error bool 318 | } 319 | 320 | func (mock *MockS3) GetObject(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { 321 | if mock.Error { 322 | return nil, errors.New("expected") 323 | } 324 | if input.Bucket == nil { 325 | return nil, errors.New("Missing Bucket") 326 | } 327 | if input.Key == nil { 328 | return nil, errors.New("Missing Key") 329 | } 330 | 331 | return &s3.GetObjectOutput{ 332 | Body: ioutil.NopCloser(strings.NewReader("example s3 output")), 333 | }, nil 334 | } 335 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | ## SSM deployment 2 | 3 | Quick way of bootstrapping one or more instances managed by SSM, 4 | using terraform, in order to test `ssm-sh`. It takes care of 5 | the following: 6 | 7 | - Creating an autoscaling group running Amazon linux 2 (includes SSM agent). 8 | - An instance profile with the correct privileges for the SSM agent. 9 | - A log group where each instance will send their SSM agent logs. 10 | 11 | ### Usage 12 | 13 | Have terraform installed, configure [main.tf](./main.tf) and run the following: 14 | 15 | ```bash 16 | terraform init 17 | terraform apply 18 | ``` 19 | 20 | If everything deploys successfully, you should see your instances listed when 21 | running: `ssm-sh list`. 22 | 23 | Terraform state will be stored locally unless you add a remote backend to `main.tf`, 24 | and when you are done testing you can tear everything down with `terraform destroy`. 25 | -------------------------------------------------------------------------------- /terraform/example.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = "0.11.8" 3 | } 4 | 5 | provider "aws" { 6 | version = "1.33.0" 7 | region = "eu-west-1" 8 | } 9 | 10 | # Use the default VPC and subnets 11 | data "aws_vpc" "main" { 12 | default = true 13 | } 14 | 15 | data "aws_subnet_ids" "main" { 16 | vpc_id = "${data.aws_vpc.main.id}" 17 | } 18 | 19 | # Use the latest Amazon Linux 2 AMI 20 | data "aws_ami" "linux2" { 21 | owners = ["amazon"] 22 | most_recent = true 23 | 24 | filter { 25 | name = "virtualization-type" 26 | values = ["hvm"] 27 | } 28 | 29 | filter { 30 | name = "architecture" 31 | values = ["x86_64"] 32 | } 33 | 34 | filter { 35 | name = "root-device-type" 36 | values = ["ebs"] 37 | } 38 | 39 | filter { 40 | name = "name" 41 | values = ["amzn2-ami*gp2"] 42 | } 43 | } 44 | 45 | module "ssm-example" { 46 | source = "modules/ssm-example" 47 | 48 | name_prefix = "ssm-sh-example" 49 | vpc_id = "${data.aws_vpc.main.id}" 50 | subnet_ids = ["${data.aws_subnet_ids.main.ids}"] 51 | 52 | instance_ami = "${data.aws_ami.linux2.id}" 53 | instance_count = "1" 54 | instance_type = "t3.micro" 55 | 56 | tags = { 57 | environment = "dev" 58 | terraform = "True" 59 | } 60 | } 61 | 62 | output "output_bucket" { 63 | value = "${module.ssm-example.output_bucket}" 64 | } 65 | 66 | output "output_log_group" { 67 | value = "${module.ssm-example.output_log_group}" 68 | } 69 | -------------------------------------------------------------------------------- /terraform/modules/ssm-example/cloud-config.yml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | package_update: true 3 | packages: 4 | - awslogs 5 | - aws-cfn-bootstrap 6 | write_files: 7 | - path: "/etc/awslogs/awscli.template" 8 | permissions: "0644" 9 | owner: "root" 10 | content: | 11 | [plugins] 12 | cwlogs = cwlogs 13 | [default] 14 | region = ${region} 15 | - path: "/etc/awslogs/awslogs.template" 16 | permissions: "0644" 17 | owner: "root" 18 | content: | 19 | [general] 20 | state_file = /var/lib/awslogs/agent-state 21 | 22 | [/var/log/amazon/ssm/amazon-ssm-agent.log] 23 | file = /var/log/amazon/ssm/amazon-ssm-agent.log 24 | log_group_name = ${log_group_name} 25 | log_stream_name = {instance_id} 26 | - path: "/usr/local/scripts/cloudformation-signal.sh" 27 | permissions: "0744" 28 | owner: "root" 29 | content: | 30 | #! /bin/bash 31 | 32 | set -euo pipefail 33 | 34 | function await_process() { 35 | echo -n "Waiting for $1..." 36 | while ! pgrep -f "$1" > /dev/null; do 37 | sleep 1 38 | done 39 | echo "Done!" 40 | } 41 | await_process "/usr/bin/amazon-ssm-agent" 42 | runcmd: 43 | - | 44 | cp /etc/awslogs/awscli.template /etc/awslogs/awscli.conf 45 | cp /etc/awslogs/awslogs.template /etc/awslogs/awslogs.conf 46 | - | 47 | systemctl enable awslogsd.service --now 48 | - | 49 | /usr/local/scripts/cloudformation-signal.sh 50 | /opt/aws/bin/cfn-signal -e $? --stack ${stack_name} --resource AutoScalingGroup --region ${region} 51 | -------------------------------------------------------------------------------- /terraform/modules/ssm-example/main.tf: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------- 2 | # Resources 3 | # ------------------------------------------------------------------------------- 4 | data "aws_region" "current" {} 5 | 6 | data "aws_caller_identity" "current" {} 7 | 8 | resource "aws_cloudwatch_log_group" "output" { 9 | name = "${var.name_prefix}-output-${data.aws_caller_identity.current.account_id}" 10 | } 11 | 12 | resource "aws_s3_bucket" "output" { 13 | bucket = "${var.name_prefix}-output-${data.aws_caller_identity.current.account_id}" 14 | acl = "private" 15 | 16 | tags = "${merge(var.tags, map("Name", "${var.name_prefix}-output-${data.aws_caller_identity.current.account_id}"))}" 17 | } 18 | 19 | resource "aws_cloudwatch_log_group" "main" { 20 | name = "${var.name_prefix}-ssm-agent" 21 | } 22 | 23 | data "template_file" "main" { 24 | template = "${file("${path.module}/cloud-config.yml")}" 25 | 26 | vars { 27 | region = "${data.aws_region.current.name}" 28 | stack_name = "${var.name_prefix}-asg" 29 | log_group_name = "${aws_cloudwatch_log_group.main.name}" 30 | } 31 | } 32 | 33 | module "asg" { 34 | source = "telia-oss/asg/aws" 35 | version = "0.2.0" 36 | 37 | name_prefix = "${var.name_prefix}" 38 | user_data = "${data.template_file.main.rendered}" 39 | vpc_id = "${var.vpc_id}" 40 | subnet_ids = "${var.subnet_ids}" 41 | await_signal = "true" 42 | pause_time = "PT5M" 43 | health_check_type = "EC2" 44 | instance_policy = "${data.aws_iam_policy_document.permissions.json}" 45 | min_size = "${var.instance_count}" 46 | instance_type = "${var.instance_type}" 47 | instance_ami = "${var.instance_ami}" 48 | instance_key = "" 49 | tags = "${var.tags}" 50 | } 51 | 52 | data "aws_iam_policy_document" "permissions" { 53 | statement { 54 | effect = "Allow" 55 | 56 | actions = [ 57 | "ssm:DescribeAssociation", 58 | "ssm:GetDeployablePatchSnapshotForInstance", 59 | "ssm:GetDocument", 60 | "ssm:GetManifest", 61 | "ssm:GetParameters", 62 | "ssm:ListAssociations", 63 | "ssm:ListInstanceAssociations", 64 | "ssm:PutInventory", 65 | "ssm:PutComplianceItems", 66 | "ssm:PutConfigurePackageResult", 67 | "ssm:UpdateAssociationStatus", 68 | "ssm:UpdateInstanceAssociationStatus", 69 | "ssm:UpdateInstanceInformation", 70 | ] 71 | 72 | resources = ["*"] 73 | } 74 | 75 | statement { 76 | effect = "Allow" 77 | 78 | actions = [ 79 | "ec2messages:AcknowledgeMessage", 80 | "ec2messages:DeleteMessage", 81 | "ec2messages:FailMessage", 82 | "ec2messages:GetEndpoint", 83 | "ec2messages:GetMessages", 84 | "ec2messages:SendReply", 85 | ] 86 | 87 | resources = ["*"] 88 | } 89 | 90 | statement { 91 | effect = "Allow" 92 | 93 | actions = [ 94 | "cloudwatch:PutMetricData", 95 | ] 96 | 97 | resources = ["*"] 98 | } 99 | 100 | statement { 101 | effect = "Allow" 102 | 103 | actions = [ 104 | "ec2:DescribeInstanceStatus", 105 | ] 106 | 107 | resources = ["*"] 108 | } 109 | 110 | statement { 111 | effect = "Allow" 112 | 113 | resources = [ 114 | "${aws_cloudwatch_log_group.main.arn}", 115 | "${aws_cloudwatch_log_group.output.arn}", 116 | ] 117 | 118 | actions = [ 119 | "logs:CreateLogStream", 120 | "logs:CreateLogGroup", 121 | "logs:PutLogEvents", 122 | ] 123 | } 124 | 125 | statement { 126 | effect = "Allow" 127 | 128 | actions = [ 129 | "s3:PutObject", 130 | "s3:GetObject", 131 | "s3:AbortMultipartUpload", 132 | "s3:ListMultipartUploadParts", 133 | "s3:ListBucket", 134 | "s3:ListBucketMultipartUploads", 135 | ] 136 | 137 | resources = [ 138 | "${aws_s3_bucket.output.arn}/*", 139 | "${aws_s3_bucket.output.arn}", 140 | ] 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /terraform/modules/ssm-example/outputs.tf: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------- 2 | # Output 3 | # ------------------------------------------------------------------------------- 4 | output "output_bucket" { 5 | value = "${aws_s3_bucket.output.id}" 6 | } 7 | 8 | output "output_log_group" { 9 | value = "${aws_cloudwatch_log_group.output.id}" 10 | } 11 | -------------------------------------------------------------------------------- /terraform/modules/ssm-example/variables.tf: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # Variables 3 | # ------------------------------------------------------------------------------ 4 | variable "name_prefix" { 5 | description = "Prefix used for resource names." 6 | } 7 | 8 | variable "vpc_id" { 9 | description = "ID of the VPC for the subnets." 10 | } 11 | 12 | variable "subnet_ids" { 13 | description = "IDs of subnets where the instances will be provisioned." 14 | type = "list" 15 | } 16 | 17 | variable "instance_count" { 18 | description = "Desired (and minimum) number of instances." 19 | default = "1" 20 | } 21 | 22 | variable "instance_ami" { 23 | description = "ID of an Amazon Linux 2 AMI. (Comes with SSM agent installed)" 24 | default = "ami-db51c2a2" 25 | } 26 | 27 | variable "instance_type" { 28 | description = "Type of instance to provision." 29 | default = "t2.micro" 30 | } 31 | 32 | variable "tags" { 33 | description = "A map of tags (key-value pairs) passed to resources." 34 | type = "map" 35 | default = {} 36 | } 37 | --------------------------------------------------------------------------------