├── glide.yaml ├── _ci ├── cross-compile.sh └── run-tests.sh ├── version_command.go ├── .gitignore ├── util.go ├── circle.yml ├── LICENSE ├── glide.lock ├── main.go ├── CHANGELOG.md ├── unit_parse_hours_test.go ├── report_command.go ├── create_command.go ├── README.md ├── delete_command.go └── integration_create_delete_test.go /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/josh-padnick/ec2-snapper 2 | import: 3 | - package: github.com/aws/aws-sdk-go 4 | subpackages: 5 | - aws 6 | - aws/session 7 | - service/cloudwatch 8 | - service/ec2 9 | - package: github.com/mitchellh/cli 10 | -------------------------------------------------------------------------------- /_ci/cross-compile.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Cross compile this go program for every major architecture. You must install gox to run it: 5 | # https://github.com/mitchellh/gox 6 | # 7 | gox -os "darwin linux windows" -output bin/ec2-snapper_{{.OS}}_{{.Arch}} -------------------------------------------------------------------------------- /version_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mitchellh/cli" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type VersionCommand struct { 10 | cliRef cli.CLI 11 | } 12 | 13 | func (c *VersionCommand) Help() string { 14 | return `ec2-snapper version 15 | 16 | Return the version of ec2-snapper.` 17 | } 18 | 19 | func (c *VersionCommand) Synopsis() string { 20 | return "Return the version of ec2-snapper" 21 | } 22 | 23 | func (c *VersionCommand) Run(args []string) int { 24 | fmt.Fprintf(os.Stdout,"You are running ec2-snapper version %s.\n", c.cliRef.Version) 25 | return 0 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Custom for this repo 2 | 3 | ### Ignore JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion 4 | *.iml 5 | 6 | ## Directory-based project format: 7 | .idea/ 8 | 9 | ## File-based project format: 10 | *.ipr 11 | *.iws 12 | 13 | ## Plugin-specific files: 14 | 15 | # IntelliJ 16 | /out/ 17 | 18 | ### Ignore Golang Artifacts 19 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 20 | *.o 21 | *.a 22 | *.so 23 | 24 | # Folders 25 | _obj 26 | _test 27 | 28 | # Architecture specific extensions/prefixes 29 | *.[568vq] 30 | [568vq].out 31 | 32 | *.cgo1.go 33 | *.cgo2.c 34 | _cgo_defun.c 35 | _cgo_gotypes.go 36 | _cgo_export.* 37 | 38 | _testmain.go 39 | 40 | *.exe 41 | *.test 42 | *.prof 43 | bin/ 44 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | const BASE_62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 10 | const UNIQUE_ID_LENGTH = 6 // Should be good for 62^6 = 56+ billion combinations 11 | 12 | // Returns a unique (ish) id we can attach to EC2 resources so they don't conflict with each other when tests run in 13 | // parallel Uses base 62 to generate a 6 character string that's unlikely to collide with the handful of tests we run in 14 | // parallel. Based on code here: http://stackoverflow.com/a/9543797/483528 15 | func UniqueId() string { 16 | var out bytes.Buffer 17 | 18 | rand := rand.New(rand.NewSource(time.Now().UnixNano())) 19 | for i := 0; i < UNIQUE_ID_LENGTH; i++ { 20 | out.WriteByte(BASE_62_CHARS[rand.Intn(len(BASE_62_CHARS))]) 21 | } 22 | 23 | return out.String() 24 | 25 | } -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | IMPORT_PATH: "github.com/gruntwork-io/${CIRCLE_PROJECT_REPONAME}" 4 | REPO: "$HOME/.go_workspace/src/${IMPORT_PATH}" 5 | GOOS: linux 6 | GO15VENDOREXPERIMENT: 1 7 | 8 | dependencies: 9 | override: 10 | # Get our repo into the CircleCI GOPATH. 11 | - | 12 | mkdir -p "${REPO}" 13 | rm -rf "${REPO}" 14 | ln -s "${HOME}/${CIRCLE_PROJECT_REPONAME}" "${REPO}" 15 | 16 | # Install glide to fetch Go dependencies 17 | - | 18 | if [[ ! -d ~/glide ]]; then 19 | wget https://github.com/Masterminds/glide/releases/download/0.10.2/glide-0.10.2-linux-amd64.zip 20 | unzip glide-0.10.2-linux-amd64.zip -d ~/glide 21 | fi 22 | 23 | # Run glide 24 | - | 25 | cd ${REPO} 26 | ~/glide/linux-amd64/glide install 27 | 28 | cache_directories: 29 | - ~/glide 30 | 31 | test: 32 | override: 33 | - | 34 | sudo chmod 0755 "${REPO}/_ci/run-tests.sh" 35 | ${REPO}/_ci/run-tests.sh 36 | -------------------------------------------------------------------------------- /_ci/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script is meant to be run in a CI job to run the automated tests. 3 | 4 | set -e 5 | 6 | # Go's default test timeout is 10 minutes, after which it unceremoniously kills the entire test run, preventing any 7 | # cleanup from running. To prevent that, we set a higher timeout. 8 | readonly TEST_TIMEOUT="30m" 9 | 10 | # Our tests do very little that is CPU intensive and spend the vast majority of their time just waiting for AWS, so 11 | # run as many of them in parallel as we can. Circle CI boxes have 32 cores, so this is just 4 tests per core, which it 12 | # should easily be able to handle if we ever get to the point that we have 128 tests! 13 | readonly TEST_PARALLELISM="128" 14 | 15 | # SCRIPT_DIR contains the location of the script you're reading now 16 | readonly SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 17 | cd "$SCRIPT_DIR/.." 18 | 19 | # Install test dependencies 20 | go test -i 21 | 22 | # Set the verbose flag so we get log output even if the tests pass 23 | go test -v -timeout "$TEST_TIMEOUT" -parallel "$TEST_PARALLELISM" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Gruntwork, LLC 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, 6 | distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 12 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 13 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: c9e3d7ef416e312654f557d289881364076e089fbbdb0e48f18efb97891ac6c0 2 | updated: 2016-05-02T21:59:11.029494894-07:00 3 | imports: 4 | - name: github.com/armon/go-radix 5 | version: 4239b77079c7b5d1243b7b4736304ce8ddb6f0f2 6 | - name: github.com/aws/aws-sdk-go 7 | version: 1915858199be30d43264f86f9b9b469b7f2c8340 8 | subpackages: 9 | - aws 10 | - aws/session 11 | - service/cloudwatch 12 | - service/ec2 13 | - aws/awserr 14 | - aws/credentials 15 | - aws/client 16 | - aws/corehandlers 17 | - aws/defaults 18 | - aws/request 19 | - private/endpoints 20 | - aws/awsutil 21 | - aws/client/metadata 22 | - private/protocol 23 | - private/protocol/query 24 | - private/signer/v4 25 | - private/protocol/ec2query 26 | - private/waiter 27 | - aws/credentials/ec2rolecreds 28 | - aws/ec2metadata 29 | - private/protocol/query/queryutil 30 | - private/protocol/xml/xmlutil 31 | - private/protocol/rest 32 | - name: github.com/bgentry/speakeasy 33 | version: 36e9cfdd690967f4f690c6edcc9ffacd006014a0 34 | - name: github.com/go-ini/ini 35 | version: 12f418cc7edc5a618a51407b7ac1f1f512139df3 36 | - name: github.com/jmespath/go-jmespath 37 | version: 0b12d6b521d83fc7f755e7cfc1b1fbdd35a01a74 38 | - name: github.com/mattn/go-isatty 39 | version: 56b76bdf51f7708750eac80fa38b952bb9f32639 40 | - name: github.com/mitchellh/cli 41 | version: 168daae10d6ff81b8b1201b0a4c9607d7e9b82e3 42 | - name: golang.org/x/sys 43 | version: b776ec39b3e54652e09028aaaaac9757f4f8211a 44 | subpackages: 45 | - unix 46 | devImports: [] 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "github.com/mitchellh/cli" 7 | ) 8 | 9 | func main() { 10 | 11 | ui := &cli.BasicUi{ 12 | Reader: os.Stdin, 13 | Writer: os.Stdout, 14 | ErrorWriter: os.Stderr, 15 | } 16 | 17 | // CLI stuff 18 | c := cli.NewCLI("ec2-snapper", "0.5.2") 19 | c.Args = os.Args[1:] 20 | 21 | c.Commands = map[string]cli.CommandFactory{ 22 | "create": func() (cli.Command, error) { 23 | return &CreateCommand{ 24 | Ui: &cli.ColoredUi{ 25 | Ui: ui, 26 | OutputColor: cli.UiColorNone, 27 | ErrorColor: cli.UiColorRed, 28 | WarnColor: cli.UiColorYellow, 29 | InfoColor: cli.UiColorGreen, 30 | }, 31 | }, nil 32 | }, 33 | "delete": func() (cli.Command, error) { 34 | return &DeleteCommand{ 35 | Ui: &cli.ColoredUi{ 36 | Ui: ui, 37 | OutputColor: cli.UiColorNone, 38 | ErrorColor: cli.UiColorRed, 39 | WarnColor: cli.UiColorYellow, 40 | InfoColor: cli.UiColorGreen, 41 | }, 42 | }, nil 43 | }, 44 | "report": func() (cli.Command, error) { 45 | return &ReportCommand{ 46 | Ui: &cli.ColoredUi{ 47 | Ui: ui, 48 | OutputColor: cli.UiColorNone, 49 | ErrorColor: cli.UiColorRed, 50 | WarnColor: cli.UiColorYellow, 51 | InfoColor: cli.UiColorGreen, 52 | }, 53 | }, nil 54 | }, 55 | "version": func() (cli.Command, error) { 56 | return &VersionCommand{ 57 | cliRef: *c, 58 | }, nil 59 | }, 60 | } 61 | 62 | exitStatus, err := c.Run() 63 | if err != nil { 64 | fmt.Println(os.Stderr, err.Error()) 65 | } 66 | 67 | os.Exit(exitStatus) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.5.2 (September 14, 2016) 2 | 3 | * BUG: The ec2-snapper `delete` command now gracefully exits if no snapshots exist for the given instance rather than 4 | exiting with an error. 5 | 6 | # 0.5.1 (September 11, 2016) 7 | 8 | * ENHANCEMENT: ec2-snapper now also tags the EBS volume snapshots it creates as part of the process of creating an AMI. 9 | This allows you to find an AMI by name and restore an entire EC2 Instance, or find individual EBS Volumes by name and 10 | restore just a single volume. 11 | 12 | # 0.5.0 (April 15, 2016) 13 | 14 | * ENHANCEMENT: Add support for a `report` command for reporting CloudWatch metrics. 15 | 16 | # 0.4.2 (April 15, 2016) 17 | 18 | * BUG: Fix version number. 19 | 20 | # 0.4.1 (April 14, 2016) 21 | 22 | * TWEAK: Use gox for cross-compile. 23 | * BUG: Fix extra newline issue in binaries. 24 | * BUG: Fix issues in `circle.yml`. 25 | 26 | # 0.4.0 (April 14, 2016) 27 | 28 | * REFACTOR: The AWS region is passed in via the `--region` param. 29 | * ENHANCEMENT: You can now specify an instance name via the `--instance-name` parameter instead of using `--instance-id` 30 | (which might change every time you redeploy). 31 | * ENHANCEMENT: Added unit and integration tests. 32 | * TWEAK: Publish binaries directly in GitHub instead of bintray. 33 | 34 | # 0.3.0 (February 11, 2016) 35 | 36 | * ENHANCEMENT: Created AMIs now include the specified name in the `Name` tag. [GH-4](https://github.com/josh-padnick/ec2-snapper/pull/4) 37 | * ENHANCEMENT: Added `ec2-snapper version` subcommand. 38 | * BUG: Fixed [ec2-snapper only deletes 1 snapshot per AMI](https://github.com/josh-padnick/ec2-snapper/issues/5) 39 | * TWEAK: Updated to latest version of AWS SDK for Golang 40 | 41 | # 0.2.0 (July 17, 2015) 42 | 43 | * FEATURE: Added the ability to say "always leave at least X AMI's in place" 44 | * BUG: Fixed [AWS API sometimes fails to add tags as requested](https://github.com/josh-padnick/ec2-snapper/issues/1) 45 | 46 | # 0.1.0 (June 8, 2015) 47 | 48 | * Initial release 49 | -------------------------------------------------------------------------------- /unit_parse_hours_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const FLOATING_POINT_THRESHOLD = 0.000001 8 | 9 | func TestParseOlderThanToHoursInvalidFormat(t *testing.T) { 10 | t.Parallel() 11 | 12 | _, err := parseOlderThanToHours("not-a-valid-format") 13 | if err == nil { 14 | t.Fatal("Expected to get an error when parsing an invalid format, but got nil") 15 | } 16 | } 17 | 18 | func TestParseOlderThanToHoursNegativeHours(t *testing.T) { 19 | t.Parallel() 20 | 21 | _, err := parseOlderThanToHours("-15h") 22 | if err == nil { 23 | t.Fatal("Expected to get an error when parsing a negative value, but got nil") 24 | } 25 | } 26 | 27 | func TestParseOlderThanToHoursZeroHours(t *testing.T) { 28 | t.Parallel() 29 | testParseOlderThan("0h", 0, t) 30 | } 31 | 32 | func TestParseOlderThanToHoursOneHour(t *testing.T) { 33 | t.Parallel() 34 | testParseOlderThan("1h", 1, t) 35 | } 36 | 37 | func TestParseOlderThanToHoursTenHours(t *testing.T) { 38 | t.Parallel() 39 | testParseOlderThan("10h", 10, t) 40 | } 41 | 42 | func TestParseOlderThanToHoursNineHundredNinetyNineHours(t *testing.T) { 43 | t.Parallel() 44 | testParseOlderThan("999h", 999, t) 45 | } 46 | 47 | func TestParseOlderThanToHoursZeroMinutes(t *testing.T) { 48 | t.Parallel() 49 | testParseOlderThan("0m", 0, t) 50 | } 51 | 52 | func TestParseOlderThanToHoursOneMinute(t *testing.T) { 53 | t.Parallel() 54 | testParseOlderThan("1m", 0.01666666666667, t) 55 | } 56 | 57 | func TestParseOlderThanToHoursTenMinutes(t *testing.T) { 58 | t.Parallel() 59 | testParseOlderThan("10m", 0.16666666666667, t) 60 | } 61 | 62 | func TestParseOlderThanToHoursSixtyMinutes(t *testing.T) { 63 | t.Parallel() 64 | testParseOlderThan("60m", 1, t) 65 | } 66 | 67 | func TestParseOlderThanToHoursNineHundredNinetyNineMinutes(t *testing.T) { 68 | t.Parallel() 69 | testParseOlderThan("999m", 16.65, t) 70 | } 71 | 72 | func TestParseOlderThanToHoursZeroDays(t *testing.T) { 73 | t.Parallel() 74 | testParseOlderThan("0d", 0, t) 75 | } 76 | 77 | func TestParseOlderThanToHoursOneDay(t *testing.T) { 78 | t.Parallel() 79 | testParseOlderThan("1d", 24, t) 80 | } 81 | 82 | func TestParseOlderThanToHoursTenDays(t *testing.T) { 83 | t.Parallel() 84 | testParseOlderThan("10d", 240, t) 85 | } 86 | 87 | func TestParseOlderThanToHoursNineHundredNinetyNineDays(t *testing.T) { 88 | t.Parallel() 89 | testParseOlderThan("999d", 23976, t) 90 | } 91 | 92 | func testParseOlderThan(timeFormat string, expectedHours float64, t *testing.T) { 93 | hours, err := parseOlderThanToHours(timeFormat) 94 | if err != nil { 95 | t.Fatalf("Unexpected error parsing a valid time format '%s': %s", timeFormat, err.Error()) 96 | } 97 | 98 | diff := expectedHours - hours 99 | if diff > FLOATING_POINT_THRESHOLD { 100 | t.Fatalf("Expected %9f but got %9f. The difference %9f is greater than the floating point threshold %9f.", expectedHours, hours, diff, FLOATING_POINT_THRESHOLD) 101 | } 102 | } -------------------------------------------------------------------------------- /report_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mitchellh/cli" 5 | "flag" 6 | "errors" 7 | "fmt" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/service/cloudwatch" 11 | ) 12 | 13 | const DEFAULT_METRIC_VALUE = 1 14 | const DEFAULT_METRIC_UNIT = cloudwatch.StandardUnitCount 15 | 16 | type ReportCommand struct { 17 | Ui cli.Ui 18 | AwsRegion string 19 | Namespace string 20 | MetricName string 21 | MetricValue float64 22 | MetricUnit string 23 | } 24 | 25 | // descriptions for args 26 | var reportDscrAwsRegion = "The AWS region to use (e.g. us-west-2)" 27 | var reportDscrNamespace = "The CloudWatch namespace for this metric (e.g. MyCustomMetrics)." 28 | var reportDscrMetricName = "The name of the metric (e.g. MyEC2Backup)." 29 | var reportDscrMetricValue = fmt.Sprintf("The value of the metric (e.g. 1). Defaults to %d.", DEFAULT_METRIC_VALUE) 30 | var reportDscrMetricUnit = fmt.Sprintf("The unit of the metric (e.g. Count). Defaults to %s.", DEFAULT_METRIC_UNIT) 31 | 32 | func (c *ReportCommand) Help() string { 33 | return `ec2-snapper report [--help] 34 | 35 | Report a metric to CloudWatch. 36 | 37 | Available args are: 38 | --region ` + reportDscrAwsRegion + ` 39 | --namespace ` + reportDscrNamespace + ` 40 | --name ` + reportDscrMetricName + ` 41 | --value ` + reportDscrMetricValue + ` 42 | --unit ` + reportDscrMetricUnit 43 | } 44 | 45 | func (c *ReportCommand) Synopsis() string { 46 | return "Report a metric to CloudWatch" 47 | } 48 | 49 | func (c *ReportCommand) Run(args []string) int { 50 | 51 | // Handle the command-line args 52 | cmdFlags := flag.NewFlagSet("report", flag.ExitOnError) 53 | cmdFlags.Usage = func() { 54 | c.Ui.Output(c.Help()) 55 | } 56 | 57 | cmdFlags.StringVar(&c.AwsRegion, "region", "", reportDscrAwsRegion) 58 | cmdFlags.StringVar(&c.Namespace, "namespace", "", reportDscrNamespace) 59 | cmdFlags.StringVar(&c.MetricName, "name", "", reportDscrMetricName) 60 | cmdFlags.Float64Var(&c.MetricValue, "value", DEFAULT_METRIC_VALUE, reportDscrMetricValue) 61 | cmdFlags.StringVar(&c.MetricUnit, "unit", DEFAULT_METRIC_UNIT, reportDscrMetricUnit) 62 | 63 | if err := cmdFlags.Parse(args); err != nil { 64 | return 1 65 | } 66 | 67 | if err := report(*c); err != nil { 68 | c.Ui.Error(err.Error()) 69 | return 1 70 | } 71 | 72 | return 0 73 | } 74 | 75 | func report(c ReportCommand) error { 76 | if err := validateReportArgs(c); err != nil { 77 | return err 78 | } 79 | 80 | session := session.New(&aws.Config{Region: &c.AwsRegion}) 81 | svc := cloudwatch.New(session) 82 | 83 | return createMetric(c, svc) 84 | } 85 | 86 | func createMetric(c ReportCommand, svc *cloudwatch.CloudWatch) error { 87 | 88 | metricData := &cloudwatch.MetricDatum{ 89 | MetricName: aws.String(c.MetricName), 90 | Value: aws.Float64(c.MetricValue), 91 | Unit: aws.String(c.MetricUnit), 92 | } 93 | metricInput := &cloudwatch.PutMetricDataInput{ 94 | Namespace: aws.String(c.Namespace), 95 | MetricData: []*cloudwatch.MetricDatum{metricData}, 96 | } 97 | 98 | c.Ui.Output(fmt.Sprintf("Writing metric data to CloudWatch:\n%s", metricInput.String())) 99 | _, err := svc.PutMetricData(metricInput) 100 | return err 101 | } 102 | 103 | func validateReportArgs(c ReportCommand) error { 104 | if c.AwsRegion == "" { 105 | return errors.New("ERROR: The argument '--region' is required.") 106 | } 107 | 108 | if c.Namespace == "" { 109 | return errors.New("ERROR: The argument '--namespace' is required.") 110 | } 111 | 112 | if c.MetricName == "" { 113 | return errors.New("ERROR: The argument '--name' is required.") 114 | } 115 | 116 | return nil 117 | } 118 | 119 | -------------------------------------------------------------------------------- /create_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "time" 6 | "strings" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/ec2" 11 | "github.com/mitchellh/cli" 12 | "errors" 13 | "fmt" 14 | ) 15 | 16 | type CreateCommand struct { 17 | Ui cli.Ui 18 | AwsRegion string 19 | InstanceId string 20 | InstanceName string 21 | AmiName string 22 | DryRun bool 23 | NoReboot bool 24 | } 25 | 26 | const EC2_SNAPPER_INSTANCE_ID_TAG = "ec2-snapper-instance-id" 27 | 28 | // descriptions for args 29 | var createDscrAwsRegion = "The AWS region to use (e.g. us-west-2)" 30 | var createDscrInstanceId = "The id of the instance from which to create the AMI" 31 | var createDscrInstanceName = "The name (from tags) of the instance from which to create the AMI" 32 | var createDscrAmiName = "The name of the AMI; the current timestamp will be automatically appended" 33 | var createDscrDryRun = "Execute a simulated run" 34 | var createDscrNoReboot = "If true, do not reboot the instance before creating the AMI. It is preferable to reboot the instance to guarantee a consistent filesystem when taking the snapshot, but the likelihood of an inconsistent snapshot is very low." 35 | 36 | func (c *CreateCommand) Help() string { 37 | return `ec2-snapper create [--help] 38 | 39 | Create an AMI of the given EC2 instance. 40 | 41 | Available args are: 42 | --region ` + createDscrAwsRegion + ` 43 | --instance-id ` + createDscrInstanceId + ` 44 | --instance-name ` + createDscrInstanceName + ` 45 | --ami-name ` + createDscrAmiName + ` 46 | --dry-run ` + createDscrDryRun + ` 47 | --no-reboot ` + createDscrNoReboot 48 | } 49 | 50 | func (c *CreateCommand) Synopsis() string { 51 | return "Create an AMI of the given EC2 instance" 52 | } 53 | 54 | func (c *CreateCommand) Run(args []string) int { 55 | 56 | // Handle the command-line args 57 | cmdFlags := flag.NewFlagSet("create", flag.ExitOnError) 58 | cmdFlags.Usage = func() { c.Ui.Output(c.Help()) } 59 | 60 | cmdFlags.StringVar(&c.AwsRegion, "region", "", createDscrAwsRegion) 61 | cmdFlags.StringVar(&c.InstanceId, "instance-id", "", createDscrInstanceId) 62 | cmdFlags.StringVar(&c.InstanceName, "instance-name", "", createDscrInstanceName) 63 | cmdFlags.StringVar(&c.AmiName, "ami-name", "", createDscrAmiName) 64 | cmdFlags.BoolVar(&c.DryRun, "dry-run", false, createDscrDryRun) 65 | cmdFlags.BoolVar(&c.NoReboot, "no-reboot", true, createDscrNoReboot) 66 | 67 | if err := cmdFlags.Parse(args); err != nil { 68 | return 1 69 | } 70 | 71 | if _, err := create(*c); err != nil { 72 | c.Ui.Error(err.Error()) 73 | return 1 74 | } 75 | 76 | return 0 77 | } 78 | 79 | func create(c CreateCommand) (string, error) { 80 | snapshotId := "" 81 | 82 | if err := validateCreateArgs(c); err != nil { 83 | return snapshotId, err 84 | } 85 | 86 | session := session.New(&aws.Config{Region: &c.AwsRegion}) 87 | svc := ec2.New(session) 88 | 89 | if c.InstanceId == "" { 90 | instanceId, err := getInstanceIdByName(c.InstanceName, svc, c.Ui) 91 | if err != nil { 92 | return snapshotId, err 93 | } 94 | c.InstanceId = instanceId 95 | } 96 | 97 | // Generate a nicely formatted timestamp for right now 98 | const dateLayoutForAmiName = "2006-01-02 at 15_04_05 (MST)" 99 | t := time.Now() 100 | 101 | // Create the AMI Snapshot 102 | name := c.AmiName + " - " + t.Format(dateLayoutForAmiName) 103 | 104 | c.Ui.Output("==> Creating AMI for " + c.InstanceId + "...") 105 | 106 | resp, err := svc.CreateImage(&ec2.CreateImageInput{ 107 | Name: &name, 108 | InstanceId: &c.InstanceId, 109 | DryRun: &c.DryRun, 110 | NoReboot: &c.NoReboot }) 111 | if err != nil && strings.Contains(err.Error(), "NoCredentialProviders") { 112 | return snapshotId, errors.New("ERROR: No AWS credentials were found. Either set the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or run this program on an EC2 instance that has an IAM Role with the appropriate permissions.") 113 | } else if err != nil { 114 | return snapshotId, err 115 | } 116 | 117 | // Sleep here to give time for AMI to get found 118 | time.Sleep(3000 * time.Millisecond) 119 | 120 | // Assign tags to this AMI. We'll use these when it comes time to delete the AMI 121 | snapshotId = *resp.ImageId 122 | c.Ui.Output("==> Adding tags to AMI " + snapshotId + "...") 123 | 124 | _, tagsErr := svc.CreateTags(&ec2.CreateTagsInput{ 125 | Resources: []*string{&snapshotId}, 126 | Tags: []*ec2.Tag{ 127 | &ec2.Tag{ Key: aws.String(EC2_SNAPPER_INSTANCE_ID_TAG), Value: &c.InstanceId }, 128 | &ec2.Tag{ Key: aws.String("Name"), Value: &c.AmiName }, 129 | }, 130 | }) 131 | 132 | if tagsErr != nil { 133 | return snapshotId, tagsErr 134 | } 135 | 136 | // Check the status of the AMI 137 | respDscrImages, err := svc.DescribeImages(&ec2.DescribeImagesInput{ 138 | Filters: []*ec2.Filter{ 139 | &ec2.Filter{ 140 | Name: aws.String("image-id"), 141 | Values: []*string{&snapshotId}, 142 | }, 143 | }, 144 | }) 145 | if err != nil { 146 | return snapshotId, err 147 | } 148 | 149 | // If no AMI at all was found, throw an error 150 | if len(respDscrImages.Images) == 0 { 151 | return snapshotId, errors.New("ERROR: Could not find the AMI just created.") 152 | } 153 | 154 | ami := *respDscrImages.Images[0] 155 | 156 | // If the AMI's status is failed throw an error 157 | if *ami.State == ec2.ImageStateFailed { 158 | return snapshotId, errors.New("ERROR: AMI was created but entered a state of 'failed'. This is an AWS issue. Please re-run this command. Note that you will need to manually de-register the AMI in the AWS console or via the API.") 159 | } 160 | 161 | // Tag each volume for the AMI as well so we can find them later 162 | for _, blockDeviceMapping := range ami.BlockDeviceMappings { 163 | if blockDeviceMapping != nil && blockDeviceMapping.Ebs != nil { 164 | c.Ui.Output("==> Adding tags to EBS Volume Snapshot " + *blockDeviceMapping.Ebs.SnapshotId + " (" + *blockDeviceMapping.DeviceName + ") of AMI " + *ami.Name + "...") 165 | _, err := svc.CreateTags(&ec2.CreateTagsInput{ 166 | Resources: []*string{blockDeviceMapping.Ebs.SnapshotId}, 167 | Tags: []*ec2.Tag{ 168 | &ec2.Tag{ Key: aws.String(EC2_SNAPPER_INSTANCE_ID_TAG), Value: &c.InstanceId }, 169 | &ec2.Tag{ Key: aws.String("Name"), Value: aws.String(c.AmiName + "-" + *blockDeviceMapping.DeviceName) }, 170 | }, 171 | }) 172 | 173 | if err != nil { 174 | return snapshotId, err 175 | } 176 | } 177 | } 178 | 179 | // Announce success 180 | c.Ui.Info("==> Success! Created " + snapshotId + " named \"" + name + "\"") 181 | return snapshotId, nil 182 | } 183 | 184 | func validateCreateArgs(c CreateCommand) error { 185 | if c.AwsRegion == "" { 186 | return errors.New("ERROR: The argument '--region' is required.") 187 | } 188 | 189 | if (c.InstanceId == "" && c.InstanceName == "") || (c.InstanceId != "" && c.InstanceName != "") { 190 | return errors.New("ERROR: You must specify exactly one of '--instance-id' or '--instance-name'.") 191 | } 192 | 193 | if c.AmiName == "" { 194 | return errors.New("ERROR: The argument '--name' is required.") 195 | } 196 | 197 | return nil 198 | } 199 | 200 | func getInstanceIdByName(instanceName string, svc *ec2.EC2, ui cli.Ui) (string, error) { 201 | ui.Output(fmt.Sprintf("Looking up id for instance named %s", instanceName)) 202 | 203 | nameTagFilter := ec2.Filter{ 204 | Name: aws.String("tag:Name"), 205 | Values: []*string{aws.String(instanceName)}, 206 | } 207 | 208 | result, err := svc.DescribeInstances(&ec2.DescribeInstancesInput{Filters: []*ec2.Filter{&nameTagFilter}}) 209 | if err != nil { 210 | return "", err 211 | } 212 | 213 | if len(result.Reservations) != 1 { 214 | return "", errors.New(fmt.Sprintf("Expected to find one result for instance name %s, but found %d", instanceName, len(result.Reservations))) 215 | } 216 | 217 | reservation := result.Reservations[0] 218 | 219 | if len(reservation.Instances) != 1 { 220 | return "", errors.New(fmt.Sprintf("Expected to find one instance with instance name %s, but found %d", instanceName, len(reservation.Instances))) 221 | } 222 | 223 | instance := reservation.Instances[0] 224 | ui.Output(fmt.Sprintf("Found id %s for instance named %s", *instance.InstanceId, instanceName)) 225 | 226 | return *instance.InstanceId, nil 227 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ec2-snapper 2 | 3 | ec2-snapper is a simple command-line tool for creating and deleting AMI's of your EC2 instances. It was designed to make it easy to make backups of your AMI's and to cleanup old backups by deleting all AMI's (and their corresponding Snapshots) for a given EC2 instance which are older than X days/hours/minutes. It works especially well as part of a cronjob. It can also report custom metrics to CloudWatch, which can be useful for triggering alarms if a cronjob fails to run. 4 | 5 | ## Download 6 | Download the latest version from the [releases page](https://github.com/josh-padnick/ec2-snapper/releases). 7 | 8 | ## Motivation 9 | For the full story, see the [Motivating Blog Post](https://joshpadnick.com/2015/06/18/a-simple-tool-for-snapshotting-your-ec2-instances/). 10 | 11 | One of the best parts of working with EC2 instances is you can create a snapshot of the EC2 instance as an Amazon Machine Image (AMI). The problem is that deleting AMI's is a really clunky experience: 12 | 13 | 1. Deleting an AMI is a two-part process. First, you have to de-register the AMI. Then you have to delete the corresponding EBS volume snapshot. 14 | 15 | 2. Finding the corresponding snapshot is cumbersome. 16 | 17 | 3. There's no out-of-the-box way to delete all AMI's older than X days. 18 | 19 | I wrote ec2-snapper so I could use a simple command-line tool to create snapshots, delete them with one command, and delete ones older than a certain age. It works especially well when run as a cronjob on a nightly basis. It even supports sending custom metrics to CloudWatch, which you can use to trigger alarms in case a cronjob fails. 20 | 21 | I personally use it to backup my Wordpress blog which is running as a single EC2 instance. If my EC2 instance were to fail, I can instantly launch a new EC2 instance from the latest snapshot. Since I run ec2-snapper nightly, I'm subject to up to 24 hours of data loss, which is tolerable for my needs. 22 | 23 | ## Prerequisites 24 | You will need to setup your AWS credentials so ec2-snapper can authenticate to AWS. 25 | 26 | ### Option 1: Set Environment Variables 27 | One option is to authenticate by exporting the following environment variables: 28 | 29 | ```bash 30 | AWS_ACCESS_KEY_ID=AKID1234567890 31 | AWS_SECRET_ACCESS_KEY=MY-SECRET-KEY 32 | ``` 33 | 34 | ### Option 2: Use IAM Roles 35 | If you're running ec2-snapper on an Amazon EC2 instance, the preferred way to authenticate is by assigning an [IAM Role](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) to your EC2 instance. Note that IAM roles can only be assigned when an EC2 instance is being launched, and not after the fact. 36 | 37 | ### Account Permissions 38 | Whichever method you use to authenticate, the AWS account you use to authenticate will need the limited set of IAM permissions in this IAM policy: 39 | 40 | ```json 41 | { 42 | "Version": "2012-10-17", 43 | "Statement": [ 44 | { 45 | "Sid": "Stmt1433747550000", 46 | "Effect": "Allow", 47 | "Action": [ 48 | "cloudwatch:PutMetricData", 49 | "ec2:CreateImage", 50 | "ec2:CreateTags", 51 | "ec2:DeleteSnapshot", 52 | "ec2:DeregisterImage", 53 | "ec2:DescribeImages", 54 | "ec2:DescribeInstances", 55 | "ec2:DescribeSnapshots" 56 | ], 57 | "Resource": [ 58 | "*" 59 | ] 60 | } 61 | ] 62 | } 63 | ``` 64 | 65 | ## Installation 66 | There's nothing to install. Just download the binary and run it using the commands you see below. 67 | 68 | ## Usage 69 | Try any of the following commands to get a full list of all arguments: 70 | 71 | ```bash 72 | ec2-snapper --help 73 | ec2-snapper create --help 74 | ec2-snapper delete --help 75 | ec2-snapper report --help 76 | ``` 77 | 78 | ### Get the Version 79 | ```bash 80 | ec2-snapper version 81 | ``` 82 | 83 | Returns the current version you're using of ec2-snapper. 84 | 85 | ### Create an AMI 86 | For all options, run `ec2-snapper create --help`. 87 | 88 | Example: 89 | 90 | ```bash 91 | ec2-snapper create --region=us-west-2 --instance-id=i-c724be30 --ami-name=MyEc2Instance --dry-run --no-reboot 92 | ``` 93 | You must specify the AWS region (e.g. `--region=us-west-2`) and either the ID (e.g. `--instance-id=i-c724be30`) or the name as set in an EC2 tag called "Name" (e.g. `--instance-name=my-instance`) of an EC2 instance in that region to be snapshotted. You also specify what to name the AMI, such as "MyWebsite.com", using the `--ami-name` parameter. A current timestamp will automatically be appended to the AMI name. 94 | 95 | For example, `ec2-snapper create --instance-id=i-c724be30 --ami-name="MyWebsite.com"` resulted in an AMI named "MyWebsite.com - 2015-06-08 at 08_26_51 (UTC)". 96 | 97 | Adding `--dry-run` will simulate the command without actually taking a snapshot. 98 | 99 | `--no-reboot` explicitly indicates whether to reboot the EC2 instance when taking the snapshot. The default is `true`. 100 | 101 | Note that the last two args can either be written as `--dry-run` or `--dry-run=true`. 102 | 103 | ### Delete AMIs older than X days / Y hours / Z minutes 104 | For all options, run `ec2-snapper delete --help`. 105 | 106 | Example: 107 | 108 | ```bash 109 | ec2-snapper delete --region=us-west-2 --instance-id=i-c724b30 --older-than=30d --dry-run 110 | ``` 111 | 112 | You must specify the AWS region (e.g. `--region=us-west-2`) and either the ID (e.g. `--instance-id=i-c724be30`) or the name as set in an EC2 tag called "Name" (e.g. `--instance-name=my-instance`) of an EC2 instance in that region that was originally used to create the AMIs you wish to delete (even if that EC2 instance has since been stopped or terminated). 113 | 114 | `--older-than` accepts time values like `30d`, `5h` or `15m` for 30 days, 5 hours, or 15 minutes, respectively. For example, `--older-than=30d` tells ec2-snapper to delete any AMI for the given EC2 instance that is older than 30 days. 115 | 116 | `--require-at-least` ensures that in no event will there be fewer than the specified number of total AMIs for this instance. For example, `--require-at-least=5` tells ec2-snapper to always make sure there are at least 5 total AMIs for the given instance, even if these AMIs are marked for deletion based on the `--older-than` command. 117 | 118 | `--dry-run` will list the AMIs that would have been deleted, but does not actually delete them. 119 | 120 | ### Report to CloudWatch 121 | For all options, run `ec2-snapper report --help`. 122 | 123 | Example: 124 | 125 | ```bash 126 | ec2-snapper report --region=us-west-2 --name=MyEc2Backup --namespace=MyCustomMetrics --value=1 127 | ``` 128 | 129 | This command will write a custom metric to the specified region (e.g. `--region=us-west-2`) with the specified name (e.g. `--metric-name=MyEc2Backup`), namespace (e.g. `--namespace=MyCustomMetrics`), and value (e.g. `--value=1`). You can then add monitoring and alerting around this metric. 130 | 131 | For example, let's say you use a cronjob to run ec2-snapper once per night, and if the job completes successfully, you fire the metric as shown in the example above. In that case, you could create a CloudWatch alarm that goes off if the value of the `MyEc2Backup` metric is less than 1 over a 24 hour period. You can configure the alarm to send you an email or text message whenever it goes into `INSUFFICIENT_DATA` state, which would be an indicator that the cronjob failed for some reason. 132 | 133 | ## Contributors 134 | This was my first golang program, so I'm sure the code can benefit from various optimizations. Pull requests and bug reports are always welcome. 135 | 136 | ### Running from source 137 | The easiest way to run ec2-snapper from source is with the following command: 138 | 139 | ```bash 140 | go run main.go *_command.go 141 | ``` 142 | 143 | This is necessary because all the code is in the `main` package, so you have to tell Go explicitly what to build and 144 | run. For example, to run the `create` command, you could do: 145 | 146 | ```bash 147 | go run main.go *_command.go create --region=us-west-2 --instance-id=i-c1234567 --ami-name=MyBackup 148 | ``` 149 | 150 | ### Tests 151 | This repo contains two types of tests: 152 | 153 | 1. Unit tests: fast, isolated tests of individual functions. They use the name format `unit_xxx_test.go`. 154 | 2. Integration tests: slower, end-to-end tests that create and delete real resources in an AWS account. **All the 155 | resources should fit into the AWS free tier, but if you've used up all your credits, you may be charged!** 156 | Integration tests use the name format `integration_xxx_test.go`. 157 | 158 | To run the tests, first, set your AWS credentials using the environment variables `AWS_ACCESS_KEY_ID` and 159 | `AWS_SECRET_ACCESS_KEY`. 160 | 161 | To run all the tests: 162 | 163 | ```bash 164 | ./_ci/run-tests.sh 165 | ``` 166 | 167 | To run a specific test: 168 | 169 | ```bash 170 | go test -run MY_TEST_NAME 171 | ``` 172 | 173 | ### Release process 174 | 175 | 1. Update the version number in `main.go`. 176 | 1. Rebuild binaries by running `cross-compile.sh`. 177 | 1. Update `CHANGELOG.md`. 178 | 1. Commit all changes. 179 | 1. Create a new release using the [GitHub Release Page](https://github.com/josh-padnick/ec2-snapper/releases). Make 180 | sure to use the same version number as in step #1 and the changelog from step #3. 181 | 182 | TODO: automate this process! -------------------------------------------------------------------------------- /delete_command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mitchellh/cli" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/ec2" 14 | "math" 15 | "errors" 16 | "fmt" 17 | ) 18 | 19 | type DeleteCommand struct { 20 | Ui cli.Ui 21 | AwsRegion string 22 | InstanceId string 23 | InstanceName string 24 | OlderThan string 25 | RequireAtLeast int 26 | DryRun bool 27 | } 28 | 29 | // descriptions for args 30 | var deleteDscrAwsRegion = "The AWS region to use (e.g. us-west-2)" 31 | var deleteDscrInstanceId = "The ID of the EC2 instance from which the AMIs to be deleted were originally created." 32 | var deleteDscrInstanceName = "The name (from tags) of the EC2 instance from which the AMIs to be deleted were originally created." 33 | var deleteOlderThan = "Delete AMIs older than the specified time; accepts formats like '30d' or '4h'." 34 | var requireAtLeast = "Never delete AMIs such that fewer than this number of AMIs will remain. E.g. require at least 3 AMIs remain." 35 | var deleteDscrDryRun = "Execute a simulated run. Lists AMIs to be deleted, but does not actually delete them." 36 | 37 | func (c *DeleteCommand) Help() string { 38 | return `ec2-snapper create [--help] 39 | 40 | Create an AMI of the given EC2 instance. 41 | 42 | Available args are: 43 | --region ` + deleteDscrAwsRegion + ` 44 | --instance-id ` + deleteDscrInstanceId + ` 45 | --instance-name ` + deleteDscrInstanceName + ` 46 | --older-than ` + deleteOlderThan + ` 47 | --require-at-least ` + requireAtLeast + ` 48 | --dry-run ` + deleteDscrDryRun 49 | } 50 | 51 | func (c *DeleteCommand) Synopsis() string { 52 | return "Delete the specified AMIs" 53 | } 54 | 55 | func (c *DeleteCommand) Run(args []string) int { 56 | 57 | // Handle the command-line args 58 | cmdFlags := flag.NewFlagSet("delete", flag.ExitOnError) 59 | cmdFlags.Usage = func() { 60 | c.Ui.Output(c.Help()) 61 | } 62 | 63 | cmdFlags.StringVar(&c.AwsRegion, "region", "", deleteDscrAwsRegion) 64 | cmdFlags.StringVar(&c.InstanceId, "instance-id", "", deleteDscrInstanceId) 65 | cmdFlags.StringVar(&c.InstanceName, "instance-name", "", deleteDscrInstanceId) 66 | cmdFlags.StringVar(&c.OlderThan, "older-than", "", deleteOlderThan) 67 | cmdFlags.IntVar(&c.RequireAtLeast, "require-at-least", 0, requireAtLeast) 68 | cmdFlags.BoolVar(&c.DryRun, "dry-run", false, deleteDscrDryRun) 69 | 70 | if err := cmdFlags.Parse(args); err != nil { 71 | return 1 72 | } 73 | 74 | if err := deleteSnapshots(*c); err != nil { 75 | c.Ui.Error(err.Error()) 76 | return 1 77 | } 78 | 79 | return 0 80 | } 81 | 82 | func deleteSnapshots(c DeleteCommand) error { 83 | if err := validateDeleteArgs(c); err != nil { 84 | return err 85 | } 86 | 87 | if c.DryRun { 88 | c.Ui.Warn("WARNING: This is a dry run, and no actions will be taken, despite what any output may say!") 89 | } 90 | 91 | session := session.New(&aws.Config{Region: &c.AwsRegion}) 92 | svc := ec2.New(session) 93 | 94 | if c.InstanceId == "" { 95 | instanceId, err := getInstanceIdByName(c.InstanceName, svc, c.Ui) 96 | if err != nil { 97 | return err 98 | } 99 | c.InstanceId = instanceId 100 | } else { 101 | result, err := svc.DescribeInstances(&ec2.DescribeInstancesInput{InstanceIds: []*string{aws.String(c.InstanceId)}}) 102 | if err != nil { 103 | return err 104 | } 105 | if len(result.Reservations) == 0 || len(result.Reservations[0].Instances) == 0 { 106 | return fmt.Errorf("Could not find an instance with id %s", c.InstanceId) 107 | } 108 | } 109 | 110 | images, err := findImages(c.InstanceId, svc) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if len(images) == 0 { 116 | c.Ui.Info("NO ACTION TAKEN. There are no existing snapshots of instance " + c.InstanceId + " to delete.") 117 | return nil 118 | } 119 | 120 | // Check that at least the --require-at-least number of AMIs exists 121 | // - Note that even if this passes, we still want to avoid deleting so many AMIs that we go below the threshold 122 | if len(images) <= c.RequireAtLeast { 123 | c.Ui.Info("NO ACTION TAKEN. There are currently " + strconv.Itoa(len(images)) + " AMIs, and --require-at-least=" + strconv.Itoa(c.RequireAtLeast) + " so no further action can be taken.") 124 | return nil 125 | } 126 | 127 | // Get the AWS Account ID of the current AWS account 128 | // We need this to do a more efficient lookup on the snapshot volumes 129 | awsAccountId := *images[0].OwnerId 130 | c.Ui.Output("==> Identified current AWS Account Id as " + awsAccountId) 131 | 132 | hours, err := parseOlderThanToHours(c.OlderThan) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | filteredAmis, err := filterImagesByDateRange(images, hours) 138 | if err != nil { 139 | return err 140 | } 141 | c.Ui.Output("==> Found " + strconv.Itoa(len(filteredAmis)) + " total AMI(s) for deletion.") 142 | 143 | if len(filteredAmis) == 0 { 144 | c.Ui.Warn("No AMIs to delete.") 145 | return nil 146 | } 147 | 148 | allSnapshots, err := getAllSnapshots(awsAccountId, svc) 149 | if err != nil { 150 | return err 151 | } 152 | c.Ui.Output("==> Found " + strconv.Itoa(len(allSnapshots)) + " total snapshots in this account.") 153 | 154 | var numAmisToRemoveFromFiltered = computeNumAmisToRemove(images, filteredAmis, c.RequireAtLeast) 155 | if numAmisToRemoveFromFiltered > 0.0 { 156 | c.Ui.Output("==> Only deleting " + strconv.Itoa(len(filteredAmis) - int(numAmisToRemoveFromFiltered)) + " total AMIs to honor '--require-at-least=" + strconv.Itoa(c.RequireAtLeast) + "'.") 157 | } 158 | 159 | if err := deleteAmis(filteredAmis, allSnapshots, numAmisToRemoveFromFiltered, svc, c.DryRun, c.Ui); err != nil { 160 | return err 161 | } 162 | 163 | if c.DryRun { 164 | c.Ui.Info("==> DRY RUN. Had this not been a dry run, " + strconv.Itoa(len(filteredAmis)) + " AMI's and their corresponding snapshots would have been deleted.") 165 | } else { 166 | c.Ui.Info("==> Success! Deleted " + strconv.Itoa(len(filteredAmis) - int(numAmisToRemoveFromFiltered)) + " AMI's and their corresponding snapshots.") 167 | } 168 | return nil 169 | } 170 | 171 | // Get a list of every single snapshot in our account 172 | // (I wasn't able to find a better way to filter these, but suggestions welcome!) 173 | func getAllSnapshots(awsAccountId string, svc *ec2.EC2) ([]*ec2.Snapshot, error) { 174 | var noSnapshots []*ec2.Snapshot 175 | 176 | respDscrSnapshots, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{ 177 | OwnerIds: []*string{&awsAccountId}, 178 | }) 179 | if err != nil { 180 | return noSnapshots, err 181 | } 182 | 183 | return respDscrSnapshots.Snapshots, nil 184 | } 185 | 186 | // Compute whether we should delete fewer AMIs to adhere to our --require-at-least requirement 187 | func computeNumAmisToRemove(images []*ec2.Image, filteredAmis []*ec2.Image, requireAtLeast int) float64 { 188 | var numTotalAmis = len(images) 189 | var numFilteredAmis = len(filteredAmis) 190 | var numAmisToRemainAfterDelete = numTotalAmis - numFilteredAmis 191 | return math.Max(0.0, float64(requireAtLeast - numAmisToRemainAfterDelete)) 192 | } 193 | 194 | // Check for required command-line args 195 | func validateDeleteArgs(c DeleteCommand) error { 196 | if c.AwsRegion == "" { 197 | return errors.New("ERROR: The argument '--region' is required.") 198 | } 199 | 200 | if (c.InstanceId == "" && c.InstanceName == "") || (c.InstanceId != "" && c.InstanceName != "") { 201 | return errors.New("ERROR: You must specify exactly one of '--instance-id' or '--instance-name'.") 202 | } 203 | 204 | if c.OlderThan == "" { 205 | return errors.New("ERROR: The argument '--older-than' is required.") 206 | } 207 | 208 | if c.RequireAtLeast < 0 { 209 | return errors.New("ERROR: The argument '--require-at-least' must be a positive integer.") 210 | } 211 | 212 | return nil 213 | } 214 | 215 | // Now filter the AMIs to only include those within our date range 216 | func filterImagesByDateRange(images []*ec2.Image, olderThanHours float64) ([]*ec2.Image, error) { 217 | var filteredAmis[]*ec2.Image 218 | 219 | for i := 0; i < len(images); i++ { 220 | now := time.Now() 221 | creationDate, err := time.Parse(time.RFC3339Nano, *images[i].CreationDate) 222 | if err != nil { 223 | return filteredAmis, err 224 | } 225 | 226 | duration := now.Sub(creationDate) 227 | 228 | if duration.Hours() > olderThanHours { 229 | filteredAmis = append(filteredAmis, images[i]) 230 | } 231 | } 232 | 233 | return filteredAmis, nil 234 | } 235 | 236 | // Get a list of the existing AMIs that were created for the given EC2 instance 237 | func findImages(instanceId string, svc *ec2.EC2) ([]*ec2.Image, error) { 238 | var noImages []*ec2.Image 239 | 240 | // Get a list of the existing AMIs that were created for the given EC2 instance 241 | resp, err := svc.DescribeImages(&ec2.DescribeImagesInput{ 242 | Filters: []*ec2.Filter{ 243 | &ec2.Filter{ 244 | Name: aws.String(fmt.Sprintf("tag:%s", EC2_SNAPPER_INSTANCE_ID_TAG)), 245 | Values: []*string{&instanceId}, 246 | }, 247 | }, 248 | }) 249 | if err != nil && strings.Contains(err.Error(), "NoCredentialProviders") { 250 | return noImages, errors.New("ERROR: No AWS credentials were found. Either set the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or run this program on an EC2 instance that has an IAM Role with the appropriate permissions.") 251 | } else if err != nil { 252 | return noImages, err 253 | } 254 | 255 | return resp.Images, nil 256 | } 257 | 258 | func deleteAmis(amis []*ec2.Image, snapshots []*ec2.Snapshot, numAmisToRemoveFromFiltered float64, svc *ec2.EC2, dryRun bool, ui cli.Ui) error { 259 | for i := 0; i < len(amis) - int(numAmisToRemoveFromFiltered); i++ { 260 | // Step 1: De-register the AMI 261 | ui.Output(*amis[i].ImageId + ": De-registering AMI named \"" + *amis[i].Name + "\"...") 262 | _, err := svc.DeregisterImage(&ec2.DeregisterImageInput{ 263 | DryRun: &dryRun, 264 | ImageId: amis[i].ImageId, 265 | }) 266 | if err != nil { 267 | if ! strings.Contains(err.Error(), "DryRunOperation") { 268 | return err 269 | } 270 | } 271 | 272 | // Step 2: Delete the corresponding AMI snapshot 273 | // Look at the "description" for each Snapshot to see if it contains our AMI id 274 | var snapshotIds []string 275 | for _, snapshot := range snapshots { 276 | if strings.Contains(*snapshot.Description, *amis[i].ImageId) { 277 | snapshotIds = append(snapshotIds, *snapshot.SnapshotId) 278 | } 279 | } 280 | 281 | // Delete all snapshots that were found 282 | ui.Output(*amis[i].ImageId + ": Found " + strconv.Itoa(len(snapshotIds)) + " snapshot(s) to delete") 283 | for _, snapshotId := range snapshotIds { 284 | ui.Output(*amis[i].ImageId + ": Deleting snapshot " + snapshotId + "...") 285 | _, deleteErr := svc.DeleteSnapshot(&ec2.DeleteSnapshotInput{ 286 | DryRun: &dryRun, 287 | SnapshotId: &snapshotId, 288 | }) 289 | 290 | if deleteErr != nil { 291 | return deleteErr 292 | } 293 | } 294 | 295 | ui.Output(*amis[i].ImageId + ": Done!") 296 | ui.Output("") 297 | } 298 | 299 | return nil 300 | } 301 | 302 | // TODO: convert this to use Go's time.ParseDuration 303 | func parseOlderThanToHours(olderThan string) (float64, error) { 304 | var minutes float64 305 | var hours float64 306 | 307 | // Parse our date range 308 | match, _ := regexp.MatchString("^[0-9]*(h|d|m)$", olderThan) 309 | if ! match { 310 | return hours, errors.New("The --older-than value of \"" + olderThan + "\" is not formatted properly. Use formats like 30d or 24h") 311 | } 312 | 313 | // We were given a time like "12h" 314 | if match, _ := regexp.MatchString("^[0-9]*(h)$", olderThan); match { 315 | hours, _ = strconv.ParseFloat(olderThan[0:len(olderThan)-1], 64) 316 | } 317 | 318 | // We were given a time like "15d" 319 | if match, _ := regexp.MatchString("^[0-9]*(d)$", olderThan); match { 320 | hours, _ = strconv.ParseFloat(olderThan[0:len(olderThan)-1], 64) 321 | hours *= 24 322 | } 323 | 324 | // We were given a time like "5m" 325 | if match, _ := regexp.MatchString("^[0-9]*(m)$", olderThan); match { 326 | minutes, _ = strconv.ParseFloat(olderThan[0:len(olderThan)-1], 64) 327 | hours = minutes/60 328 | } 329 | 330 | return hours, nil 331 | } -------------------------------------------------------------------------------- /integration_create_delete_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/ec2" 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/mitchellh/cli" 9 | "os" 10 | "fmt" 11 | "log" 12 | "encoding/base64" 13 | "time" 14 | ) 15 | 16 | const AWS_REGION_FOR_TESTING = "us-east-1" 17 | const AMAZON_LINUX_AMI_ID = "ami-08111162" 18 | 19 | const TEST_FILE_PATH = "/home/ec2-user/test-file" 20 | const USER_DATA_TEMPLATE = 21 | `#!/bin/bash 22 | set -e 23 | echo '%s' > "%s" 24 | ` 25 | // An integration test that runs an EC2 instance, uses create_command to take a snapshot of it, and then delete_command 26 | // to delete that snapshot. 27 | func TestCreateAndDelete(t *testing.T) { 28 | t.Parallel() 29 | 30 | logger, ui := createLoggerAndUi("TestCreateAndDelete") 31 | session := session.New(&aws.Config{Region: aws.String(AWS_REGION_FOR_TESTING)}) 32 | svc := ec2.New(session) 33 | 34 | instance, instanceName := launchInstance(svc, logger, t) 35 | defer terminateInstance(instance, svc, logger, t) 36 | waitForInstanceToStart(instance, svc, logger, t) 37 | 38 | snapshotId := takeSnapshotWithVerification(instanceName, *instance.InstanceId, ui, svc, logger, t) 39 | deleteSnapshotWithVerification(instanceName, snapshotId, ui, svc, logger, t) 40 | } 41 | 42 | // An integration test that runs an EC2 instance, uses create_command to take a snapshot of it, and then calls the 43 | // delete_command to delete that snapshot, but setting the older than parmaeter in a way that should prevent any actual 44 | // deletion. 45 | func TestDeleteRespectsOlderThan(t *testing.T) { 46 | t.Parallel() 47 | 48 | logger, ui := createLoggerAndUi("TestDeleteRespectsOlderThan") 49 | session := session.New(&aws.Config{Region: aws.String(AWS_REGION_FOR_TESTING)}) 50 | svc := ec2.New(session) 51 | 52 | instance, instanceName := launchInstance(svc, logger, t) 53 | defer terminateInstance(instance, svc, logger, t) 54 | waitForInstanceToStart(instance, svc, logger, t) 55 | 56 | snapshotId := takeSnapshotWithVerification(instanceName, *instance.InstanceId, ui, svc, logger, t) 57 | // Always try to delete the snapshot at the end so the tests don't litter the AWS account with snapshots 58 | defer deleteSnapshotWithVerification(instanceName, snapshotId, ui, svc, logger, t) 59 | 60 | // Set olderThan to "10h" to ensure the snapshot, which is only a few seconds old, does not get deleted 61 | deleteSnapshotForInstance(instanceName, "10h", 0, ui, logger, t) 62 | waitForSnapshotToBeDeleted(snapshotId, svc, logger, t) 63 | verifySnapshotWorks(snapshotId, svc, logger, t) 64 | } 65 | 66 | // An integration test that runs an EC2 instance, uses create_command to take a snapshot of it, and then calls the 67 | // delete_command to delete that snapshot, but setting the at least parameter in a way that should prevent any actual 68 | // deletion. 69 | func TestDeleteRespectsAtLeast(t *testing.T) { 70 | t.Parallel() 71 | 72 | logger, ui := createLoggerAndUi("TestDeleteRespectsAtLeast") 73 | session := session.New(&aws.Config{Region: aws.String(AWS_REGION_FOR_TESTING)}) 74 | svc := ec2.New(session) 75 | 76 | instance, instanceName := launchInstance(svc, logger, t) 77 | defer terminateInstance(instance, svc, logger, t) 78 | waitForInstanceToStart(instance, svc, logger, t) 79 | 80 | snapshotId := takeSnapshotWithVerification(instanceName, *instance.InstanceId, ui, svc, logger, t) 81 | // Always try to delete the snapshot at the end so the tests don't litter the AWS account with snapshots 82 | defer deleteSnapshotWithVerification(instanceName, snapshotId, ui, svc, logger, t) 83 | 84 | // Set atLeast to 1 to ensure the snapshot, which is the only one that exists, does not get deleted 85 | deleteSnapshotForInstance(instanceName, "0h", 1, ui, logger, t) 86 | waitForSnapshotToBeDeleted(snapshotId, svc, logger, t) 87 | verifySnapshotWorks(snapshotId, svc, logger, t) 88 | } 89 | 90 | // An integration test that runs an EC2 instance, does not take any snapshots of it, and then calls the delete_command 91 | // to ensure that it exits gracefully even if no snapshots exist. 92 | func TestDeleteHandlesNoSnapshots(t *testing.T) { 93 | t.Parallel() 94 | 95 | logger, ui := createLoggerAndUi("TestDeleteHandlesNoSnapshots") 96 | session := session.New(&aws.Config{Region: aws.String(AWS_REGION_FOR_TESTING)}) 97 | svc := ec2.New(session) 98 | 99 | instance, instanceName := launchInstance(svc, logger, t) 100 | defer terminateInstance(instance, svc, logger, t) 101 | waitForInstanceToStart(instance, svc, logger, t) 102 | 103 | deleteSnapshotForInstance(instanceName, "0h", 0, ui, logger, t) 104 | } 105 | 106 | func TestCreateWithInvalidInstanceName(t *testing.T) { 107 | t.Parallel() 108 | 109 | _, ui := createLoggerAndUi("TestCreateWithInvalidInstanceName") 110 | cmd := CreateCommand{ 111 | Ui: ui, 112 | AwsRegion: AWS_REGION_FOR_TESTING, 113 | InstanceName: "not-a-valid-instance-name", 114 | AmiName: "this-ami-should-not-be-created", 115 | } 116 | 117 | _, err := create(cmd) 118 | 119 | if err == nil { 120 | t.Fatalf("Expected an error when creating a snapshot of an instance name that doesn't exist, but instead got nil") 121 | } 122 | } 123 | 124 | func TestCreateWithInvalidInstanceId(t *testing.T) { 125 | t.Parallel() 126 | 127 | _, ui := createLoggerAndUi("TestCreateWithInvalidInstanceId") 128 | cmd := CreateCommand{ 129 | Ui: ui, 130 | AwsRegion: AWS_REGION_FOR_TESTING, 131 | InstanceId: "not-a-valid-instance-id", 132 | AmiName: "this-ami-should-not-be-created", 133 | } 134 | 135 | _, err := create(cmd) 136 | 137 | if err == nil { 138 | t.Fatalf("Expected an error when creating a snapshot of an instance id that doesn't exist, but instead got nil") 139 | } 140 | } 141 | 142 | func TestDeleteWithInvalidInstanceName(t *testing.T) { 143 | t.Parallel() 144 | 145 | _, ui := createLoggerAndUi("TestDeleteWithInvalidInstanceName") 146 | cmd := DeleteCommand{ 147 | Ui: ui, 148 | AwsRegion: AWS_REGION_FOR_TESTING, 149 | InstanceName: "not-a-valid-instance-name", 150 | OlderThan: "0h", 151 | RequireAtLeast: 0, 152 | } 153 | 154 | err := deleteSnapshots(cmd) 155 | 156 | if err == nil { 157 | t.Fatalf("Expected an error when deleting a snapshot of an instance name that doesn't exist, but instead got nil") 158 | } 159 | } 160 | 161 | func TestDeleteWithInvalidInstanceId(t *testing.T) { 162 | t.Parallel() 163 | 164 | _, ui := createLoggerAndUi("TestDeleteWithInvalidInstanceId") 165 | cmd := DeleteCommand{ 166 | Ui: ui, 167 | AwsRegion: AWS_REGION_FOR_TESTING, 168 | InstanceId: "not-a-valid-instance-id", 169 | OlderThan: "0h", 170 | RequireAtLeast: 0, 171 | } 172 | 173 | err := deleteSnapshots(cmd) 174 | 175 | if err == nil { 176 | t.Fatalf("Expected an error when deleting a snapshot of an instance id that doesn't exist, but instead got nil") 177 | } 178 | } 179 | 180 | func launchInstance(svc *ec2.EC2, logger *log.Logger, t *testing.T) (*ec2.Instance, string) { 181 | instanceName := fmt.Sprintf("ec2-snapper-unit-test-%s", UniqueId()) 182 | userData := fmt.Sprint(USER_DATA_TEMPLATE, instanceName, TEST_FILE_PATH) 183 | 184 | logger.Printf("Launching EC2 instance in region %s. Its User Data will create a file %s with contents %s.", AWS_REGION_FOR_TESTING, TEST_FILE_PATH, instanceName) 185 | 186 | runResult, err := svc.RunInstances(&ec2.RunInstancesInput{ 187 | ImageId: aws.String(AMAZON_LINUX_AMI_ID), 188 | InstanceType: aws.String("t2.micro"), 189 | MinCount: aws.Int64(1), 190 | MaxCount: aws.Int64(1), 191 | UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(userData))), 192 | }) 193 | 194 | if err != nil { 195 | t.Fatal(err) 196 | } 197 | 198 | if len(runResult.Instances) != 1 { 199 | t.Fatalf("Expected to launch 1 instance but got %d", len(runResult.Instances)) 200 | } 201 | 202 | instance := runResult.Instances[0] 203 | logger.Printf("Launched instance %s", *instance.InstanceId) 204 | 205 | err = svc.WaitUntilInstanceExists(&ec2.DescribeInstancesInput{InstanceIds: []*string{instance.InstanceId}}) 206 | if err != nil { 207 | t.Fatal(err) 208 | } 209 | 210 | tagInstance(instance, instanceName, svc, logger, t) 211 | 212 | return instance, instanceName 213 | } 214 | 215 | func tagInstance(instance *ec2.Instance, instanceName string, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 216 | logger.Printf("Adding tags to instance %s", *instance.InstanceId) 217 | 218 | _ , err := svc.CreateTags(&ec2.CreateTagsInput{ 219 | Resources: []*string{instance.InstanceId}, 220 | Tags: []*ec2.Tag{ 221 | { 222 | Key: aws.String("Name"), 223 | Value: aws.String(instanceName), 224 | }, 225 | }, 226 | }) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | } 231 | 232 | func waitForInstanceToStart(instance *ec2.Instance, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 233 | logger.Printf("Waiting for instance %s to start...", *instance.InstanceId) 234 | 235 | if err := svc.WaitUntilInstanceRunning(&ec2.DescribeInstancesInput{InstanceIds: []*string{instance.InstanceId}}); err != nil { 236 | t.Fatal(err) 237 | } 238 | 239 | logger.Printf("Instance %s is now running", *instance.InstanceId) 240 | } 241 | 242 | func terminateInstance(instance *ec2.Instance, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 243 | logger.Printf("Terminating instance %s", *instance.InstanceId) 244 | if _, err := svc.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: []*string{instance.InstanceId}}); err != nil { 245 | t.Fatal("Failed to terminate instance %s", *instance.InstanceId) 246 | } 247 | } 248 | 249 | func takeSnapshot(instanceName string, ui cli.Ui, logger *log.Logger, t *testing.T) string { 250 | log.Printf("Creating a snapshot with name %s.", instanceName) 251 | 252 | cmd := CreateCommand{ 253 | Ui: ui, 254 | AwsRegion: AWS_REGION_FOR_TESTING, 255 | InstanceName: instanceName, 256 | AmiName: instanceName, 257 | } 258 | 259 | snapshotId, err := create(cmd) 260 | 261 | if err != nil { 262 | t.Fatal(err) 263 | } 264 | 265 | logger.Printf("Created snasphot %s", snapshotId) 266 | return snapshotId 267 | } 268 | 269 | func verifySnapshotWorks(snapshotId string, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 270 | logger.Printf("Verifying snapshot %s exists", snapshotId) 271 | 272 | snapshots := findSnapshots(snapshotId, svc, logger, t) 273 | if len(snapshots) != 1 { 274 | t.Fatalf("Expected to find one snapshot with id %s but found %d", snapshotId, len(snapshots)) 275 | } 276 | 277 | snapshot := snapshots[0] 278 | 279 | if *snapshot.State == ec2.ImageStateAvailable { 280 | logger.Printf("Found snapshot %s in expected state %s", snapshotId, *snapshot.State) 281 | } else { 282 | t.Fatalf("Expected image to be in state %s, but it was in state %s", ec2.ImageStateAvailable, *snapshot.State) 283 | } 284 | 285 | // TODO: fire up a new EC2 instance with the snapshot, SSH to it, and check the file we wrote is still there 286 | } 287 | 288 | func verifySnapshotIsDeleted(snapshotId string, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 289 | logger.Printf("Verifying snapshot %s is deleted", snapshotId) 290 | snapshots := findSnapshots(snapshotId, svc, logger, t) 291 | if len(snapshots) != 0 { 292 | t.Fatalf("Expected to find zero snapshots with id %s but found %d", snapshotId, len(snapshots)) 293 | } 294 | } 295 | 296 | func findSnapshots(snapshotId string, svc *ec2.EC2, logger *log.Logger, t *testing.T) []*ec2.Image { 297 | resp, err := svc.DescribeImages(&ec2.DescribeImagesInput{ImageIds: []*string{&snapshotId}}) 298 | if err != nil { 299 | t.Fatal(err) 300 | } 301 | 302 | return resp.Images 303 | } 304 | 305 | func waitForSnapshotToBeAvailable(instanceId string, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 306 | logger.Printf("Waiting for snapshot for instance %s to become available", instanceId) 307 | 308 | instanceIdTagFilter := &ec2.Filter{ 309 | Name: aws.String(fmt.Sprintf("tag:%s", EC2_SNAPPER_INSTANCE_ID_TAG)), 310 | Values: []*string{aws.String(instanceId)}, 311 | } 312 | 313 | if err := svc.WaitUntilImageAvailable(&ec2.DescribeImagesInput{Filters: []*ec2.Filter{instanceIdTagFilter}}); err != nil { 314 | t.Fatal(err) 315 | } 316 | } 317 | 318 | func waitForSnapshotToBeDeleted(snapshotId string, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 319 | logger.Printf("Waiting for snapshot %s to be deleted", snapshotId) 320 | 321 | // We just do a simple sleep, as there is no built-in API call to wait for this. 322 | time.Sleep(30 * time.Second) 323 | } 324 | 325 | func deleteSnapshotForInstance(instanceName string, olderThan string, requireAtLeast int, ui cli.Ui, logger *log.Logger, t *testing.T) { 326 | logger.Printf("Deleting snapshot for instance %s", instanceName) 327 | 328 | deleteCmd := DeleteCommand{ 329 | Ui: ui, 330 | AwsRegion: AWS_REGION_FOR_TESTING, 331 | InstanceName: instanceName, 332 | OlderThan: olderThan, 333 | RequireAtLeast: requireAtLeast, 334 | } 335 | 336 | if err := deleteSnapshots(deleteCmd); err != nil { 337 | t.Fatal(err) 338 | } 339 | } 340 | 341 | func takeSnapshotWithVerification(instanceName string, instanceId string, ui cli.Ui, svc *ec2.EC2, logger *log.Logger, t *testing.T) string { 342 | snapshotId := takeSnapshot(instanceName, ui, logger, t) 343 | 344 | waitForSnapshotToBeAvailable(instanceId, svc, logger, t) 345 | verifySnapshotWorks(snapshotId, svc, logger, t) 346 | 347 | return snapshotId 348 | } 349 | 350 | func deleteSnapshotWithVerification(instanceName string, snapshotId string, ui cli.Ui, svc *ec2.EC2, logger *log.Logger, t *testing.T) { 351 | deleteSnapshotForInstance(instanceName, "0h", 0, ui, logger, t) 352 | waitForSnapshotToBeDeleted(snapshotId, svc, logger, t) 353 | verifySnapshotIsDeleted(snapshotId, svc, logger, t) 354 | } 355 | 356 | func createLoggerAndUi(testName string) (*log.Logger, cli.Ui) { 357 | logger := log.New(os.Stdout, testName + " ", log.LstdFlags) 358 | 359 | basicUi := &cli.BasicUi{ 360 | Reader: os.Stdin, 361 | Writer: os.Stdout, 362 | ErrorWriter: os.Stderr, 363 | } 364 | 365 | prefixedUi := &cli.PrefixedUi{ 366 | AskPrefix: logger.Prefix(), 367 | AskSecretPrefix: logger.Prefix(), 368 | OutputPrefix: logger.Prefix(), 369 | InfoPrefix: logger.Prefix(), 370 | ErrorPrefix: logger.Prefix(), 371 | WarnPrefix: logger.Prefix(), 372 | Ui: basicUi, 373 | 374 | } 375 | 376 | return logger, prefixedUi 377 | } --------------------------------------------------------------------------------