From f42c43fc1c602dd95dc5ead672ef854734f5f2c0 Mon Sep 17 00:00:00 2001 From: Fabio Gollinucci Date: Thu, 4 Mar 2021 21:00:44 +0100 Subject: [PATCH] Initial commit --- .github/workflows/release.yml | 29 ++ .gitignore | 2 + .goreleaser.yml | 50 ++++ README.md | 345 +++++++++++++++++++++++ cmd/build/build.go | 110 ++++++++ cmd/deploy/deploy.go | 281 +++++++++++++++++++ cmd/logs/logs.go | 89 ++++++ cmd/remove/remove.go | 201 ++++++++++++++ cmd/results/results.go | 82 ++++++ cmd/start/start.go | 141 ++++++++++ cmd/stop/stop.go | 128 +++++++++ examples/nodejs/api/canary.yml | 19 ++ examples/nodejs/api/data.json | 4 + examples/nodejs/api/index.js | 67 +++++ examples/nodejs/aws/canary.yml | 21 ++ examples/nodejs/aws/index.js | 22 ++ examples/nodejs/deps/.gitignore | 1 + examples/nodejs/deps/canary.yml | 15 + examples/nodejs/deps/index.js | 20 ++ examples/nodejs/deps/package-lock.json | 13 + examples/nodejs/deps/package.json | 9 + examples/nodejs/parameters/canary.yml | 15 + examples/nodejs/parameters/index.js | 26 ++ examples/nodejs/simple/canary.yml | 17 ++ examples/nodejs/simple/index.js | 12 + examples/nodejs/web/canary.yml | 18 ++ examples/nodejs/web/index.js | 28 ++ examples/python/simple/canary.yml | 18 ++ examples/python/simple/script.py | 7 + examples/python/web/canary.yml | 20 ++ examples/python/web/script.py | 10 + go.mod | 11 + go.sum | 59 ++++ internal/aws/aws.go | 47 ++++ internal/bucket/bucket.go | 129 +++++++++ internal/canary/canary.go | 365 +++++++++++++++++++++++++ internal/canary/code.go | 171 ++++++++++++ internal/config/config.go | 39 +++ internal/config/input.go | 164 +++++++++++ internal/config/json.go | 25 ++ internal/config/loader.go | 167 +++++++++++ internal/config/yaml.go | 26 ++ internal/iam/iam.go | 9 + internal/iam/policy.go | 207 ++++++++++++++ internal/iam/role.go | 156 +++++++++++ main.go | 99 +++++++ package-lock.json | 3 + 47 files changed, 3497 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 README.md create mode 100644 cmd/build/build.go create mode 100644 cmd/deploy/deploy.go create mode 100644 cmd/logs/logs.go create mode 100644 cmd/remove/remove.go create mode 100644 cmd/results/results.go create mode 100644 cmd/start/start.go create mode 100644 cmd/stop/stop.go create mode 100644 examples/nodejs/api/canary.yml create mode 100644 examples/nodejs/api/data.json create mode 100644 examples/nodejs/api/index.js create mode 100644 examples/nodejs/aws/canary.yml create mode 100644 examples/nodejs/aws/index.js create mode 100644 examples/nodejs/deps/.gitignore create mode 100644 examples/nodejs/deps/canary.yml create mode 100644 examples/nodejs/deps/index.js create mode 100644 examples/nodejs/deps/package-lock.json create mode 100644 examples/nodejs/deps/package.json create mode 100644 examples/nodejs/parameters/canary.yml create mode 100644 examples/nodejs/parameters/index.js create mode 100644 examples/nodejs/simple/canary.yml create mode 100644 examples/nodejs/simple/index.js create mode 100644 examples/nodejs/web/canary.yml create mode 100644 examples/nodejs/web/index.js create mode 100644 examples/python/simple/canary.yml create mode 100644 examples/python/simple/script.py create mode 100644 examples/python/web/canary.yml create mode 100644 examples/python/web/script.py create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/aws/aws.go create mode 100644 internal/bucket/bucket.go create mode 100644 internal/canary/canary.go create mode 100644 internal/canary/code.go create mode 100644 internal/config/config.go create mode 100644 internal/config/input.go create mode 100644 internal/config/json.go create mode 100644 internal/config/loader.go create mode 100644 internal/config/yaml.go create mode 100644 internal/iam/iam.go create mode 100644 internal/iam/policy.go create mode 100644 internal/iam/role.go create mode 100644 main.go create mode 100644 package-lock.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..790b8d7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: 1.15.x + - name: Get version tag + id: get_version + run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} + - name: Set version + run: sed -i s/VERSION/${{ steps.get_version.outputs.VERSION }}/ main.go + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v1 + with: + version: latest + args: release --rm-dist --skip-validate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ec46b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +aws-canary diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..26fc935 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,50 @@ +release: + +builds: +- id: aws-canary + main: main.go + binary: aws-canary + goos: + - windows + - darwin + - linux + goarch: + - amd64 + env: + - CGO_ENABLED=0 + +archives: +- builds: + - aws-canary + replacements: + darwin: Darwin + linux: Linux + windows: Windows + amd64: x86_64 + format: tar.gz + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: 'checksums.txt' + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^examples:' + +nfpms: + - license: MIT + maintainer: Fabio Gollinucci + description: AWS Synthetics Canary CLI + homepage: https://github.com/daaru00/aws-canary-cli + suggests: + - nodejs + - python + formats: + - rpm + - deb diff --git a/README.md b/README.md new file mode 100644 index 0000000..387bf07 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# Why this project? + +Right now the only methods available to deploy AWS Canaries are: +- Using CloudFormation template and place the script code inline in a string inside a yml file... +- Using the web console (until the page is reloaded) and don't worry about code versioning, pipelines and those other complex things... + +This is a third method that allows you to version the code, deploy and manage canaries. +When you don't want them anymore use the remove and related resources will be also removed (like Lambda Function and Layer Versions). + +PS: Also tried [Serverless Components](https://github.com/daaru00/serverless-component-synthetics-canary) and I had some deployment problems. + +## Install CLI + +Download last archive package version from [releases page](https://github.com/daaru00/aws-canary-cli/releases): + +* Windows: aws-canary_VERSION_Windows_x86_64.zip +* Mac: aws-canary_VERSION_Darwin_x86_64.tar.gz +* Linux: aws-canary_VERSION_Linux_x86_64.tar.gz + +Unpack it and copy `aws-canary` into one of your executable paths, for example, for Mac and Linux users: +```bash +tar -czvf aws-canary_*.tar.gz +sudo mv aws-canary /usr/local/bin/aws-canary +rm aws-canary_*.tar.gz +``` + +### For Linux Users + +You can also install CLI from deb or rpm package downloading from releases page: + +* aws-canary_1.0.0_linux_amd64.deb +* aws-canary_1.0.0_linux_amd64.rpm + +### For Mac Users + +Unlock CLI executable file going to "System Preference > Security and Privacy > General" and click on button "open anyway". + +## Commands + +Usage: +```bash +./aws-canary [global options] command [command options] [arguments...] +``` + +- **deploy**: Deploy a Synthetics Canary +- **remove**: Remove a Synthetics Canary +- **start**: Start a Synthetics Canary +- **stop**: Stop a Synthetics Canary +- **logs**: Return Synthetics Canary Run logs +- **results**: Return Synthetics Canary Runs +- **help**: Shows a list of commands or help for one command + +## Environment configuration file + +This CLI also load environment variable from `.env` file in current working directory: +``` +AWS_PROFILE=my-profile +AWS_REGION=us-east-1 + +CANARY_ARTIFACT_BUCKET_NAME=my-bucket-bucket-name +``` + +Setting `CANARY_ENV` environment variable is it possible to load different env file: +```bash +export CANARY_ENV="" +aws-canary deploy # will load .env file +``` +```bash +export CANARY_ENV="prod" +aws-canary deploy # will load .env.prod file +``` +```bash +export CANARY_ENV="STAGE" +aws-canary deploy # will load .env.STAGE file +``` + +## Canary configuration file + +This CLI will search for `canary.yml` configurations files, recursively, in search path (provided via first argument of any commands) for configurations file and deploy/remove canaries in parallels. The canary configuration file looks like this: +```yaml +name: test # canary name +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +env: # canary environment variables + ENDPOINT: "https://example.com" + PAGE_LOAD_TIMEOUT: 15000 +role: my-role-name # use an existing, custom IAM role +policies: # policies statement to attach to IAM role. If the role property is set, this property is ignored. + - Effect: "Allow" + Action: + - "dynamodb:ListTables" + Resource: + - "*" +retention: + failure: 31 # retention for failure results, in days + success: 31 # retention for success results, in days +schedule: + duration: 0 # run only once when it starts, or regular run in period (in seconds) + expression: "rate(0 hour)" # run only manually with 0 value or rate(30 minutes) +tags: # canary tags + Project: test + Environment: test +``` + +### Search path + +Any command accept file or directory paths as arguments, any canary configuration file that match will be loaded an added to list. + +If a directory is provided the CLI will search recursively for files `canary.yml` (configurable via `--config-config-file`) +and try to parse them using YAML parser (configurable via `--config-config-parser`), for example: +```bash +aws-canary deploy examples/ +``` + +If a file is provided the CLI will be try to parse using YAML parser (configurable via `--config-config-parser`), for example: +```bash +aws-canary deploy examples/nodejs/simple/canary.yml +``` + +Search path can be multiple, every argument respect the rules mentioned above: +```bash +aws-canary deploy examples/nodejs/simple/canary.yml examples/nodejs/web/canary.yml examples/python/simple/canary.yml +# load 3 canaries from provided files + +aws-canary deploy examples/nodejs/ examples/python/simple/canary.yml +# load all canaries in nodejs directory and a single one from python + +aws-canary deploy examples/nodejs/ examples/python/ +# load all canaries from nodejs and python directories (all) +``` + +Also a file glob pattern can be used as search paths: +```bash +aws-canary deploy examples/**/simple/canary.yml +# load 2 canaries, one in nodejs directory and the other in the python one +``` + +### Custom policy + +Custom policy statement must respect a strict format: +``` +Effect: String +Action: Array of strings +Resource: Array of strings +Condition: + StringEquals: Map of string[string] +``` + +Policies entries defined in policies are merged with the default provided by the cli: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "xray:PutTraceSegments" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "ssm:GetParameter*", + ], + "Resource": [ + "arn:aws:ssm:us-east-1:1234567890:parameter/cwsyn/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "cloudwatch:PutMetricData" + ], + "Resource": [ + "*" + ], + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "CloudWatchSynthetics" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:CreateLogGroup" + ], + "Resource": [ + "arn:aws:logs:us-east-1:1234567890:log-group:/aws/lambda/cwsyn-*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetBucketLocation" + ], + "Resource": [ + "arn:aws:s3:::" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:ListAllMyBuckets" + ], + "Resource": [ + "*" + ] + } + ] +} +``` +It's the original AWS one with some additions: +- SSM parameters read-only access for paths that starts with `/cwsyn/`. + +In configuration file it is possible to interpolate environment variables using `${var}` or `$var` syntax: +```yaml +name: test +env: + ENDPOINT: "${ENDPOINT_FROM_ENV}" +tags: + Project: "${APP_NAME}" + Environment: "${ENV}" +``` + +Here an example of project configuration with single canary: +```bash +. +└── canary +    ├── canary.yml +    └── index.js +``` + +Here an example of project configuration with multiple canaries: +```bash +. +└── canaries + ├── cart + │   ├── canary.yml + │   └── index.js + ├── home + │   ├── canary.yml + │   └── index.js + └── login + ├── canary.yml + └── index.js +``` + +## Build canaries code + +An command `build` is provided in order to install dependencies for canaries that need to, so this command is not required if you don't use npm or pip dependencies. + +Build code (install dependencies) +```bash +aws-canary build +``` + +Adding `--output` flag the build process wil print the output at the end of command: +```bash +aws-canary build --output +``` +will print an output similar to this: +``` +[test-js-deps] Output: +audited 1 package in 1.087s +found 0 vulnerabilities +``` + +If there are no `package.json` or `requirements.txt` files in canary directory, no commands will run. + +## Deploy canaries + +To deploy canaries run the `deploy` command: +```bash +aws-canary deploy +``` + +Adding `--yes` flag the deploy process wil automatically create artifact bucket required for canary execution: +```bash +aws-canary deploy --artifact-bucket my-bucket-bucket-name --yes +``` + +## Start canaries (manually execution) + +To state canaries manually run the `start` command: +```bash +aws-canary start +``` + +## Stop canaries (manually execution) + +To state canaries manually run the `stop` command: +```bash +aws-canary stop +``` + +## Retrieve canaries logs + +To retrieve canary runs' logs run the `logs` command: +```bash +aws-canary logs +``` + +To retrieve last canary run's logs run the `logs` command with `--last` flag: +```bash +aws-canary logs --last +``` + +## Retrieve canaries results + +To retrieve canary runs' results run the `results` command: +```bash +aws-canary results +``` + +To retrieve last canary run's results run the `results` command with `--last` flag: +```bash +aws-canary results --last +``` + +## Remove canaries + +To remove (only) canaries run the `remove` command: +```bash +aws-canary remove +``` +The related Lambda function and Layer Versions (with name that starts with "cwsyn-") are also cleaned + +In order to also remove artifact bucket with canaries run the `remove` command with `--bucket` flag: +aws-canary remove --bucket --artifact-bucket my-bucket-bucket-name --yes diff --git a/cmd/build/build.go b/cmd/build/build.go new file mode 100644 index 0000000..250e1af --- /dev/null +++ b/cmd/build/build.go @@ -0,0 +1,110 @@ +package build + +import ( + "fmt" + "sync" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return deploy commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "build", + Usage: "Build Synthetics Canary code", + Flags: append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Select all canaries", + }, + &cli.BoolFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Print build command output", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canaries, err = config.AskMultipleCanariesSelection(c, *canaries) + if err != nil { + return err + } + + // Setup wait group for async jobs + var waitGroup sync.WaitGroup + waitGroup.Add(len(*canaries)) + + // Setup deploy chan error + errs := make(chan error) + + // Loop over found canaries + for _, cy := range *canaries { + + // Execute parallel deploy + go func(canary *canary.Canary) { + output, err := SingleCanary(ses, canary) + + // Check output flag + if c.Bool("output") && len(*output) > 0 { + fmt.Println(fmt.Sprintf("[%s] Output: \n%s", canary.Name, *output)) + } + waitGroup.Done() + + errs <- err + close(errs) + }(cy) + } + + // Wait until all remove ends + waitGroup.Wait() + + // Check remove error + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +// SingleCanary build single canary code +func SingleCanary(ses *session.Session, canary *canary.Canary) (*string, error) { + var err error + var output string + + // Install code dependencies + if canary.IsPythonRuntime() { + fmt.Println(fmt.Sprintf("[%s] Installing pip dependencies..", canary.Name)) + output, err = canary.Code.InstallPipDependencies() + } else if canary.IsNodeRuntime() { + fmt.Println(fmt.Sprintf("[%s] Installing npm dependencies..", canary.Name)) + output, err = canary.Code.InstallNpmDependencies() + } + if err != nil { + return &output, err + } + + fmt.Println(fmt.Sprintf("[%s] Dependencies installed!", canary.Name)) + return &output, nil +} diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..c87d111 --- /dev/null +++ b/cmd/deploy/deploy.go @@ -0,0 +1,281 @@ +package deploy + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/cmd/build" + "github.com/daaru00/aws-canary-cli/cmd/start" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/bucket" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/daaru00/aws-canary-cli/internal/iam" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return deploy commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "deploy", + Usage: "Deploy a Synthetics Canary", + Flags: append(globalFlags, []cli.Flag{ + &cli.StringFlag{ + Name: "artifact-bucket", + Usage: "Then Artifact bucket name", + EnvVars: []string{"CANARY_ARTIFACT_BUCKET", "CANARY_ARTIFACT_BUCKET_NAME"}, + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Answer yes for all confirmations", + }, + &cli.BoolFlag{ + Name: "start", + Aliases: []string{"s"}, + Usage: "Start canary after deploy", + }, + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Select all canaries", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canaries, err = config.AskMultipleCanariesSelection(c, *canaries) + if err != nil { + return err + } + + // Get caller infos + accountID := aws.GetCallerAccountID(ses) + region := aws.GetCallerRegion(ses) + if accountID == nil { + return errors.New("No valid AWS credentials found") + } + + // Deploy artifact bucket + artifactBucketName := c.String("artifact-bucket") + if len(artifactBucketName) == 0 { + artifactBucketName = fmt.Sprintf("cw-syn-results-%s-%s", *accountID, *region) + } + artifactBucket, err := deployArtifactBucket(ses, &artifactBucketName) + if err != nil { + return err + } + + // Setup wait group for async jobs + var waitGroup sync.WaitGroup + waitGroup.Add(len(*canaries)) + + // Setup deploy chan error + errs := make(chan error) + + // Loop over found canaries + for _, cy := range *canaries { + + // Execute parallel deploy + go func(canary *canary.Canary) { + err := deploySingleCanary(ses, region, accountID, canary, artifactBucket) + if err == nil && c.Bool("start") { + err = start.SingleCanary(canary) + } + waitGroup.Done() + + errs <- err + close(errs) + }(cy) + } + + // Wait until all remove ends + waitGroup.Wait() + + // Check remove error + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +func deployArtifactBucket(ses *session.Session, artifactBucketName *string) (*bucket.Bucket, error) { + fmt.Println(fmt.Sprintf("Checking artifact bucket..")) + + // Check artifact bucket + artifactBucket := bucket.New(ses, artifactBucketName) + if artifactBucket.IsDeployed() == false { + // Ask for deploy + confirm := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Artifact bucket %s not found, do you want to deploy it now?", *artifactBucketName), + } + survey.AskOne(prompt, &confirm) + + // Check respose + if confirm == false { + return nil, fmt.Errorf("Artifact bucket %s not found", *artifactBucketName) + } + + // Deploy artifact bucket + fmt.Println(fmt.Sprintf("Deploying artifact bucket %s..", *artifactBucketName)) + err := artifactBucket.Deploy() + if err != nil { + return artifactBucket, err + } + } + + return artifactBucket, nil +} + +func deployIamRole(ses *session.Session, roleName *string, policy *iam.Policy) (*iam.Role, error) { + // Prepare role + role := iam.NewRole(ses, roleName) + role.SetInlinePolicy(policy) + + // Deploy role + err := role.Deploy() + if err != nil { + return role, err + } + + return role, nil +} + +func buildIamPolicy(ses *session.Session, policyName *string, artifactBucket *bucket.Bucket, policyStatements *[]iam.StatementEntry, region *string, accountID *string) (*iam.Policy, error) { + // Build policy + policy := iam.NewPolicy(ses, policyName) + policy.AddArtifactBucketPermission(artifactBucket) + policy.AddLogPermission(region, accountID) + policy.AddMetricsPermission() + policy.AddSSMParamersPermission(region, accountID) + policy.AddXRayPermission() + + // Add custom policy statements + for _, statement := range *policyStatements { + policy.AddStatement(statement) + } + + // Return policy + return policy, nil +} + +func deploySingleCanary(ses *session.Session, region *string, accountID *string, canary *canary.Canary, artifactBucket *bucket.Bucket) error { + var err error + var role *iam.Role + + // Check provided role + if len(canary.RoleName) > 0 { + role = iam.NewRole(ses, &canary.RoleName) + } else { + + // Deploy iam policy + fmt.Println(fmt.Sprintf("[%s] Build policy..", canary.Name)) + policyName := fmt.Sprintf("CloudWatchSyntheticsPolicy-%s-%s", *region, canary.Name) + policy, err := buildIamPolicy(ses, &policyName, artifactBucket, &canary.PolicyStatements, region, accountID) + if err != nil { + return err + } + + // Deploy iam role + fmt.Println(fmt.Sprintf("[%s] Deploying role..", canary.Name)) + roleName := fmt.Sprintf("CloudWatchSyntheticsRole-%s-%s", *region, canary.Name) + role, err = deployIamRole(ses, &roleName, policy) + if err != nil { + return err + } + } + + // Elaborate path prefix + codePathPrefix := "" + if canary.IsPythonRuntime() { + codePathPrefix = "python" + } else if canary.IsNodeRuntime() { + codePathPrefix = "nodejs/node_modules" + } + + // Install code dependencies + _, err = build.SingleCanary(ses, canary) + if err != nil { + return err + } + + // Prepare canary code + fmt.Println(fmt.Sprintf("[%s] Preparing code..", canary.Name)) + err = canary.Code.CreateArchive(&canary.Name, &codePathPrefix) + if err != nil { + return err + } + + // Clean archive at the end of deploy + defer cleanTemporaryResources(canary) + + // // Upload canary code + // fmt.Println(fmt.Sprintf("[%s] Upload code..", canary.Name)) + // err = canary.Code.Upload(artifactBucket.Name) + // if err != nil { + // return err + // } + + // Deploy canary + fmt.Println(fmt.Sprintf("[%s] Deploying..", canary.Name)) + artifactBucketLocation := *artifactBucket.Location + "/canary/" + canary.Name + err = canary.Deploy(role, &artifactBucketLocation) + if err != nil { + return err + } + + // Wait until canary is created + var status *synthetics.CanaryStatus + fmt.Println(fmt.Sprintf("[%s] Waiting..", canary.Name)) + for { + time.Sleep(1000 * time.Millisecond) + + // Get canary status + status, err = canary.GetStatus() + if err != nil { + return err + } + + // Check canary state + if *status.State != "CREATING" && *status.State != "UPDATING" { + break + } + } + + // Check for deploy error + if *status.State == "ERROR" { + return fmt.Errorf("[%s] Error: %s", canary.Name, *status.StateReason) + } + + fmt.Println(fmt.Sprintf("[%s] Deploy completed!", canary.Name)) + return nil +} + +func cleanTemporaryResources(canary *canary.Canary) { + // Clean temporary resources + fmt.Println(fmt.Sprintf("[%s] Cleaning temporary resources..", canary.Name)) + canary.Code.DeleteArchive() +} diff --git a/cmd/logs/logs.go b/cmd/logs/logs.go new file mode 100644 index 0000000..0835ec9 --- /dev/null +++ b/cmd/logs/logs.go @@ -0,0 +1,89 @@ +package logs + +import ( + "errors" + "fmt" + + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return start commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "logs", + Usage: "Return Synthetics Canary Run logs", + Flags: append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "last", + Aliases: []string{"l"}, + Usage: "Automatically select last canary run", + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "Filter canary name", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canary, err := config.AskSingleCanarySelection(c, *canaries) + if err != nil { + return err + } + + // Check if deployed + if canary.IsDeployed() == false { + return fmt.Errorf("Canary %s not yet deployed", canary.Name) + } + + // Retrieve runs + runs, err := canary.GetRuns() + if err != nil { + return err + } + + // Check runs + if len(runs) == 0 { + return errors.New("No runs found for canary") + } + + // Ask use to select run + var run *synthetics.CanaryRun + if c.Bool("last") { + run = runs[0] + } else { + run, err = config.AskSingleCanaryRun(runs) + if err != nil { + return err + } + } + + // Get run log + logs, err := canary.GetRunLogs(run) + if err != nil { + return err + } + + // Print logs + fmt.Println(*logs) + + return nil +} diff --git a/cmd/remove/remove.go b/cmd/remove/remove.go new file mode 100644 index 0000000..ae934bd --- /dev/null +++ b/cmd/remove/remove.go @@ -0,0 +1,201 @@ +package remove + +import ( + "errors" + "fmt" + "sync" + + "github.com/AlecAivazis/survey/v2" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/daaru00/aws-canary-cli/cmd/stop" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/bucket" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/daaru00/aws-canary-cli/internal/iam" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return remove commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "remove", + Aliases: []string{"delete"}, + Usage: "Remove a Synthetics Canary", + Flags: append(globalFlags, []cli.Flag{ + &cli.StringFlag{ + Name: "artifact-bucket", + Usage: "The Artifact bucket name", + EnvVars: []string{"CANARY_ARTIFACT_BUCKET", "CANARY_ARTIFACT_BUCKET_NAME"}, + }, + &cli.BoolFlag{ + Name: "bucket", + Aliases: []string{"b"}, + Usage: "Remove also artifact bucket", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Answer yes for all confirmations", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canaries, err = config.AskMultipleCanariesSelection(c, *canaries) + if err != nil { + return err + } + + // Ask confirmation + if c.Bool("yes") == false { + confirm := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Are you sure you want to remove %d canaries?", len(*canaries)), + } + survey.AskOne(prompt, &confirm) + + // Check respose + if confirm == false { + return errors.New("Not confirmed canaries remove, skip operation") + } + } + + // Get caller infos + accountID := aws.GetCallerAccountID(ses) + region := aws.GetCallerRegion(ses) + if accountID == nil { + return errors.New("No valid AWS credentials found") + } + + // Remove artifact bucket + if c.Bool("bucket") { + artifactBucketName := c.String("artifact-bucket") + if len(artifactBucketName) == 0 { + artifactBucketName = fmt.Sprintf("canary-artifact-%s-%s", *accountID, *region) + } + err = removeArtifactBucket(ses, &artifactBucketName) + if err != nil { + return err + } + } + + // Setup wait group for async jobs + var waitGroup sync.WaitGroup + waitGroup.Add(len(*canaries)) + + // Setup deploy chan error + errs := make(chan error) + + // Loop over found canaries + for _, cy := range *canaries { + + // Execute parallel deploy + go func(canary *canary.Canary) { + var err error + + if canary.IsDeployed() { + err = stop.SingleCanary(canary) + } + + if err == nil { + err = removeSingleCanary(ses, canary, region) + } + + waitGroup.Done() + + errs <- err + close(errs) + }(cy) + } + + // Wait until all remove ends + waitGroup.Wait() + + // Check remove error + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +func removeArtifactBucket(ses *session.Session, artifactBucketName *string) error { + artifactBucket := bucket.New(ses, artifactBucketName) + + // Empty bucket + fmt.Println(fmt.Sprintf("Empty artifact bucket..")) + err := artifactBucket.Empty() + if err != nil { + return err + } + + // Remove artifact bucket + fmt.Println(fmt.Sprintf("Removing artifact bucket..")) + err = artifactBucket.Remove() + if err != nil { + return err + } + + return nil +} + +func removeIamRole(ses *session.Session, canary *canary.Canary, roleName *string, policyName *string) error { + role := iam.NewRole(ses, roleName) + policy := iam.NewPolicy(ses, policyName) + role.SetInlinePolicy(policy) + + // Check if role is deployed + if role.IsDeployed() == false { + return nil + } + + // Remove role + fmt.Println(fmt.Sprintf("[%s] Removing role..", canary.Name)) + err := role.Remove() + if err != nil { + return err + } + + return nil +} + +func removeSingleCanary(ses *session.Session, canary *canary.Canary, region *string) error { + var err error + + if canary.IsDeployed() { + // Remove canary + fmt.Println(fmt.Sprintf("[%s] Removing..", canary.Name)) + err = canary.Remove() + if err != nil { + return err + } + } + + // Remove role + roleName := fmt.Sprintf("CloudWatchSyntheticsRole-%s-%s", *region, canary.Name) + policyName := fmt.Sprintf("CloudWatchSyntheticsPolicy-%s-%s", *region, canary.Name) + err = removeIamRole(ses, canary, &roleName, &policyName) + if err != nil { + return err + } + + fmt.Println(fmt.Sprintf("[%s] Remove completed!", canary.Name)) + return nil +} diff --git a/cmd/results/results.go b/cmd/results/results.go new file mode 100644 index 0000000..b7ca637 --- /dev/null +++ b/cmd/results/results.go @@ -0,0 +1,82 @@ +package results + +import ( + "errors" + "fmt" + + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return start commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "results", + Usage: "Return Synthetics Canary Runs", + Flags: append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "last", + Aliases: []string{"l"}, + Usage: "Return details about last canary run", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canary, err := config.AskSingleCanarySelection(c, *canaries) + if err != nil { + return err + } + + // Check if deployed + if canary.IsDeployed() == false { + return fmt.Errorf("Canary %s not yet deployed", canary.Name) + } + + // Retrieve runs + runs, err := canary.GetRuns() + if err != nil { + return err + } + + // Check runs + if len(runs) == 0 { + return errors.New("No run found for canary") + } + + // Return last detail + if c.Bool("last") { + fmt.Println(fmt.Sprintf("Id: %s", *runs[0].Id)) + fmt.Println(fmt.Sprintf("Status: %s", *runs[0].Status.State)) + reason := *runs[0].Status.StateReason + if len(reason) > 0 { + fmt.Println(fmt.Sprintf("Status Reason: %s", reason)) + } + fmt.Println(fmt.Sprintf("Started At: %s", *runs[0].Timeline.Started)) + fmt.Println(fmt.Sprintf("Compleated At: %s", *runs[0].Timeline.Completed)) + return nil + } + + // Print results + fmt.Println(fmt.Sprintf("%-36s\t%-7s\t%-25s\t%-25s", "Id", "Status", "Started At", "Compleated At")) + for _, run := range runs { + fmt.Println(fmt.Sprintf("%-36s\t%-7s\t%-25s\t%-25s", *run.Id, *run.Status.State, *run.Timeline.Started, *run.Timeline.Completed)) + } + + return nil +} diff --git a/cmd/start/start.go b/cmd/start/start.go new file mode 100644 index 0000000..7a408ff --- /dev/null +++ b/cmd/start/start.go @@ -0,0 +1,141 @@ +package start + +import ( + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return start commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "start", + Usage: "Start a Synthetics Canary", + Flags: append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Select all canaries", + }, + }...), + Action: Action, + ArgsUsage: "[path...]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canaries, err = config.AskMultipleCanariesSelection(c, *canaries) + if err != nil { + return err + } + + // Setup wait group for async jobs + var waitGroup sync.WaitGroup + waitGroup.Add(len(*canaries)) + + // Setup deploy chan error + errs := make(chan error) + + // Loop over found canaries + for _, c := range *canaries { + + // Execute parallel deploy + go func(c *canary.Canary) { + err := SingleCanary(c) + waitGroup.Done() + + errs <- err + close(errs) + }(c) + } + + // Wait until all remove ends + waitGroup.Wait() + + // Check remove error + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +// SingleCanary start single canary +func SingleCanary(canary *canary.Canary) error { + // Check if deployed + if canary.IsDeployed() == false { + return fmt.Errorf("[%s] Error: not yet deployed", canary.Name) + } + + // Get canary status + currentStatus, err := canary.GetStatus() + if err != nil { + return err + } + + // Check if already stopped or never started + if *currentStatus.State == "RUNNING" { + fmt.Println(fmt.Sprintf("[%s] Skipped: not in a startable state %s", canary.Name, *currentStatus.State)) + return nil + } + + // Start canary + fmt.Println(fmt.Sprintf("[%s] Starting..", canary.Name)) + err = canary.Start() + if err != nil { + return err + } + + // Wait until canary ends + fmt.Println(fmt.Sprintf("[%s] Waiting..", canary.Name)) + var status *synthetics.CanaryStatus + for { + time.Sleep(1000 * time.Millisecond) + + // Get canary status + status, err = canary.GetStatus() + if err != nil { + return err + } + + // Check canary state + if status != nil && *status.State != "RUNNING" { + time.Sleep(2000 * time.Millisecond) + break + } + } + + // Get last run + run, err := canary.GetLastRun() + if err != nil { + return nil + } + + // Check for run error + if *run.Status.State == "FAILED" { + return fmt.Errorf("[%s] Fail: %s", canary.Name, *run.Status.StateReason) + } + + fmt.Println(fmt.Sprintf("[%s] Passed!", canary.Name)) + + return nil +} diff --git a/cmd/stop/stop.go b/cmd/stop/stop.go new file mode 100644 index 0000000..93e1513 --- /dev/null +++ b/cmd/stop/stop.go @@ -0,0 +1,128 @@ +package stop + +import ( + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +// NewCommand - Return stop commands +func NewCommand(globalFlags []cli.Flag) *cli.Command { + return &cli.Command{ + Name: "stop", + Usage: "Stop a Synthetics Canary", + Flags: append(globalFlags, []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "Select all canaries", + }, + }...), + Action: Action, + ArgsUsage: "[path..]", + } +} + +// Action contain the command flow +func Action(c *cli.Context) error { + // Create AWS session + ses := aws.NewAwsSession(c) + + // Get canaries + canaries, err := config.LoadCanaries(c, ses) + if err != nil { + return err + } + + // Ask canaries selection + canaries, err = config.AskMultipleCanariesSelection(c, *canaries) + if err != nil { + return err + } + + // Setup wait group for async jobs + var waitGroup sync.WaitGroup + waitGroup.Add(len(*canaries)) + + // Setup deploy chan error + errs := make(chan error) + + // Loop over found canaries + for _, c := range *canaries { + + // Execute parallel deploy + go func(c *canary.Canary) { + err := SingleCanary(c) + waitGroup.Done() + + errs <- err + close(errs) + }(c) + } + + // Wait until all remove ends + waitGroup.Wait() + + // Check remove error + for err := range errs { + if err != nil { + return err + } + } + + return nil +} + +// SingleCanary stop single canary +func SingleCanary(canary *canary.Canary) error { + // Check if deployed + if canary.IsDeployed() == false { + return fmt.Errorf("[%s] Error: not yet deployed", canary.Name) + } + + // Get canary status + currentStatus, err := canary.GetStatus() + if err != nil { + return err + } + + // Check if already stopped or never started + if *currentStatus.State == "STOPPED" || *currentStatus.State == "READY" || *currentStatus.State == "ERROR" { + fmt.Println(fmt.Sprintf("[%s] Stop Skipped: not in a stoppable state %s", canary.Name, *currentStatus.State)) + return nil + } + + // Stop canary + fmt.Println(fmt.Sprintf("[%s] Stopping..", canary.Name)) + err = canary.Stop() + if err != nil { + return err + } + + // Wait until canary stop + var status *synthetics.CanaryStatus + fmt.Println(fmt.Sprintf("[%s] Waiting..", canary.Name)) + for { + time.Sleep(1000 * time.Millisecond) + + // Get canary status + status, err = canary.GetStatus() + if err != nil { + return err + } + + // Check canary state + if *status.State == "STOPPED" { + break + } + } + + fmt.Println(fmt.Sprintf("[%s] Stopped!", canary.Name)) + return nil +} diff --git a/examples/nodejs/api/canary.yml b/examples/nodejs/api/canary.yml new file mode 100644 index 0000000..5736c98 --- /dev/null +++ b/examples/nodejs/api/canary.yml @@ -0,0 +1,19 @@ +name: "test-js-api" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +env: + ENDPOINT: "https://dummyapi.io/data/api/user?limit=10" + RESPONSE_TIMEOUT: 5000 + API_KEY: "${API_KEY}" +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "API" diff --git a/examples/nodejs/api/data.json b/examples/nodejs/api/data.json new file mode 100644 index 0000000..3b51594 --- /dev/null +++ b/examples/nodejs/api/data.json @@ -0,0 +1,4 @@ +{ + "foo": "", + "bar": 0 +} diff --git a/examples/nodejs/api/index.js b/examples/nodejs/api/index.js new file mode 100644 index 0000000..26c6ba2 --- /dev/null +++ b/examples/nodejs/api/index.js @@ -0,0 +1,67 @@ +const log = require('SyntheticsLogger') +const https = require('https') +const url = new URL(process.env.ENDPOINT) + +const request = async (options, data) => { + options = options || {} + + return new Promise((resolve, reject) => { + const req = https.request({ + host: url.host, + username: url.username, + password: url.password, + path: url.pathname, + search: url.search, + ...options + }, (res) => { + let data = ''; + res.on('data', function (chunk) { + data += chunk; + }); + res.on('error', function (err) { + reject(err) + }); + res.on('timeout', function () { + reject(new Error('RequestTimeout')) + }); + res.on('end', function () { + resolve({ + headers: res.headers, + statusCode: res.statusCode, + data + }); + }); + }) + + if (data) { + req.write(data); + } + + req.setTimeout(parseInt(process.env.RESPONSE_TIMEOUT)) + req.end(); + }) +} + +const basicCustomEntryPoint = async function () { + try { + let res = await request({ + headers: { + 'app-id': process.env.API_KEY, + 'x-api-key': process.env.API_KEY + } + }) + log.info('API response: ' + JSON.stringify(res)) + if (res.statusCode !== 200) { + throw res.data + } + } catch (err) { + log.error('API error: ' + JSON.stringify(err), err.stack) + throw err + } + + return `Successfully completed ${process.env.ENDPOINT} API checks.` +} + +exports.handler = async () => { + return await basicCustomEntryPoint() +} diff --git a/examples/nodejs/aws/canary.yml b/examples/nodejs/aws/canary.yml new file mode 100644 index 0000000..d14c2ff --- /dev/null +++ b/examples/nodejs/aws/canary.yml @@ -0,0 +1,21 @@ +name: "test-js-aws" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +policies: + - Effect: "Allow" + Action: + - "dynamodb:ListTables" + Resource: + - "*" +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "AWS" diff --git a/examples/nodejs/aws/index.js b/examples/nodejs/aws/index.js new file mode 100644 index 0000000..1684907 --- /dev/null +++ b/examples/nodejs/aws/index.js @@ -0,0 +1,22 @@ +const log = require('SyntheticsLogger') +const AWS = require('aws-sdk') + +const basicCustomEntryPoint = async function () { + log.info('Starting DynamoDB:listTables canary.') + + const dynamodb = new AWS.DynamoDB() + const request = await dynamodb.listTables() + try { + const response = await request.promise() + log.info('listTables response: ' + JSON.stringify(response)) + } catch (err) { + log.error('listTables error: ' + JSON.stringify(err), err.stack) + throw err + } + + return 'Successfully completed DynamoDB:listTables canary.' +} + +exports.handler = async () => { + return await basicCustomEntryPoint() +} diff --git a/examples/nodejs/deps/.gitignore b/examples/nodejs/deps/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/examples/nodejs/deps/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/examples/nodejs/deps/canary.yml b/examples/nodejs/deps/canary.yml new file mode 100644 index 0000000..d3725fe --- /dev/null +++ b/examples/nodejs/deps/canary.yml @@ -0,0 +1,15 @@ +name: "test-js-deps" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "Dependencies" diff --git a/examples/nodejs/deps/index.js b/examples/nodejs/deps/index.js new file mode 100644 index 0000000..d073d9d --- /dev/null +++ b/examples/nodejs/deps/index.js @@ -0,0 +1,20 @@ +const basicCustomEntryPoint = async function () { + let str = "" + + try { + const { v4: uuid } = require('uuid') + str = uuid() + } catch (error) { + throw `Failed uuid string generation: ${error}` + } + + if (str.length === 0) { + throw `Failed uuid string generation: string empty` + } + + return `Successfully created uuid string: ${str}` +} + +exports.handler = async () => { + return await basicCustomEntryPoint() +} diff --git a/examples/nodejs/deps/package-lock.json b/examples/nodejs/deps/package-lock.json new file mode 100644 index 0000000..2a0f994 --- /dev/null +++ b/examples/nodejs/deps/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "deps", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } +} diff --git a/examples/nodejs/deps/package.json b/examples/nodejs/deps/package.json new file mode 100644 index 0000000..98c631c --- /dev/null +++ b/examples/nodejs/deps/package.json @@ -0,0 +1,9 @@ +{ + "name": "deps", + "version": "1.0.0", + "private": true, + "main": "index.js", + "dependencies": { + "uuid": "^8.3.2" + } +} diff --git a/examples/nodejs/parameters/canary.yml b/examples/nodejs/parameters/canary.yml new file mode 100644 index 0000000..9354c03 --- /dev/null +++ b/examples/nodejs/parameters/canary.yml @@ -0,0 +1,15 @@ +name: "test-js-parameters" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "Parameters" diff --git a/examples/nodejs/parameters/index.js b/examples/nodejs/parameters/index.js new file mode 100644 index 0000000..943ed13 --- /dev/null +++ b/examples/nodejs/parameters/index.js @@ -0,0 +1,26 @@ +const log = require('SyntheticsLogger') +const AWS = require('aws-sdk') + +const basicCustomEntryPoint = async function () { + log.info('Starting SSM:GetParametersByPath canary.') + + const ssm = new AWS.SSM() + const params = { + Path: '/cwsyn/', + Recursive: true + } + const request = await ssm.getParametersByPath(params) + try { + const response = await request.promise() + log.info('getParametersByPath response: ' + JSON.stringify(response)) + } catch (err) { + log.error('getParametersByPath error: ' + JSON.stringify(err), err.stack) + throw err + } + + return 'Successfully completed SSM:GetParametersByPath canary.' +} + +exports.handler = async () => { + return await basicCustomEntryPoint() +} diff --git a/examples/nodejs/simple/canary.yml b/examples/nodejs/simple/canary.yml new file mode 100644 index 0000000..8ef5e6c --- /dev/null +++ b/examples/nodejs/simple/canary.yml @@ -0,0 +1,17 @@ +name: "test-js-simple" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +env: + TEST_NAME: "example" +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "Simple" diff --git a/examples/nodejs/simple/index.js b/examples/nodejs/simple/index.js new file mode 100644 index 0000000..f0d48f2 --- /dev/null +++ b/examples/nodejs/simple/index.js @@ -0,0 +1,12 @@ +const basicCustomEntryPoint = async function () { + let fail = false + if (fail) { + throw `Failed ${process.env.TEST_NAME} check.` + } + + return `Successfully completed ${process.env.TEST_NAME} checks.` +} + +exports.handler = async () => { + return await basicCustomEntryPoint() +} diff --git a/examples/nodejs/web/canary.yml b/examples/nodejs/web/canary.yml new file mode 100644 index 0000000..80a7ec1 --- /dev/null +++ b/examples/nodejs/web/canary.yml @@ -0,0 +1,18 @@ +name: "test-js-web" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +env: + ENDPOINT: "https://example.com/" + PAGE_LOAD_TIMEOUT: 15000 +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "NodeJS" + Test: "Web" diff --git a/examples/nodejs/web/index.js b/examples/nodejs/web/index.js new file mode 100644 index 0000000..c2c19fe --- /dev/null +++ b/examples/nodejs/web/index.js @@ -0,0 +1,28 @@ +var synthetics = require('Synthetics') +const log = require('SyntheticsLogger') + +const pageLoadBlueprint = async function () { + let page = await synthetics.getPage() + + const response = await page.goto(process.env.ENDPOINT, { + waitUntil: 'domcontentloaded', + timeout: process.env.PAGE_LOAD_TIMEOUT + }) + if (!response) { + throw 'Failed to load page!' + } + + await page.waitFor(15000) + await synthetics.takeScreenshot('loaded', 'loaded') + + let pageTitle = await page.title() + log.info('Page title: ' + pageTitle) + + if (response.status() < 200 || response.status() > 299) { + throw 'Failed to load page!' + } +} + +exports.handler = async () => { + return await pageLoadBlueprint() +} diff --git a/examples/python/simple/canary.yml b/examples/python/simple/canary.yml new file mode 100644 index 0000000..06cf754 --- /dev/null +++ b/examples/python/simple/canary.yml @@ -0,0 +1,18 @@ +name: "test-py-simple" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +runtime: syn-python-selenium-1.0 +code: + handler: script.handler +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "Python" + Test: "Simple" diff --git a/examples/python/simple/script.py b/examples/python/simple/script.py new file mode 100644 index 0000000..c8512fb --- /dev/null +++ b/examples/python/simple/script.py @@ -0,0 +1,7 @@ +def basic_custom_script(): + fail = False + if fail: + raise Exception("Failed basicCanary check.") + return "Successfully completed basicCanary checks." +def handler(event, context): + return basic_custom_script() diff --git a/examples/python/web/canary.yml b/examples/python/web/canary.yml new file mode 100644 index 0000000..6a27904 --- /dev/null +++ b/examples/python/web/canary.yml @@ -0,0 +1,20 @@ +name: "test-py-web" +memory: 1000 # minimum required memory, in MB +timeout: 840 # maximum timeout (14 minutes), in seconds +tracing: false # enable active tracing +runtime: syn-python-selenium-1.0 +env: + ENDPOINT: "https://example.com/" +code: + handler: script.handler +retention: + failure: 31 # in days + success: 31 # in days +schedule: + duration: 0 # run only once when it is started, or regular run period (in seconds) + expression: "rate(0 hour)" # run only manually, or rate(30 minutes) +tags: + Project: "${PROJECT}" + Environment: "${ENVIRONMENT}" + Language: "Python" + Test: "Web" diff --git a/examples/python/web/script.py b/examples/python/web/script.py new file mode 100644 index 0000000..27d21fe --- /dev/null +++ b/examples/python/web/script.py @@ -0,0 +1,10 @@ +import os +from aws_synthetics.selenium import synthetics_webdriver as webdriver + +def basic_selenium_script(): + browser = webdriver.Chrome() + browser.get(os.environ.get('ENDPOINT')) + browser.save_screenshot('loaded.png') + +def handler(event, context): + basic_selenium_script() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bd321db --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/daaru00/aws-canary-cli + +go 1.16 + +require ( + github.com/AlecAivazis/survey/v2 v2.2.8 + github.com/aws/aws-sdk-go v1.37.20 + github.com/joho/godotenv v1.3.0 + github.com/urfave/cli/v2 v2.3.0 + gopkg.in/yaml.v2 v2.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce2bdd2 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/AlecAivazis/survey v1.8.8 h1:Y4yypp763E8cbqb5RBqZhGgkCFLRFnbRBHrxnpMMsgQ= +github.com/AlecAivazis/survey/v2 v2.2.8 h1:TgxCwybKdBckmC+/P9/5h49rw/nAHe/itZL0dgHs+Q0= +github.com/AlecAivazis/survey/v2 v2.2.8/go.mod h1:9DYvHgXtiXm6nCn+jXnOXLKbH+Yo9u8fAS/SduGdoPk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= +github.com/aws/aws-sdk-go v1.37.18 h1:SRdWLg+DqMFWX8HB3UvXyAoZpw9IDIUYnSTwgzOYbqg= +github.com/aws/aws-sdk-go v1.37.18/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.37.20 h1:CJCXpMYmBJrRH8YwoSE0oB9S3J5ax+62F14sYlDCztg= +github.com/aws/aws-sdk-go v1.37.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/aws/aws.go b/internal/aws/aws.go new file mode 100644 index 0000000..51d0e06 --- /dev/null +++ b/internal/aws/aws.go @@ -0,0 +1,47 @@ +package aws + +import ( + "os" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/urfave/cli/v2" +) + +// NewAwsSession return a new AWS client session +func NewAwsSession(c *cli.Context) *session.Session { + profile := c.String("profile") + region := c.String("region") + + // Create AWS config object + awsConfig := aws.Config{} + if len(region) != 0 { + awsConfig.Region = aws.String(region) + } + + // Check for debug mode + debugMode := os.Getenv("AWS_DEBUG") + if debugMode != "" { + awsConfig.LogLevel = aws.LogLevel(aws.LogDebugWithHTTPBody) + } + + // Return a new session + return session.Must(session.NewSessionWithOptions(session.Options{ + Profile: profile, + SharedConfigState: session.SharedConfigEnable, + Config: awsConfig, + })) +} + +// GetCallerAccountID return the account number +func GetCallerAccountID(ses *session.Session) *string { + stsClient := sts.New(ses) + identity, _ := stsClient.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + return identity.Account +} + +// GetCallerRegion return the account number +func GetCallerRegion(ses *session.Session) *string { + return ses.Config.Region +} diff --git a/internal/bucket/bucket.go b/internal/bucket/bucket.go new file mode 100644 index 0000000..5d5720d --- /dev/null +++ b/internal/bucket/bucket.go @@ -0,0 +1,129 @@ +package bucket + +import ( + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" +) + +type clients struct { + s3 *s3.S3 +} + +// Bucket structure +type Bucket struct { + clients *clients + + Name *string + Location *string +} + +// New creates a bucket +func New(ses *session.Session, name *string) *Bucket { + location := fmt.Sprintf("s3://%s", *name) + + return &Bucket{ + clients: &clients{ + s3.New(ses), + }, + Name: name, + Location: &location, + } +} + +// IsDeployed check if IAM Bucket name is present in current AWS account +func (b *Bucket) IsDeployed() bool { + _, err := b.clients.s3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: b.Name, + }) + return err == nil +} + +// Deploy Bucket +func (b *Bucket) Deploy() error { + + // Check if bucket is already deployed + if b.IsDeployed() { + return nil + } + + // Create new Bucket + _, err := b.clients.s3.CreateBucket(&s3.CreateBucketInput{ + Bucket: b.Name, + }) + + // Put lock block ACL + _, err = b.clients.s3.PutPublicAccessBlock(&s3.PutPublicAccessBlockInput{ + Bucket: b.Name, + PublicAccessBlockConfiguration: &s3.PublicAccessBlockConfiguration{ + BlockPublicAcls: aws.Bool(true), + BlockPublicPolicy: aws.Bool(true), + IgnorePublicAcls: aws.Bool(true), + RestrictPublicBuckets: aws.Bool(true), + }, + }) + + return err +} + +// Empty Bucket +func (b *Bucket) Empty() error { + + // Check if bucket is not deployed + if b.IsDeployed() == false { + return nil + } + + var listRes *s3.ListObjectsV2Output + for { + // List objects + listRes, err := b.clients.s3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: b.Name, + ContinuationToken: listRes.ContinuationToken, + }) + if err != nil { + return err + } + + // Collect keys + keysToDelete := []*s3.ObjectIdentifier{} + for _, object := range listRes.Contents { + keysToDelete = append(keysToDelete, &s3.ObjectIdentifier{ + Key: object.Key, + }) + } + + // Delete objects + b.clients.s3.DeleteObjects(&s3.DeleteObjectsInput{ + Bucket: b.Name, + Delete: &s3.Delete{ + Objects: keysToDelete, + }, + }) + + // Check if list is compleated + if *listRes.IsTruncated { + break + } + } + + return nil +} + +// Remove Bucket +func (b *Bucket) Remove() error { + + // Check if bucket is not deployed + if b.IsDeployed() == false { + return nil + } + + // Delete Bucket + _, err := b.clients.s3.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: b.Name, + }) + + return err +} diff --git a/internal/canary/canary.go b/internal/canary/canary.go new file mode 100644 index 0000000..c77d568 --- /dev/null +++ b/internal/canary/canary.go @@ -0,0 +1,365 @@ +package canary + +import ( + "bytes" + "fmt" + "path" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/lambda" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/internal/iam" +) + +type clients struct { + synthetics *synthetics.Synthetics + s3 *s3.S3 + s3uploader *s3manager.Uploader + lambda *lambda.Lambda +} + +// Schedule configuration +type Schedule struct { + DurationInSeconds int64 `yaml:"duration" json:"duration"` + Expression string `yaml:"expression" json:"expression"` +} + +// VPCConfig configuration +type VPCConfig struct { + SecurityGroupIds []string `yaml:"securityGroupIds" json:"securityGroupIds"` + SubnetIDs []string `yaml:"subnetIds" json:"subnetIds"` + VpcID string `yaml:"id" json:"id"` +} + +// RetentionConfig configuration +type RetentionConfig struct { + FailureRetentionPeriod int64 `yaml:"failure" json:"failure"` + SuccessRetentionPeriod int64 `yaml:"success" json:"success"` +} + +// Canary structure +type Canary struct { + clients *clients + region *string + + Name string `yaml:"name" json:"name"` + Retention RetentionConfig `yaml:"retention" json:"retention"` + RuntimeVersion string `yaml:"runtime" json:"runtime"` + Tags map[string]string `yaml:"tags" json:"tags"` + Code Code `yaml:"code" json:"code"` + EnvironmentVariables map[string]string `yaml:"env" json:"env"` + ActiveTracing bool `yaml:"tracing" json:"tracing"` + MemoryInMB int64 `yaml:"memory" json:"memory"` + TimeoutInSeconds int64 `yaml:"timeout" json:"timeout"` + Schedule Schedule `yaml:"schedule" json:"schedule"` + VPCConfig VPCConfig `yaml:"vpc" json:"vpc"` + RoleName string `yaml:"role" json:"role"` + PolicyStatements []iam.StatementEntry `yaml:"policies" json:"policies"` +} + +// New creates a new Canary +func New(ses *session.Session, name string) *Canary { + clients := &clients{ + synthetics: synthetics.New(ses), + s3: s3.New(ses), + s3uploader: s3manager.NewUploader(ses), + lambda: lambda.New(ses), + } + + return &Canary{ + clients: clients, + region: ses.Config.Region, + + Name: name, + RuntimeVersion: "syn-nodejs-puppeteer-3.0", + Retention: RetentionConfig{ + FailureRetentionPeriod: 31, + SuccessRetentionPeriod: 31, + }, + Code: Code{ + clients: clients, + + Handler: "index.handler", + Src: "./", + }, + ActiveTracing: false, + TimeoutInSeconds: 840, // 14 minutes + MemoryInMB: 1000, + EnvironmentVariables: nil, + Schedule: Schedule{ + DurationInSeconds: 0, + Expression: "rate(0 hour)", + }, + } +} + +// GetFlatTags return tags as flat string +func (c *Canary) GetFlatTags(separator string) *string { + flat := "" + + // Iterate over tags and concatenated them + for _, value := range c.Tags { + if len(flat) != 0 { + flat += separator + } + flat += fmt.Sprintf("%s", value) + } + + return &flat +} + +// IsDeployed check if Canary name is present in current AWS account +func (c *Canary) IsDeployed() bool { + _, err := c.clients.synthetics.GetCanary(&synthetics.GetCanaryInput{ + Name: &c.Name, + }) + return err == nil +} + +// Deploy canary +func (c *Canary) Deploy(role *iam.Role, artifactBucketLocation *string) error { + var err error + + // Load archive path + data, err := c.Code.ReadArchive() + if err != nil { + return err + } + + // Check if Canary is already deployed + if c.IsDeployed() == false { + input := &synthetics.CreateCanaryInput{ + Name: &c.Name, + ArtifactS3Location: artifactBucketLocation, + ExecutionRoleArn: role.Arn, + FailureRetentionPeriodInDays: &c.Retention.FailureRetentionPeriod, + SuccessRetentionPeriodInDays: &c.Retention.SuccessRetentionPeriod, + RunConfig: &synthetics.CanaryRunConfigInput{ + ActiveTracing: &c.ActiveTracing, + EnvironmentVariables: aws.StringMap(c.EnvironmentVariables), + MemoryInMB: &c.MemoryInMB, + TimeoutInSeconds: &c.TimeoutInSeconds, + }, + RuntimeVersion: &c.RuntimeVersion, + Schedule: &synthetics.CanaryScheduleInput{ + DurationInSeconds: &c.Schedule.DurationInSeconds, + Expression: &c.Schedule.Expression, + }, + Code: &synthetics.CanaryCodeInput{ + Handler: &c.Code.Handler, + // S3Bucket: &c.Code.archives3bucket, + // S3Key: &c.Code.archives3key, + ZipFile: data, + }, + Tags: aws.StringMap(c.Tags), + } + + // Setup VPc config only if set + if len(c.VPCConfig.SecurityGroupIds) > 0 { + input.VpcConfig = &synthetics.VpcConfigInput{ + SecurityGroupIds: aws.StringSlice(c.VPCConfig.SecurityGroupIds), + SubnetIds: aws.StringSlice(c.VPCConfig.SubnetIDs), + } + } + + // Update canary + _, err = c.clients.synthetics.CreateCanary(input) + } else { + input := &synthetics.UpdateCanaryInput{ + Name: &c.Name, + ExecutionRoleArn: role.Arn, + FailureRetentionPeriodInDays: &c.Retention.FailureRetentionPeriod, + SuccessRetentionPeriodInDays: &c.Retention.SuccessRetentionPeriod, + RunConfig: &synthetics.CanaryRunConfigInput{ + ActiveTracing: &c.ActiveTracing, + EnvironmentVariables: aws.StringMap(c.EnvironmentVariables), + MemoryInMB: &c.MemoryInMB, + TimeoutInSeconds: &c.TimeoutInSeconds, + }, + RuntimeVersion: &c.RuntimeVersion, + Schedule: &synthetics.CanaryScheduleInput{ + DurationInSeconds: &c.Schedule.DurationInSeconds, + Expression: &c.Schedule.Expression, + }, + Code: &synthetics.CanaryCodeInput{ + Handler: &c.Code.Handler, + // S3Bucket: &c.Code.archives3bucket, + // S3Key: &c.Code.archives3key, + ZipFile: data, + }, + } + + // Setup VPc config only if set + if len(c.VPCConfig.SecurityGroupIds) > 0 { + input.VpcConfig = &synthetics.VpcConfigInput{ + SecurityGroupIds: aws.StringSlice(c.VPCConfig.SecurityGroupIds), + SubnetIds: aws.StringSlice(c.VPCConfig.SubnetIDs), + } + } + + // Update canary + _, err = c.clients.synthetics.UpdateCanary(input) + } + + return err +} + +// Start canary +func (c *Canary) Start() error { + _, err := c.clients.synthetics.StartCanary(&synthetics.StartCanaryInput{ + Name: &c.Name, + }) + return err +} + +// Stop canary +func (c *Canary) Stop() error { + _, err := c.clients.synthetics.StopCanary(&synthetics.StopCanaryInput{ + Name: &c.Name, + }) + return err +} + +// GetStatus return canary status +func (c *Canary) GetStatus() (*synthetics.CanaryStatus, error) { + res, err := c.clients.synthetics.GetCanary(&synthetics.GetCanaryInput{ + Name: &c.Name, + }) + + return res.Canary.Status, err +} + +// GetRuns return canary runs +func (c *Canary) GetRuns() ([]*synthetics.CanaryRun, error) { + res, err := c.clients.synthetics.GetCanaryRuns(&synthetics.GetCanaryRunsInput{ + Name: &c.Name, + }) + + return res.CanaryRuns, err +} + +// GetLastRun return the latest canary run +func (c *Canary) GetLastRun() (*synthetics.CanaryRun, error) { + runs, err := c.GetRuns() + if len(runs) > 0 { + return runs[0], err + } + return nil, err +} + +// GetRunLogs return canary run log +func (c *Canary) GetRunLogs(run *synthetics.CanaryRun) (*string, error) { + log := "" + + // Check if not data are set + if *run.ArtifactS3Location == "No data" { + return run.ArtifactS3Location, nil + } + + // Elaborate bucket name + artifactPath := *run.ArtifactS3Location + bucketName := strings.Split(artifactPath, "/")[0] + + // List artifact objects + listRes, err := c.clients.s3.ListObjects(&s3.ListObjectsInput{ + Bucket: &bucketName, + Prefix: aws.String(artifactPath[len(bucketName)+1:]), + }) + + if err != nil { + return &log, err + } + + // Search for logs + logKey := "" + for _, object := range listRes.Contents { + if path.Ext(*object.Key) == ".txt" { + logKey = *object.Key + break + } + } + + // Check if log was found + if len(logKey) == 0 { + return &log, fmt.Errorf("Cannot find log txt file in artifact bucket s3://%s", artifactPath) + } + + // Retrieve log file content + getRes, err := c.clients.s3.GetObject(&s3.GetObjectInput{ + Bucket: &bucketName, + Key: &logKey, + }) + if err != nil { + return &log, err + } + + // Read object content + buf := new(bytes.Buffer) + buf.ReadFrom(getRes.Body) + log = buf.String() + + return &log, nil +} + +// Remove canary +func (c *Canary) Remove() error { + // Get canary + canaryGet, err := c.clients.synthetics.GetCanary(&synthetics.GetCanaryInput{ + Name: &c.Name, + }) + if err != nil { + return err + } + + // Delete canary + _, err = c.clients.synthetics.DeleteCanary(&synthetics.DeleteCanaryInput{ + Name: &c.Name, + }) + if err != nil { + return err + } + + // Delete related layer + layerName := fmt.Sprintf("cwsyn-%s-%s", c.Name, *canaryGet.Canary.Id) + layerList, err := c.clients.lambda.ListLayerVersions(&lambda.ListLayerVersionsInput{ + LayerName: &layerName, + }) + if err != nil { + return err + } + + // Delete all layer's versions + for _, version := range layerList.LayerVersions { + _, err = c.clients.lambda.DeleteLayerVersion(&lambda.DeleteLayerVersionInput{ + LayerName: &layerName, + VersionNumber: version.Version, + }) + if err != nil { + return err + } + } + + // Delete related function + _, err = c.clients.lambda.DeleteFunction(&lambda.DeleteFunctionInput{ + FunctionName: &layerName, + }) + if err != nil { + return err + } + + return nil +} + +// IsNodeRuntime check if is node runtime +func (c *Canary) IsNodeRuntime() bool { + return strings.Contains(c.RuntimeVersion, "nodejs") +} + +// IsPythonRuntime check if is python runtime +func (c *Canary) IsPythonRuntime() bool { + return strings.Contains(c.RuntimeVersion, "python") +} diff --git a/internal/canary/code.go b/internal/canary/code.go new file mode 100644 index 0000000..f1c5f45 --- /dev/null +++ b/internal/canary/code.go @@ -0,0 +1,171 @@ +package canary + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go/service/s3/s3manager" +) + +// Code structure +type Code struct { + archivename string + archivepath string + archives3bucket string + archives3key string + clients *clients + + Src string `yaml:"src" json:"src"` + Handler string `yaml:"handler" json:"handler"` +} + +// CreateArchive create a ZIP archive from code path +func (c *Code) CreateArchive(name *string, pathprefix *string) error { + c.archivename = fmt.Sprintf("%s.zip", *name) + c.archivepath = path.Join(os.TempDir(), c.archivename) + + // Create ZIP archive + destinationFile, err := os.Create(c.archivepath) + if err != nil { + return err + } + + // Initialize write + codeZip := zip.NewWriter(destinationFile) + + // Walk for each files in source path + err = filepath.Walk(c.Src, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip directories + if info.IsDir() { + return nil + } + + // Elaborate destination path + destPath := filePath + if len(c.Src) != 0 && c.Src != "." && c.Src != "./" { + destPath = strings.TrimPrefix(destPath, c.Src) + } + destPath = path.Join(*pathprefix, destPath) + + // Add file to ZIP archive + zipFile, err := codeZip.Create(destPath) + if err != nil { + return err + } + fsFile, err := os.Open(filePath) + if err != nil { + return err + } + _, err = io.Copy(zipFile, fsFile) + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + + // Close ZIP archive + err = codeZip.Close() + if err != nil { + return err + } + return nil +} + +// ReadArchive will return the archive data +func (c *Code) ReadArchive() ([]byte, error) { + return ioutil.ReadFile(c.archivepath) +} + +// DeleteArchive will delete the temporary archive +func (c *Code) DeleteArchive() error { + return os.Remove(c.archivepath) +} + +// Upload will upload archive to S3 +func (c *Code) Upload(bucket *string) error { + // Open archive file + file, err := os.Open(c.archivepath) + if err != nil { + return err + } + defer file.Close() + + // Set archive s3 location + c.archives3bucket = *bucket + c.archives3key = c.archivename + + // Upload archive + _, err = c.clients.s3uploader.Upload(&s3manager.UploadInput{ + Bucket: &c.archives3bucket, + Key: &c.archives3key, + Body: file, + }) + + return err +} + +// InstallNpmDependencies will install npm dependencies +func (c *Code) InstallNpmDependencies() (string, error) { + var outBuffer, errBuffer bytes.Buffer + + // Check if package.json exist + if _, err := os.Stat(path.Join(c.Src, "package.json")); os.IsNotExist(err) { + return outBuffer.String(), nil + } + + // Prepare npm dependencies install command + cmd := exec.Command("npm", "install", "--production") + cmd.Dir = c.Src + + // Set outputs + cmd.Stdout = &outBuffer + cmd.Stderr = &errBuffer + + // Run command + err := cmd.Run() + if err != nil { + return outBuffer.String(), fmt.Errorf("Error installing npm dependencies in %s: %s", c.Src, errBuffer.String()) + } + + return outBuffer.String(), nil +} + +// InstallPipDependencies will install pip dependencies +func (c *Code) InstallPipDependencies() (string, error) { + var outBuffer, errBuffer bytes.Buffer + + // Check if requirements.txt exist + if _, err := os.Stat(path.Join(c.Src, "requirements.txt")); os.IsNotExist(err) { + return outBuffer.String(), nil + } + + // Prepare npm dependencies install command + cmd := exec.Command("pip", "install", "-r", "requirements.txt") + cmd.Dir = c.Src + + // Set outputs + cmd.Stdout = &outBuffer + cmd.Stderr = &errBuffer + + // Run command + err := cmd.Run() + if err != nil { + return outBuffer.String(), fmt.Errorf("Error installing npm dependencies in %s: %s", c.Src, errBuffer.String()) + } + + return outBuffer.String(), nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..467dfce --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,39 @@ +package config + +import ( + "fmt" + "os" +) + +// InterpolateContent interpolate variables from current environment +func InterpolateContent(content *[]byte) *string { + strContent := string(*content) + strContent = os.ExpandEnv(strContent) + return &strContent +} + +// ParseContent create a Config from content +func ParseContent(content *string, parser *string, destination interface{}) error { + // Check parser type + switch *parser { + case "json": + jsonParser := NewJSONParser(*content) + err := jsonParser.Parse(destination) + if err != nil { + return err + } + break + case "yaml": + case "yml": + yamlParser := NewYAMLParser(*content) + err := yamlParser.Parse(destination) + if err != nil { + return err + } + break + default: + return fmt.Errorf("Parser %s not supported", *parser) + } + + return nil +} diff --git a/internal/config/input.go b/internal/config/input.go new file mode 100644 index 0000000..d7d78c5 --- /dev/null +++ b/internal/config/input.go @@ -0,0 +1,164 @@ +package config + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/AlecAivazis/survey/v2" + "github.com/aws/aws-sdk-go/service/synthetics" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/urfave/cli/v2" +) + +// FilterCanariesByName filter canaries by name flag +func FilterCanariesByName(canaries *[]*canary.Canary, names *[]string) []*canary.Canary { + selectedCanaries := []*canary.Canary{} + + // Check for provided canaries name + for _, canary := range *canaries { + for _, name := range *names { + match, _ := filepath.Match(name, canary.Name) + if match { + selectedCanaries = append(selectedCanaries, canary) + break + } + } + } + + return selectedCanaries +} + +// AskMultipleCanariesSelection ask user to select multiple canaries +func AskMultipleCanariesSelection(c *cli.Context, canaries []*canary.Canary) (*[]*canary.Canary, error) { + selectedCanaries := []*canary.Canary{} + + // Check if single canary + if len(canaries) == 1 { + return &canaries, nil + } + + // Check if all flag is present + if c.Bool("all") { + return &canaries, nil + } + + // Check for provided canaries name + names := c.StringSlice("name") + if len(c.StringSlice("name")) > 0 { + selectedCanaries = FilterCanariesByName(&canaries, &names) + + // Check if at least one canary was found + if len(selectedCanaries) == 0 { + return &selectedCanaries, errors.New("Cannot find any canaries that match provided name filters") + } + + return &selectedCanaries, nil + } + + // Build table + header := fmt.Sprintf("%-25s\t%-20s", "Name", "Tags") + var options []string + for _, canary := range canaries { + options = append(options, fmt.Sprintf("%-20s\t%-20s", canary.Name, *canary.GetFlatTags(","))) + } + + // Ask selection + canariesSelectedIndexes := []int{} + prompt := &survey.MultiSelect{ + Message: "Select canaries: \n\n " + header + "\n", + Options: options, + PageSize: 15, + } + survey.AskOne(prompt, &canariesSelectedIndexes) + fmt.Println("") + + // Check response + if len(canariesSelectedIndexes) == 0 { + return &selectedCanaries, errors.New("No canaries selected") + } + + // Load selected canaries + for _, index := range canariesSelectedIndexes { + selectedCanaries = append(selectedCanaries, canaries[index]) + } + + return &selectedCanaries, nil +} + +// AskSingleCanarySelection ask user to select canaries +func AskSingleCanarySelection(c *cli.Context, canaries []*canary.Canary) (*canary.Canary, error) { + selectedCanaries := []*canary.Canary{} + + // Check if single canary + if len(canaries) == 1 { + return canaries[0], nil + } + + // Check for provided canaries name + name := c.String("name") + if len(c.String("name")) > 0 { + selectedCanaries = FilterCanariesByName(&canaries, &[]string{ + name, + }) + + // Check if a canary was found + if len(selectedCanaries) == 0 { + return nil, errors.New("Cannot find canary that match provided name filter") + } + + return selectedCanaries[0], nil + } + + // Build table + header := fmt.Sprintf("%-25s\t%-20s", "Name", "Tags") + var options []string + for _, canary := range canaries { + options = append(options, fmt.Sprintf("%-20s\t%-20s", canary.Name, *canary.GetFlatTags(","))) + } + + // Ask selection + canarySelectedIndex := -1 + prompt := &survey.Select{ + Message: "Select a canary: \n\n " + header + "\n", + Options: options, + Help: "", + PageSize: 15, + } + survey.AskOne(prompt, &canarySelectedIndex) + fmt.Println("") + + // Check response + if canarySelectedIndex == -1 { + return nil, errors.New("No canaries selected") + } + + return canaries[canarySelectedIndex], nil +} + +// AskSingleCanaryRun ask user to select canary run +func AskSingleCanaryRun(runs []*synthetics.CanaryRun) (*synthetics.CanaryRun, error) { + // Build table + header := fmt.Sprintf("%-36s\t%-7s\t%-25s\t%-25s", "Id", "Status", "Started At", "Compleated At") + var options []string + for _, run := range runs { + options = append(options, fmt.Sprintf("%-36s\t%-7s\t%-25s\t%-25s", *run.Id, *run.Status.State, *run.Timeline.Started, *run.Timeline.Completed)) + } + + // Ask selection + canaryRunIndex := -1 + prompt := &survey.Select{ + Message: "Select canary run: \n\n " + header + "\n", + Options: options, + PageSize: 15, + } + survey.AskOne(prompt, &canaryRunIndex) + fmt.Println("") + + // Check response + if canaryRunIndex == -1 { + return nil, errors.New("No canary run selected") + } + + return runs[canaryRunIndex], nil +} diff --git a/internal/config/json.go b/internal/config/json.go new file mode 100644 index 0000000..7930216 --- /dev/null +++ b/internal/config/json.go @@ -0,0 +1,25 @@ +package config + +import "encoding/json" + +// JSONParser parse YML format +type JSONParser struct { + content string +} + +// Parse convert string into config object +func (parser JSONParser) Parse(config interface{}) error { + err := json.Unmarshal([]byte(parser.content), config) + if err != nil { + return err + } + + return nil +} + +// NewJSONParser create a JSONParser +func NewJSONParser(content string) *JSONParser { + parser := new(JSONParser) + parser.content = content + return parser +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..fb33ec8 --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,167 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/daaru00/aws-canary-cli/internal/canary" + "github.com/joho/godotenv" + "github.com/urfave/cli/v2" +) + +// LoadDotEnv will load environment variable from .env file +func LoadDotEnv() error { + env := os.Getenv("CANARY_ENV") + envFile := ".env" + + // Build env file name + if len(env) != 0 { + envFile += "." + env + } + + // Check if file exist + _, err := os.Stat(envFile) + if os.IsNotExist(err) { + return nil + } + + // Load environment variables + return godotenv.Load(envFile) +} + +// LoadCanaries load canary using user input +func LoadCanaries(c *cli.Context, ses *session.Session) (*[]*canary.Canary, error) { + canaries := []*canary.Canary{} + + // Search config in sources + fileName := c.String("config-file") + parser := c.String("config-parser") + + // Check tests source path argument + searchPaths := []string{"."} + if c.Args().Len() > 0 { + searchPaths = c.Args().Slice() + } + + // Iterate over search paths provided + for _, searchPath := range searchPaths { + + // Check provided path type + info, err := os.Stat(searchPath) + if err != nil { + return &canaries, err + } + + // Check if path is a directory or file + fileMode := info.Mode() + if fileMode.IsDir() { + // Found canary in directory + canariesFound, err := LoadCanariesFromDir(ses, &searchPath, &fileName, &parser) + if err != nil { + return nil, err + } + + // Append canaries + canaries = append(canaries, canariesFound...) + } else if fileMode.IsRegular() { + // Load canary from file + canaryFound, err := LoadCanaryFromFile(ses, &searchPath, &parser) + if err != nil { + return nil, err + } + + // Append canaries + canaries = append(canaries, canaryFound) + } else { + return &canaries, fmt.Errorf("Path %s has a unsupported type", searchPath) + } + } + + return &canaries, nil +} + +// LoadCanaryFromFile load canary from file +func LoadCanaryFromFile(ses *session.Session, filePath *string, parser *string) (*canary.Canary, error) { + // If file match read content + fileContent, err := ioutil.ReadFile(*filePath) + if err != nil { + return nil, err + } + + // Interpolate file content + fileContentInterpolated := InterpolateContent(&fileContent) + + // Parse file content into config object + fileName := filepath.Base(*filePath) + extension := filepath.Ext(fileName) + canaryName := fileName[0 : len(fileName)-len(extension)] + canary := canary.New(ses, canaryName) + err = ParseContent(fileContentInterpolated, parser, canary) + if err != nil { + return nil, err + } + + // Add path to config + if len(canary.Code.Src) == 0 { + canary.Code.Src = filepath.Dir(*filePath) + } else { + canary.Code.Src = path.Join(canary.Code.Src, filepath.Dir(*filePath)) + } + + return canary, nil +} + +// LoadCanariesFromDir search config files and load canaries +func LoadCanariesFromDir(ses *session.Session, searchPath *string, fileNameToMatch *string, parser *string) ([]*canary.Canary, error) { + start := time.Now() + filesCount := 0 + canaries := []*canary.Canary{} + + // Walk for each files in source path + err := filepath.Walk(*searchPath, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + filesCount++ + + // Check if file match name + fileName := filepath.Base(filePath) + if fileName != *fileNameToMatch { + return nil + } + + // Parse canary from file + canary, err := LoadCanaryFromFile(ses, &filePath, parser) + if err != nil { + return err + } + // Add canary to slice + canaries = append(canaries, canary) + return nil + }) + + // Check for errors + if err != nil { + return canaries, err + } + + // Check canaries length + if len(canaries) == 0 { + round, _ := time.ParseDuration("5ms") + elapsed := time.Since(start).Round(round) + return canaries, fmt.Errorf("No canaries found in path %s (%d files scanned in %s)", *searchPath, filesCount, elapsed) + } + + // Return canaries + return canaries, err +} diff --git a/internal/config/yaml.go b/internal/config/yaml.go new file mode 100644 index 0000000..04cb9e7 --- /dev/null +++ b/internal/config/yaml.go @@ -0,0 +1,26 @@ +package config + +import ( + "gopkg.in/yaml.v2" +) + +// YAMLParser parse YAML format +type YAMLParser struct { + content string +} + +// Parse convert string into config object +func (parser YAMLParser) Parse(config interface{}) error { + err := yaml.Unmarshal([]byte(parser.content), config) + if err != nil { + return err + } + return nil +} + +// NewYAMLParser create a YAMLParser +func NewYAMLParser(content string) *YAMLParser { + parser := new(YAMLParser) + parser.content = content + return parser +} diff --git a/internal/iam/iam.go b/internal/iam/iam.go new file mode 100644 index 0000000..54b59d4 --- /dev/null +++ b/internal/iam/iam.go @@ -0,0 +1,9 @@ +package iam + +import ( + "github.com/aws/aws-sdk-go/service/iam" +) + +type clients struct { + iam *iam.IAM +} diff --git a/internal/iam/policy.go b/internal/iam/policy.go new file mode 100644 index 0000000..2574af7 --- /dev/null +++ b/internal/iam/policy.go @@ -0,0 +1,207 @@ +package iam + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + awsinternal "github.com/daaru00/aws-canary-cli/internal/aws" + "github.com/daaru00/aws-canary-cli/internal/bucket" +) + +// Policy structure +type Policy struct { + clients *clients + statements []StatementEntry + + Name *string + Arn *string +} + +// PolicyDocument structure +type PolicyDocument struct { + Version string + Statement []StatementEntry +} + +// StatementEntry structure +type StatementEntry struct { + Effect string `yaml:"Effect" json:"Effect"` + Action []string `yaml:"Action" json:"Action"` + Resource []string `yaml:"Resource" json:"Resource"` + Condition Condition `yaml:"Condition" json:"Condition"` +} + +// Condition structure +type Condition struct { + StringEquals map[string]string `json:"StringEquals,omitempty"` +} + +// NewPolicy creates a new IAM Policy for Canary +func NewPolicy(ses *session.Session, nameOrArn *string) *Policy { + var arn string + var name string + + // Get account id + accountID := awsinternal.GetCallerAccountID(ses) + + // Check if an arn is provided + if len(*nameOrArn) > 0 { + if strings.HasPrefix(*nameOrArn, "arn:") == true { + arn = *nameOrArn + arnParts := strings.Split(*nameOrArn, "/") + name = arnParts[len(arnParts)-1] + } else { + name = *nameOrArn + + arn = fmt.Sprintf("arn:aws:iam::%s:policy/%s", *accountID, name) + } + } + + return &Policy{ + clients: &clients{ + iam: iam.New(ses), + }, + statements: []StatementEntry{}, + + Name: &name, + Arn: &arn, + } +} + +// IsDeployed check if IAM Policy name is present in current AWS account +func (p *Policy) IsDeployed() bool { + _, err := p.clients.iam.GetPolicy(&iam.GetPolicyInput{ + PolicyArn: p.Arn, + }) + + return err == nil +} + +// AddStatement add statement to policy +func (p *Policy) AddStatement(statement StatementEntry) { + p.statements = append(p.statements, statement) +} + +// AddArtifactBucketPermission add s3 artifact permissions statements to policy +func (p *Policy) AddArtifactBucketPermission(artifactBucket *bucket.Bucket) { + p.statements = append([]StatementEntry{ + { + Effect: "Allow", + Action: []string{ + "s3:PutObject", + }, + Resource: []string{ + fmt.Sprintf("arn:aws:s3:::%s/*", *artifactBucket.Name), + }, + }, + { + Effect: "Allow", + Action: []string{ + "s3:GetBucketLocation", + }, + Resource: []string{ + fmt.Sprintf("arn:aws:s3:::%s", *artifactBucket.Name), + }, + }, + { + Effect: "Allow", + Action: []string{ + "s3:ListAllMyBuckets", + }, + Resource: []string{ + "*", + }, + }, + }, p.statements...) +} + +// AddXRayPermission add xray permissions statements to policy +func (p *Policy) AddXRayPermission() { + p.statements = append([]StatementEntry{ + { + Effect: "Allow", + Action: []string{ + "xray:PutTraceSegments", + }, + Resource: []string{ + "*", + }, + }, + }, p.statements...) +} + +// AddLogPermission add cloudwatch log permissions statements to policy +func (p *Policy) AddLogPermission(region *string, accountID *string) { + p.statements = append([]StatementEntry{ + { + Effect: "Allow", + Action: []string{ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:CreateLogGroup", + }, + Resource: []string{ + fmt.Sprintf("arn:aws:logs:%s:%s:log-group:/aws/lambda/cwsyn-*", *region, *accountID), + }, + }, + }, p.statements...) +} + +// AddMetricsPermission add cloudwatch metrics permissions statements to policy +func (p *Policy) AddMetricsPermission() { + p.statements = append([]StatementEntry{ + { + Effect: "Allow", + Action: []string{ + "cloudwatch:PutMetricData", + }, + Resource: []string{ + "*", + }, + Condition: Condition{ + StringEquals: map[string]string{ + "cloudwatch:namespace": "CloudWatchSynthetics", + }, + }, + }, + }, p.statements...) +} + +// AddSSMParamersPermission add SSM parameters metrics permissions statements to policy +func (p *Policy) AddSSMParamersPermission(region *string, accountID *string) { + p.statements = append([]StatementEntry{ + { + Effect: "Allow", + Action: []string{ + "ssm:GetParameter*", + }, + Resource: []string{ + fmt.Sprintf("arn:aws:ssm:%s:%s:parameter/cwsyn/*", *region, *accountID), + }, + }, + }, p.statements...) +} + +// Render IAM Policy +func (p *Policy) Render() (*string, error) { + str := "{}" + + // Build policy document + policy := PolicyDocument{ + Version: "2012-10-17", + Statement: p.statements, + } + + // Generate policy document + doc, err := json.Marshal(&policy) + if err != nil { + return &str, err + } + + // Convert to string + str = string(doc) + return &str, err +} diff --git a/internal/iam/role.go b/internal/iam/role.go new file mode 100644 index 0000000..56f3541 --- /dev/null +++ b/internal/iam/role.go @@ -0,0 +1,156 @@ +package iam + +import ( + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + awsinternal "github.com/daaru00/aws-canary-cli/internal/aws" +) + +// Role structure +type Role struct { + clients *clients + + Name *string + Arn *string + InlinePolicy *Policy +} + +// NewRole creates a new IAM Role for Canary +func NewRole(ses *session.Session, nameOrArn *string) *Role { + var arn string + var name string + + // Get account id + accountID := awsinternal.GetCallerAccountID(ses) + + // Check if an arn is provided + if strings.HasPrefix(*nameOrArn, "arn:") == true { + arnParts := strings.Split(name, "/") + name = arnParts[1] + arn = *nameOrArn + } else { + name = *nameOrArn + + arn = fmt.Sprintf("arn:aws:iam::%s:role/%s", *accountID, name) + } + + return &Role{ + clients: &clients{ + iam.New(ses), + }, + Name: &name, + Arn: &arn, + InlinePolicy: nil, + } +} + +// IsDeployed check if IAM Role name is present in current AWS account +func (r *Role) IsDeployed() bool { + _, err := r.clients.iam.GetRole(&iam.GetRoleInput{ + RoleName: r.Name, + }) + return err == nil +} + +// SetInlinePolicy Set inline policy +func (r *Role) SetInlinePolicy(policy *Policy) error { + r.InlinePolicy = policy + return nil +} + +// Deploy IAM role +func (r *Role) Deploy() error { + + // Check if not deployed + if r.IsDeployed() == false { + // Create role + _, err := r.clients.iam.CreateRole(&iam.CreateRoleInput{ + RoleName: r.Name, + AssumeRolePolicyDocument: aws.String(`{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + }] + }`), + }) + if err != nil { + return err + } + + // Wait until policy is fully created + err = r.clients.iam.WaitUntilRoleExists(&iam.GetRoleInput{ + RoleName: r.Name, + }) + if err != nil { + return err + } + + // Do a dummy sleep to avoid IAM role not recognized as valida Lambda role + time.Sleep(10 * 1000 * time.Millisecond) + } + + // Check for inline policy + if r.InlinePolicy != nil { + // Render policy + policyDoc, err := r.InlinePolicy.Render() + if err != nil { + return err + } + + // Add role policy + _, err = r.clients.iam.PutRolePolicy(&iam.PutRolePolicyInput{ + RoleName: r.Name, + PolicyName: r.InlinePolicy.Name, + PolicyDocument: policyDoc, + }) + if err != nil { + return err + } + } + + return nil +} + +// Remove IAM role +func (r *Role) Remove() error { + // Get role inline policy + _, err := r.clients.iam.GetRolePolicy(&iam.GetRolePolicyInput{ + RoleName: r.Name, + PolicyName: r.InlinePolicy.Name, + }) + if err != nil { + return err + } + + // Delete inline policy + _, err = r.clients.iam.DeleteRolePolicy(&iam.DeleteRolePolicyInput{ + RoleName: r.Name, + PolicyName: r.InlinePolicy.Name, + }) + if err != nil { + return err + } + + // Delete role + _, err = r.clients.iam.DeleteRole(&iam.DeleteRoleInput{ + RoleName: r.Name, + }) + if err != nil { + return err + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0178050 --- /dev/null +++ b/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/daaru00/aws-canary-cli/cmd/build" + "github.com/daaru00/aws-canary-cli/cmd/deploy" + "github.com/daaru00/aws-canary-cli/cmd/logs" + "github.com/daaru00/aws-canary-cli/cmd/remove" + "github.com/daaru00/aws-canary-cli/cmd/results" + "github.com/daaru00/aws-canary-cli/cmd/start" + "github.com/daaru00/aws-canary-cli/cmd/stop" + "github.com/daaru00/aws-canary-cli/internal/config" + "github.com/urfave/cli/v2" +) + +func main() { + var err error + + // Load .env + err = config.LoadDotEnv() + if err != nil { + log.Fatal(err) + } + + // Setup global flags + globalFlags := []cli.Flag{ + &cli.StringFlag{ + Name: "profile", + Aliases: []string{"p"}, + Usage: "AWS profile name", + EnvVars: []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"}, + }, + &cli.StringFlag{ + Name: "region", + Aliases: []string{"r"}, + Usage: "AWS region", + EnvVars: []string{"AWS_REGION", "AWS_DEFAULT_REGION"}, + }, + &cli.StringFlag{ + Name: "config-file", + Aliases: []string{"cf"}, + Usage: "Config file name", + Value: "canary.yml", + EnvVars: []string{"CONFIG_FILE"}, + }, + &cli.StringFlag{ + Name: "config-parser", + Aliases: []string{"cp"}, + Usage: "Config file parser, valid values are \"yml\" or \"json\"", + Value: "yml", + EnvVars: []string{"CONFIG_PARSER"}, + }, + } + + // Create CLI application + app := &cli.App{ + Name: "aws-canary", + Description: "AWS Synthetics Canary Helper CLI", + Usage: "Deploy and manage AWS Synthetics Canaries", + UsageText: "./aws-canary [global options] command [command options] [path...]", + Version: "VERSION", // this will be overridden during build phase + Commands: []*cli.Command{ + build.NewCommand(globalFlags), + deploy.NewCommand(globalFlags), + remove.NewCommand(globalFlags), + start.NewCommand(globalFlags), + stop.NewCommand(globalFlags), + logs.NewCommand(globalFlags), + results.NewCommand(globalFlags), + }, + Flags: globalFlags, + EnableBashCompletion: true, + Before: func(c *cli.Context) error { + if len(c.String("profile")) > 0 { + os.Setenv("AWS_PROFILE", c.String("profile")) + } + if len(c.String("region")) > 0 { + os.Setenv("AWS_REGION", c.String("region")) + } + if len(c.String("config-file")) > 0 { + os.Setenv("CONFIG_FILE", c.String("config-file")) + } + if len(c.String("config-parser")) > 0 { + os.Setenv("CONFIG_PARSER", c.String("config-parser")) + } + return nil + }, + } + + // Run the CLI application + err = app.Run(os.Args) + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48e341a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 1 +}