Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code refactor #2

Merged
merged 7 commits into from
Nov 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading