Skip to content

Commit

Permalink
Code refactor (#2)
Browse files Browse the repository at this point in the history
* refactored bash command

* added max exec time

* fix bash script support

* split bash and ansbile

* implementing ansible support

* added full ansible support

* updated readme
  • Loading branch information
ZeljkoBenovic authored Nov 18, 2023
1 parent f21da61 commit 211b556
Show file tree
Hide file tree
Showing 28 changed files with 711 additions and 955 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:

- uses: actions/setup-go@v3
with:
go-version: 1.17.x
go-version: 1.21.x
cache: true

- uses: goreleaser/goreleaser-action@v2
Expand Down
3 changes: 0 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
builds:
- env:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: lint
lint:
golangci-lint run -E whitespace -E wsl -E wastedassign -E unconvert -E tparallel -E thelper -E stylecheck -E prealloc \
golangci-lint run --fix -E whitespace -E wsl -E wastedassign -E unconvert -E tparallel -E thelper -E stylecheck -E prealloc \
-E predeclared -E nlreturn -E misspell -E makezero -E lll -E importas -E gosec -E gofmt -E goconst \
-E forcetypeassert -E dogsled -E dupl -E errname -E errorlint -E nolintlint --timeout 2m
72 changes: 40 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,72 @@
# AWS Commander

A tool used for running bash scripts on AWS EC2 instances, leveraging AWS Systems Manager > Run Command feature.
User can load a bash script or define a single command, that will execute on all instances with defined instance ID.
A tool used for easier automation of the AWS EC2 instances, leveraging AWS Systems Manager - Run Command feature.
Supported scripts:
* One-liner bash command
* Bash script loaded from a local filesystem
* Ansible playbook loaded from a local filesystem

The command/script/playbook will run across all EC2 instances simultaneously.
EC2 instances, for now, can be selected only by their IDs. Support for selection by tags will be
added in future versions.


## Prerequisites

* The **AmazonSSMManagedInstanceCore** must be placed on all instances that need to be managed via this tool.
* AWS API credentials defined in *aws credentials* file or as environment variables
* The **AmazonSSMManagedInstanceCore** IAM role, attached on all EC2 instances.
* Authenticated AWS CLI session

## Usage

### AWS credentials
AWS credentials can be pulled from environment variables or from aws credentials file.
To define a which profile from credentials file should be used, set `aws-profile` flag. By default, it is set to `default`.
Environment variables with credentials that can be set:
* `AWS_ACCESS_KEY_ID` - the aws access key id
* `AWS_SECRET_ACCESS_KEY` - the access key secret
* `AWS_SESSION_TOKEN` - the session token (optional)

AWS access must be authenticated via `aws cli`.

### General Parameters
* `aws-profile` - AWS profile as defined in *aws credentials* file. Default: `default`
* `aws-zone` - AWS zone in which EC2 instances reside. Default: `eu-central-1`
* `instances` - instance IDs, separated by comma (,). This is a mandatory flag.
* `log-level` - the level of logging output (info, debug, error). Default: `info`
* `output` - a file name to write the output result of a command/script. Default: `console output`
* `mode` - switch between modes - Bash script or Ansible playbook. Default: `bash`
* `log-level` - the level of logging output (`info`, `debug`, `error`). Default: `info`
* `mode` - commands running mode (`bash`, `ansible`) Default: `bash`
* `profile` - AWS profile as defined in *aws credentials* file.
* `region` - AWS region in which EC2 instances reside.
* `ids` - instance IDs, separated by comma (`,`). This is a mandatory flag.
* `max-wait` - maximum wait time in seconds to run the command Default: `30`
* `max-exec` - maximum wait time in seconds to get command result Default: `300`

### Running Bash scripts
* `cmd` - one-liner bash command that will be executed on EC2 instances.
* `script` - the location of bash script file that will run on EC2 instances.
* `mode` - for running bash scripts `mode` can be omitted as the default value is `bash`

If both `cmd` and `script` flags are defined, `script` will take precedence, and `cmd` will be disregarded.
* `mode` - for running Bash script or oneliner `mode` can be omitted or set to `bash`

#### Example

```bash
# AWS authentication
aws sso login --profile test-account

# oneliner
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -cmd "cd /tmp && ls -lah" -aws-profile test-account

# or bash script
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -script ./script.sh -aws-profile test-account
```

### Running Ansible Playbook
* `playbook` - the location of Ansible playbook that will be executed on EC2 instances.
* `ansible-url` - the URL locaction of the Ansible playbook
* `extra-vars` - comma delimited, key value pairs of Ansible variables
* `dryrun` - when set to true, Ansible playbook will run and the output will be shown, but
no data will be changed.
no data will be changed. Default: `false`
* `mode` - for running Ansible playbook `mode` must be set to `ansible`

#### Ansible prerequisites
Every EC2 instance, that should run Ansible playbook, must have Ansible already installed.
If Ansible is not installed, the deployment will fail.
You can use `bash` mode to simply install Ansible from your OS package manager before running the playbook.

#### Example
```bash
## if Ansible is not installed on host - install Ansible
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -cmd "sudo apt install -y ansible" -aws-profile test-account -aws-zone us-west-2
## run playbook
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -mode ansible -playbook scripts/nodes-restart.yaml -aws-profile test-account -aws-zone us-west-2
# AWS authentication
aws sso login

# run local playbook
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -mode ansible -playbook scripts/init.yaml -extra-vars foo=bar,faz=baz

# or from url
aws-commander -instances i-0bf9c273c67f684a0,i-011c9b3e3607a63b5,i-0e53e37f7b34517f5,i-0f02ca10faf8f349e -mode ansible -ansible-url https://example.com/init.yaml -extra-vars foo=bar,faz=baz
```

#### Missing features
Currently, running the Ansible playbook from a remote location via URL / S3 is not supported.
It will be supported in the future release.
* Select EC2 instances using instance tags
31 changes: 31 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app

import (
"os"

"github.com/Trapesys/aws-commander/aws"
"github.com/Trapesys/aws-commander/conf"
"github.com/Trapesys/aws-commander/logger"
"go.uber.org/fx"
)

func Run() {
fx.New(
fx.Provide(
conf.New,
logger.New,
aws.New,
),
fx.Invoke(mainApp),
fx.NopLogger,
).Run()
}

func mainApp(log logger.Logger, awss aws.Aws) {
if err := awss.Run(); err != nil {
log.Error("Run command error", "err", err)
os.Exit(1)
}

os.Exit(0)
}
81 changes: 81 additions & 0 deletions aws/aws.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package aws

import (
"github.com/Trapesys/aws-commander/aws/ssm"
"github.com/Trapesys/aws-commander/conf"
"github.com/Trapesys/aws-commander/logger"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/pkg/errors"
)

type mode string

const (
bash mode = "bash"
ansible mode = "ansible"
)

type modeHandler func() error

type modesFactory map[mode]modeHandler

var (
ErrModeNotSupported = errors.New("selected mode not supported")
)

type SSM interface {
RunBash() error
RunAnsible() error
}

type Aws struct {
conf conf.Config
ssm SSM
modes modesFactory
}

func New(conf conf.Config, log logger.Logger) Aws {
sess, err := provideSesson(conf)
if err != nil {
log.Fatalln("Could not create AWS session", "err", err.Error())
}

localssm := ssm.New(log, conf, sess)

return Aws{
conf: conf,
ssm: localssm,
modes: modesFactory{
bash: localssm.RunBash,
ansible: localssm.RunAnsible,
},
}
}

func (a *Aws) Run() error {
modeHn, ok := a.modes[mode(a.conf.Mode)]
if !ok {
return ErrModeNotSupported
}

return modeHn()
}

func provideSesson(conf conf.Config) (*session.Session, error) {
sessOpt := session.Options{}
sessConf := aws.Config{}

if conf.AWSRegion != "" {
sessConf.Region = &conf.AWSRegion
}

if conf.AWSProfile != "" {
sessOpt.Profile = conf.AWSProfile
}

sessOpt.Config = sessConf
sessOpt.SharedConfigState = session.SharedConfigEnable

return session.NewSessionWithOptions(sessOpt)
}
91 changes: 91 additions & 0 deletions aws/ssm/ansible.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package ssm

import (
"os"
"strings"

"github.com/aws/aws-sdk-go/aws"
assm "github.com/aws/aws-sdk-go/service/ssm"
"github.com/davecgh/go-spew/spew"
)

func (s ssm) RunAnsible() error {
s.log.Info("Running ssm ansible command")

command, err := s.cl.SendCommand(&assm.SendCommandInput{
DocumentName: aws.String("AWS-RunAnsiblePlaybook"),
DocumentVersion: aws.String("$LATEST"),
InstanceIds: s.provideInstanceIDs(),
Parameters: s.provideAnsibleCommands(),
TimeoutSeconds: &s.conf.CommandExecMaxWait,
})
if err != nil {
return err
}

s.log.Info("Ansible playbook deployed successfully")
s.log.Info("Waiting for results...")

s.waitForCmdExecAndDisplayCmdOutput(command)

return nil
}

func (s ssm) provideAnsibleCommands() map[string][]*string {
var (
trueStr = "True"
falseStr = "False"
resp = map[string][]*string{}
check = map[bool]*string{
true: &trueStr,
false: &falseStr,
}
)

resp["check"] = []*string{check[s.conf.AnsibleDryRun]}

if s.conf.AnsiblePlaybook != "" {
playbookStr, err := os.ReadFile(s.conf.AnsiblePlaybook)
if err != nil {
s.log.Fatalln("Could not read ansible playbook", "err", err.Error())
}

playbook := string(playbookStr)

resp["playbook"] = []*string{&playbook}
}

if s.conf.AnsibleURL != "" {
resp["playbookurl"] = []*string{&s.conf.AnsibleURL}
}

if s.conf.AnsibleExtraVars != "" {
resp["extravars"] = []*string{s.processExtraVars()}
}

s.log.Debug("Ansible params", "prams", spew.Sdump(resp))

return resp
}

func (s ssm) processExtraVars() *string {
var (
trimmedVars = make([]string, 0)
processedVars string
)

vars := strings.Split(strings.TrimSpace(s.conf.AnsibleExtraVars), ",")
for _, v := range vars {
trimmedVars = append(trimmedVars, strings.TrimSpace(v))
}

for _, tv := range trimmedVars {
processedVars += tv + " "
}

processedVars = processedVars[:len(processedVars)-1] // trim last space char

s.log.Debug("Processed extra vars", "vars", processedVars)

return &processedVars
}
Loading

0 comments on commit 211b556

Please sign in to comment.