diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0cd1e88 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +_Replace this content with your own_ + +## Checklists + +### About the changes + +- [ ] Tests added if necessary +- [ ] man-pages (`./man`) updated if necessary +- [ ] Formatted properly (e.g. Restyled passes) + +### About the PR + +- [ ] Descriptive, imperative-tense title +- [ ] Body explaining the _why_ of the change +- [ ] `breaking-change` or `enhancement` label applied if appropriate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b86d74c..0742ac4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,26 +2,39 @@ name: CI on: pull_request: - push: - branches: main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - id: generate + uses: freckle/stack-action/generate-matrix@v5 + outputs: + stack-yamls: ${{ steps.generate.outputs.stack-yamls }} + build: + needs: generate + strategy: + matrix: + stack-yaml: ${{ fromJSON(needs.generate.outputs.stack-yamls) }} + fail-fast: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: freckle/stack-cache-action@v2 - - uses: freckle/stack-action@v3 + - uses: actions/checkout@v7 + - uses: freckle/stack-action@v5 + with: + stack-arguments: --stack-yaml ${{ matrix.stack-yaml }} lint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: haskell/actions/hlint-setup@v2 - - uses: haskell/actions/hlint-run@v2 + - uses: actions/checkout@v7 + - uses: haskell-actions/hlint-setup@v2 + - uses: haskell-actions/hlint-run@v2 with: fail-on: warning diff --git a/.github/workflows/mergeabot.yml b/.github/workflows/mergeabot.yml new file mode 100644 index 0000000..434cb69 --- /dev/null +++ b/.github/workflows/mergeabot.yml @@ -0,0 +1,19 @@ +name: Mergeabot + +on: + schedule: + - cron: "0 0 * * *" + + pull_request: + +permissions: + contents: write + pull-requests: write + +jobs: + mergeabot: + runs-on: ubuntu-latest + steps: + - uses: freckle/mergeabot-action@v3.1.2 + with: + quarantine-days: -1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 301e921..b013786 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -20,9 +20,12 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - run: echo "$HOME/.local/share/gem/ruby/3.0.0/bin" >>"$GITHUB_PATH" - run: gem install --user ronn-ng - - uses: actions/checkout@v3 + - run: | + for bin in "$HOME"/.local/share/gem/ruby/*/bin; do + echo "$bin" + done >>"$GITHUB_PATH" + - uses: actions/checkout@v7 - name: Generate HTML man-pages run: ronn --style toc,custom --html man/*.ronn @@ -36,9 +39,9 @@ jobs: cp -v man/*.html _site/ cp -v _site/stackctl.1.html _site/index.html - - uses: actions/configure-pages@v3 - - uses: actions/upload-pages-artifact@v1 + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 with: path: _site - id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 010f17a..5632f06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,46 +1,13 @@ -name: Release executables +name: Release on: push: - branches: main + branches: + - main + - rc/* jobs: - tag: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - id: tag - uses: freckle/haskell-tag-action@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - outputs: - tag: ${{ steps.tag.outputs.tag }} - - create-release: - needs: tag - if: needs.tag.outputs.tag - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - id: release-notes - uses: freckle/release-notes-action@v1 - with: - version: ${{ needs.tag.outputs.tag }} - - uses: actions/create-release@v1 - id: create-release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ needs.tag.outputs.tag }} - release_name: Release ${{ needs.tag.outputs.tag }} - body_path: ${{ steps.release-notes.outputs.path }} - draft: false - prerelease: false - outputs: - upload_url: ${{ steps.create-release.outputs.upload_url }} - - upload-assets: - needs: create-release + build: strategy: fail-fast: false matrix: @@ -52,29 +19,61 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - uses: freckle/stack-cache-action@v2 - - run: echo "$HOME/.local/share/gem/ruby/3.0.0/bin" >>"$GITHUB_PATH" - - run: gem install --user ronn-ng - if: ${{ runner.os == 'macOS' }} run: brew install coreutils # need GNU install - - run: make install.check - - uses: actions/upload-release-asset@v1 - id: upload-release-asset + - run: gem install --user ronn-ng + - run: | + for bin in "$HOME"/.local/share/gem/ruby/*/bin; do + echo "$bin" + done >>"$GITHUB_PATH" + - uses: actions/checkout@v7 + + - id: release + uses: cycjimmy/semantic-release-action@v6.0.0 + with: + dry_run: true + extra_plugins: | + semantic-release-stack-upload env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FORCE_COLOR: 1 + PREPARE_IN_VERIFY: 1 + + # These are unused, but needed for verify to succeed + GITHUB_TOKEN: ${{ github.token }} + HACKAGE_KEY: ${{ secrets.HACKAGE_UPLOAD_API_KEY }} + + - uses: freckle/stack-action@v5 + - run: | + make install.check # creates dist/stackctl.tar.gz + cp -v dist/stackctl.tar.gz stackctl-${{ matrix.suffix }}.tar.gz + - uses: actions/upload-artifact@v7 with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ./dist/stackctl.tar.gz - asset_name: stackctl-${{ matrix.suffix }}.tar.gz - asset_content_type: application/gzip + name: ${{ matrix.os }}-binaries + path: "stackctl-*.tar.gz" + if-no-files-found: error - upload-hackage: - needs: tag - if: needs.tag.outputs.tag + release: + needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: freckle/stack-upload-action@v2 + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/download-artifact@v8 + + - id: token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.FRECKLE_AUTOMATION_APP_ID }} + private-key: ${{ secrets.FRECKLE_AUTOMATION_PRIVATE_KEY }} + + - id: release + uses: cycjimmy/semantic-release-action@v6.0.0 + with: + extra_plugins: | + semantic-release-stack-upload env: - HACKAGE_API_KEY: ${{ secrets.HACKAGE_UPLOAD_API_KEY }} + FORCE_COLOR: 1 + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + HACKAGE_KEY: ${{ secrets.HACKAGE_UPLOAD_API_KEY }} diff --git a/.github/workflows/restyled.yml b/.github/workflows/restyled.yml new file mode 100644 index 0000000..c9a42d0 --- /dev/null +++ b/.github/workflows/restyled.yml @@ -0,0 +1,22 @@ +name: Restyled + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + restyled: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: restyled-io/actions/setup@v4 + - uses: restyled-io/actions/run@v4 + with: + suggestions: true diff --git a/.gitignore b/.gitignore index c42eee2..20a7c51 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ man/* !man/index.txt !man/*.css !man/*.ronn +.direnv +.envrc diff --git a/.hlint.yaml b/.hlint.yaml index 7bc8214..5b48bc7 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -11,6 +11,7 @@ - ignore: {name: "Use join"} # this often leads to cryptic code when do notation is easier to read - ignore: {name: "Redundant ^."} # commonly broken by esqueleto - ignore: {name: "Use ++"} # less readable for commandline option lists +- ignore: {name: "Functor law"} # too aggressive # Custom Warnings - warn: {lhs: mapM, rhs: traverse} diff --git a/.releaserc.yaml b/.releaserc.yaml new file mode 100644 index 0000000..10a3bae --- /dev/null +++ b/.releaserc.yaml @@ -0,0 +1,17 @@ +tagFormat: "v1.${version}" # PVP prefixed + +plugins: + - "@semantic-release/commit-analyzer" + - "@semantic-release/release-notes-generator" + - - "@semantic-release/github" + - assets: "*-binaries/stackctl-*.tar.gz" + successCommentCondition: false + failCommentCondition: false + - - "semantic-release-stack-upload" + - pvpBounds: lower + stripSuffix: true + +branches: + - main + - name: rc/* + prerelease: '${name.replace(/^rc\//, "rc-")}' diff --git a/.restyled.yaml b/.restyled.yaml index f3c387a..c25b270 100644 --- a/.restyled.yaml +++ b/.restyled.yaml @@ -1,6 +1,10 @@ restylers_version: dev restylers: - - fourmolu + - cabal-fmt: + enabled: false + - fourmolu: + image: + tag: v0.17.0.0 - stylish-haskell: enabled: false - prettier-markdown: @@ -11,3 +15,6 @@ restylers: - "!**/*.t" # cram tests have whitespace in assertions - "!README.md" # help code blocks have trailing whitespace - "*" + +also_exclude: + - "test/files/**/*" diff --git a/CHANGELOG.md b/CHANGELOG.md index 11cafce..f63b56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,179 +1 @@ -## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.4.3.0...main) - -## [v1.4.3.0](https://github.com/freckle/stackctl/compare/v1.4.2.2...v1.4.3.0) - -- Add `awsWithAuth` -- Add `forEachSpec_` - -## [v1.4.2.2](https://github.com/freckle/stackctl/compare/v1.4.2.1...v1.4.2.2) - -- Use `amazonka-2.0` :tada: -- Finalize update to `UnliftIO.Exception.Lens` -- Re-export upstreamed `Blammo.Logging.Colors` - -## [v1.4.2.1](https://github.com/freckle/stackctl/compare/v1.4.2.0...v1.4.2.1) - -No changes. Bumped to trigger release workflow. - -## [v1.4.2.0](https://github.com/freckle/stackctl/compare/v1.4.0.1...v1.4.2.0) - -- Add `stackctl-ls` for listing stacks and their deployed status -- Add `--auto-sso` option for automating `aws sso login` when required - -## [v1.4.0.1](https://github.com/freckle/stackctl/compare/v1.4.0.0...v1.4.0.1) - -- Document and read a consistently-named `STACKCTL_FILTER` for `--filter`. For - now, the old and incorrect `STACKCTL_FILTERS` will still work. - -## [v1.4.0.0](https://github.com/freckle/stackctl/compare/v1.3.0.2...v1.4.0.0) - -- Add `awsAssumeRole` for running an action as an assumed role -- Refactor `Generate` interface to better support generating stacks with - pre-existing templates - -## [v1.3.0.2](https://github.com/freckle/stackctl/compare/v1.3.0.1...v1.3.0.2) - -- Adjust timeout when invoking Lambdas to allow up to Lambda's own execution - timeout (15 minutes). - -## [v1.3.0.1](https://github.com/freckle/stackctl/compare/v1.3.0.0...v1.3.0.1) - -- Fix bug where `LOG_COLOR` was never respected -- Also accept `"required_version: == "` -- Add `Eq`, `ToJSON` instance on `RequiredVersion` - -## [v1.3.0.0](https://github.com/freckle/stackctl/compare/v1.2.0.1...v1.3.0.0) - -- Fix it so commands like `version` don't need a valid AWS environment - - This changes the `Subcommand` interface and so is a major version update for - the purposes of those using Stackctl as a library. - -## [v1.2.0.0](https://github.com/freckle/stackctl/compare/v1.1.3.1...v1.2.0.0) - -- Use more specific types in `Has{Directory,Filter,Color}Option` -- Add environment variable configuration for `STACKCTL_{DIRECTORY,FILTERS}` - -## [v1.1.4.0](https://github.com/freckle/stackctl/compare/v1.1.3.1...v1.1.4.0) - -- Support matching Stacks by glob in `capture` -- Add `--tag` to `changes` and `deploy` - -## [v1.1.3.1](https://github.com/freckle/stackctl/compare/v1.1.3.0...v1.1.3.1) - -- Fix JSON formatting bugs in generating specification - -## [v1.1.3.0](https://github.com/freckle/stackctl/compare/v1.1.2.2...v1.1.3.0) - -- Repository-local configuration - - See https://github.com/freckle/stackctl/commit/564678203fe70b5c4c46c655dd3daeaafb6de9e0 - -- Don't duplicate re-used templates in `stackctl-cat` -- Improve `--filter` - - - Match against stack name and template, in addition to spec path. - - Automatically prepend `**/` (unless there is already a leading wildcard) and - append `{/*,.yaml,.json}` (unless there is already a trailing wildcard or - extension). - - In general, this aims to make `--filter` match more things more intuitively - for operators, but still match exactly in programmatic use-cases. - -- Various documentation improvements -- Support more natural `{Key}: {Value}` syntax in `Parameters` and `Tags` -- Fix bug where we may generate an `{}` element in `Parameters` - -## [v1.1.2.2](https://github.com/freckle/stackctl/compare/v1.1.2.1...v1.1.2.2) - -- Add support for Stack descriptions - -## [v1.1.2.1](https://github.com/freckle/stackctl/compare/v1.1.2.0...v1.1.2.1) - -- Build with LTS-20.4 / GHC 9.2 - -## [v1.1.2.0](https://github.com/freckle/stackctl/compare/v1.1.1.1...v1.1.2.0) - -- Fix incorrect ordering of log-messages by setting `LOG_CONCURRENCY=1` -- Fix potential coloring of changes being redirected to a file -- Make `PATH` optional (again) in `stackctl changes` -- Add `--no-flip` to `stackctl capture` - -## [v1.1.1.1](https://github.com/freckle/stackctl/compare/v1.1.1.0...v1.1.1.1) - -- Trigger release - -## [v1.1.1.0](https://github.com/freckle/stackctl/compare/v1.1.0.5...v1.1.1.0) - -- Add `--parameter` to `changes` and `deploy` -- Sort changes by causing-before-caused - -## [v1.1.0.5](https://github.com/freckle/stackctl/compare/v1.1.0.4...v1.1.0.5) - -- Trigger release workflow - -## [v1.1.0.4](https://github.com/freckle/stackctl/compare/v1.1.0.3...v1.1.0.4) - -- Fix bug where only the last spec in a multi-spec case had its changes present - in the output file generated by `changes`. - -## [v1.1.0.3](https://github.com/freckle/stackctl/compare/v1.1.0.2...v1.1.0.3) - -- Require Blammo-1.1.1.0 - -## [v1.1.0.2](https://github.com/freckle/stackctl/compare/v1.1.0.1...v1.1.0.2) - -- Log responses from `awsLambdaInvoke` when running actions -- Clarify discovery logging -- Add install script - -## [v1.1.0.1](https://github.com/freckle/stackctl/compare/v1.1.0.0...v1.1.0.1) - -- Update to `cfn-flip-0.1.0.3` - -## [v1.1.0.0](https://github.com/freckle/stackctl/compare/v1.0.2.0...v1.1.0.0) - -- Fix interleaved or out-of-order output bugs by streaming deployment events - through the Logger instead of directly to `stdout` -- Logging goes to `stdout` by default (`LOG_DESTINATION` can still be used) -- The `changes` subcommand now requires a `PATH` argument - -## [v1.0.2.0](https://github.com/freckle/stackctl/compare/v1.0.1.2...v1.0.2.0) - -- Add `Stackctl.Action` - - Support for taking actions during Stack management, currently we support - invoking a lambda post-deployment. In the future, we can add more, such as - running local pre-deploy validation or preparation scripts. - -- Add `awsCloudFormationDescribeStackOutputs` - -## [v1.0.1.2](https://github.com/freckle/stackctl/compare/v1.0.1.1...v1.0.1.2) - -- Always flush log messages before our own output - -## [v1.0.1.1](https://github.com/freckle/stackctl/compare/v1.0.1.0...v1.0.1.1) - -- Respect `LOG_DESTINATION` (the default remains `stderr`) - -## [v1.0.1.0](https://github.com/freckle/stackctl/compare/v1.0.0.2...v1.0.1.0) - -- Support reading CloudGenesis specifications - - - Accept account paths like `id.name` or `name.id` - - Read `Parameters` as `Parameter{Key,Value}` or `{Name,Value}` - - This allows us to work with specifications directories originally implemented - for, and potentially still used with, the CloudGenesis tooling. - -## [v1.0.0.2](https://github.com/freckle/stackctl/compare/v1.0.0.1...v1.0.0.2) - -- Fix tailing all events to read most recent, causing Throttling errors - -## [v1.0.0.1](https://github.com/freckle/stackctl/compare/v1.0.0.0...v1.0.0.1) - -- Fix non-portable paths issue in OSX executable build - -## [v1.0.0.0](https://github.com/freckle/stackctl/tree/v1.0.0.0) - -First release +See https://github.com/freckle/stackctl/releases diff --git a/Makefile b/Makefile index 5bef2ab..b1762a7 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ ARCHIVE_TARGETS := \ dist/stackctl/completion/fish \ dist/stackctl/completion/zsh \ dist/stackctl/doc/stackctl.1 \ + dist/stackctl/doc/stackctl.5 \ dist/stackctl/doc/stackctl-cat.1 \ dist/stackctl/doc/stackctl-capture.1 \ dist/stackctl/doc/stackctl-changes.1 \ @@ -56,6 +57,7 @@ install: $(INSTALL) -Dm644 completion/fish $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/stackctl.fish $(INSTALL) -Dm644 completion/zsh $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_stackctl $(INSTALL) -Dm644 doc/stackctl.1 $(DESTDIR)$(MANPREFIX)/man1/stackctl.1 + $(INSTALL) -Dm644 doc/stackctl.5 $(DESTDIR)$(MANPREFIX)/man5/stackctl.5 $(INSTALL) -Dm644 doc/stackctl-cat.1 $(DESTDIR)$(MANPREFIX)/man1/stackctl-cat.1 $(INSTALL) -Dm644 doc/stackctl-capture.1 $(DESTDIR)$(MANPREFIX)/man1/stackctl-capture.1 $(INSTALL) -Dm644 doc/stackctl-changes.1 $(DESTDIR)$(MANPREFIX)/man1/stackctl-changes.1 @@ -69,6 +71,7 @@ uninstall: $(RM) $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/stackctl.fish $(RM) $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_stackctl $(RM) $(DESTDIR)$(MANPREFIX)/man1/stackctl.1 + $(RM) $(DESTDIR)$(MANPREFIX)/man5/stackctl.5 $(RM) $(DESTDIR)$(MANPREFIX)/man1/stackctl-cat.1 $(RM) $(DESTDIR)$(MANPREFIX)/man1/stackctl-capture.1 $(RM) $(DESTDIR)$(MANPREFIX)/man1/stackctl-changes.1 diff --git a/README.md b/README.md index 8b61894..f9c9ac4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ to-be-deployed) CloudFormation Stacks including the Template, Parameters, and Tags. `stackctl` can be used to pretty-print, diff, and deploy these specifications. -[spec]: https://github.com/freckle/stackctl/blob/main/doc/stackctl.1.md#stack-specifications +[spec]: https://freckle.github.io/stackctl/#STACK-SPECIFICATIONS This project also contains a Haskell library for doing the same. @@ -32,10 +32,11 @@ This project also contains a Haskell library for doing the same. curl -L https://raw.githubusercontent.com/freckle/stackctl/main/install | bash ``` -**NOTE**: some in the community have expressed [concerns][curlsh-bad] about the -security of so-called "curl-sh" installations. We think the argument has been -[pretty well debunked][curlsh-ok], but feel free to use the manual steps -instead. +> [!NOTE] +> Some in the community have expressed [concerns][curlsh-bad] about the +> security of so-called "curl-sh" installations. We think the argument has been +> [pretty well debunked][curlsh-ok], but feel free to use the manual steps +> instead. [curlsh-bad]: https://0x46.net/thoughts/2019/04/27/piping-curl-to-shell/ [curlsh-ok]: https://www.arp242.net/curl-to-sh.html @@ -76,6 +77,46 @@ Once installed, see: The man pages are also available [online](https://freckle.github.io/stackctl/), but contain documentation as of `main`, and not your installed version. +## Release + +To trigger a release in this project, merge a commit to `main` with a +conventionally-formatted commit message. In short, one that starts with: + +1. `fix:` to trigger a patch release, +1. `feat:` for minor, or +1. `feat!:` for major + +Conventional commits are not required generally for this project, though you're +free to always use them. They are only required when you want to trigger a +release. + +## Comparison to AWS CloudFormation Git Sync + +[AWS CloudFormation Git Sync][aws-git-sync] was recently released by AWS. It +allows you to link a repository on GitHub to a CloudFormation Stack. The +repository contains a "deployment file" that defines a `template-file-path`, +`parameters`, and `tags` -- effectively, a Stack Specification. + +When AWS notices updates to the deployment or template file land on a defined +branch, it updates the configured Stack accordingly, emitting events to SNS as +it does. + +This is great for simple use-cases, and we fully expect they'll improve and +extend it such that it obviates Stackctl one day. In the meantime, there are +currently the following limitations when compared to Stackctl: + +1. A repository can only target a single account and region +1. There is no changeset flow amenable to previewing changes via PRs. You update + the file(s) on `main` and it syncs, that's it. If you're using a PR, you have + only linting and human review as possible pre-deployment steps. +1. There is no way to specify description, capabilities, or dependencies +1. As of 12/23, there seemed to be some bugs, and the setup installs a managed + event bridge that "phones home", sending events about your updates to some + other AWS account ([source][first-look-blog]) + +[aws-git-sync]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/git-sync.html +[first-look-blog]: https://medium.com/@mattgillard/first-look-git-sync-for-cloudformation-stacks-9e2f39c311ac + ## Relationship to CloudGenesis [CloudGenesis][] is a project that also takes a directory of Stack diff --git a/app/Main.hs b/app/Main.hs index cecba33..3c4b678 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -11,8 +11,8 @@ main :: IO () main = runSubcommand $ subcommand Commands.cat - <> subcommand Commands.capture - <> subcommand Commands.changes - <> subcommand Commands.deploy - <> subcommand Commands.list - <> subcommand Commands.version + <> subcommand Commands.capture + <> subcommand Commands.changes + <> subcommand Commands.deploy + <> subcommand Commands.list + <> subcommand Commands.version diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2cee93a --- /dev/null +++ b/flake.lock @@ -0,0 +1,384 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "inputs": { + "systems": "systems_3" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "freckle": { + "inputs": { + "flake-utils": "flake-utils_2", + "haskell-openapi-code-generator": "haskell-openapi-code-generator", + "nix-github-actions": "nix-github-actions", + "nixpkgs-23-05": "nixpkgs-23-05", + "nixpkgs-23-11": "nixpkgs-23-11", + "nixpkgs-24-05": "nixpkgs-24-05", + "nixpkgs-24-11": "nixpkgs-24-11", + "nixpkgs-25-05": "nixpkgs-25-05", + "nixpkgs-unstable": "nixpkgs-unstable" + }, + "locked": { + "dir": "main", + "lastModified": 1760374014, + "narHash": "sha256-BoNvJ+VFtSPO8+wnyh1Qrn4XXKGxvUeb2xRNceVSFuo=", + "owner": "freckle", + "repo": "flakes", + "rev": "872341c9d85213db04b0ef7cad9e05b362af89c6", + "type": "github" + }, + "original": { + "dir": "main", + "owner": "freckle", + "repo": "flakes", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "freckle", + "haskell-openapi-code-generator", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "haskell-openapi-code-generator": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1752914035, + "narHash": "sha256-QghINu6JPxiUyK3XSBhjgT/CvFez4hL8hGwfNvr9vPI=", + "owner": "Haskell-OpenAPI-Code-Generator", + "repo": "Haskell-OpenAPI-Client-Code-Generator", + "rev": "08fa0eb1d2baef4e3f328ae155bd0ff4ad08efcf", + "type": "github" + }, + "original": { + "owner": "Haskell-OpenAPI-Code-Generator", + "repo": "Haskell-OpenAPI-Client-Code-Generator", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "freckle", + "nixpkgs-25-05" + ] + }, + "locked": { + "lastModified": 1737420293, + "narHash": "sha256-F1G5ifvqTpJq7fdkT34e/Jy9VCyzd5XfJ9TO8fHhJWE=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "f4158fa080ef4503c8f4c820967d946c2af31ec9", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1748162331, + "narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-23-05": { + "locked": { + "lastModified": 1704290814, + "narHash": "sha256-LWvKHp7kGxk/GEtlrGYV68qIvPHkU9iToomNFGagixU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "70bdadeb94ffc8806c0570eb5c2695ad29f0e421", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-23-11": { + "locked": { + "lastModified": 1720535198, + "narHash": "sha256-zwVvxrdIzralnSbcpghA92tWu2DV2lwv89xZc8MTrbg=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "205fd4226592cc83fd4c0885a3e4c9c400efabb5", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-23.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-24-05": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-24-11": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-25-05": { + "locked": { + "lastModified": 1760139962, + "narHash": "sha256-4xggC56Rub3WInz5eD7EZWXuLXpNvJiUPahGtMkwtuc=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "7e297ddff44a3cc93673bb38d0374df8d0ad73e4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1760284886, + "narHash": "sha256-TK9Kr0BYBQ/1P5kAsnNQhmWWKgmZXwUQr4ZMjCzWf2c=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "cf3f5c4def3c7b5f1fc012b3d839575dbe552d43", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1730768919, + "narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1760139962, + "narHash": "sha256-4xggC56Rub3WInz5eD7EZWXuLXpNvJiUPahGtMkwtuc=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "7e297ddff44a3cc93673bb38d0374df8d0ad73e4", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1742649964, + "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "freckle": "freckle", + "nixpkgs": "nixpkgs_3" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7ff838f --- /dev/null +++ b/flake.nix @@ -0,0 +1,73 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; + freckle.url = "github:freckle/flakes?dir=main"; + flake-utils.url = "github:numtide/flake-utils"; + }; + outputs = inputs: inputs.flake-utils.lib.eachDefaultSystem (system: + let + nixpkgs = inputs.nixpkgs.legacyPackages.${system}; + freckle = inputs.freckle.packages.${system}; + freckleLib = inputs.freckle.lib.${system}; + in + rec { + packages = { + awscli = freckle.aws-cli-2-11-x; + + cabal = nixpkgs.cabal-install; + + fourmolu = freckle.fourmolu-0-13-x; + + ghc = freckleLib.haskellBundle { + ghcVersion = "ghc-9-8-4"; + packageSelection = p: [ ]; + enableHLS = true; + }; + + hlint = + nixpkgs.haskell.lib.justStaticExecutables + nixpkgs.hlint; + + stack = nixpkgs.writeShellApplication { + name = "stack"; + text = '' + ${nixpkgs.stack}/bin/stack --system-ghc --no-nix "$@" + ''; + } + ; + }; + + devShells.default = nixpkgs.mkShell { + buildInputs = with (nixpkgs); [ + pcre + pcre.dev + zlib + zlib.dev + ]; + + nativeBuildInputs = with (packages); [ + awscli + cabal + fourmolu + ghc + hlint + stack + ]; + + shellHook = '' + export STACK_YAML=stack.yaml + ''; + }; + }); + + nixConfig = { + extra-substituters = [ + "https://freckle.cachix.org" + "https://freckle-private.cachix.org" + ]; + extra-trusted-public-keys = [ + "freckle.cachix.org-1:WnI1pZdwLf2vnP9Fx7OGbVSREqqi4HM2OhNjYmZ7odo=" + "freckle-private.cachix.org-1:zbTfpeeq5YBCPOjheu0gLyVPVeM6K2dc1e8ei8fE0AI=" + ]; + }; +} diff --git a/fourmolu.yaml b/fourmolu.yaml index ef571e8..292304b 100644 --- a/fourmolu.yaml +++ b/fourmolu.yaml @@ -1,15 +1,35 @@ indentation: 2 -column-limit: 80 # ignored until v12 / ghc-9.6 +column-limit: 80 # needs v0.12 function-arrows: leading comma-style: leading # default import-export-style: leading +import-grouping: # needs v0.17 + - name: "Preludes" + rules: + - glob: Prelude + - glob: "**.Prelude" + - glob: RIO + - glob: Stackctl.Test.App + - name: "Everything else" + rules: + - match: all + priority: 100 indent-wheres: false # default record-brace-space: true newlines-between-decls: 1 # default haddock-style: single-line let-style: mixed in-style: left-align -single-constraint-parens: never # ignored until v12 / ghc-9.6 +single-constraint-parens: never # needs v0.12 +sort-constraints: false # default +sort-derived-classes: false # default +sort-derived-clauses: false # default +trailing-section-operators: false # needs v0.17 unicode: never # default respectful: true # default -fixities: [] # default + +# fourmolu can't figure this out because of the re-exports we use +fixities: + - "infixl 1 &" + - "infixr 4 .~" + - "infixr 4 ?~" diff --git a/man/index.txt b/man/index.txt index 793d09f..a697daa 100644 --- a/man/index.txt +++ b/man/index.txt @@ -1,4 +1,6 @@ # manuals included in this project: +stackctl(1) stackctl.1.ronn +stackctl(5) stackctl.5.ronn stackctl-cat(1) stackctl-cat.1.ronn stackctl-capture(1) stackctl-capture.1.ronn stackctl-changes(1) stackctl-changes.1.ronn diff --git a/man/stackctl-ls.1.ronn b/man/stackctl-ls.1.ronn index 92472cb..2bd1a65 100644 --- a/man/stackctl-ls.1.ronn +++ b/man/stackctl-ls.1.ronn @@ -3,7 +3,7 @@ stackctl-ls(1) - list stack specifications ## SYNOPSIS -`stackctl ls` +`stackctl ls` [] ## DESCRIPTION @@ -11,9 +11,10 @@ This command locates `stacks/` for the currently-authorized AWS Account and Region and lists them. The key differences between this and stackctl-cat(1) is that this command lists -things as simple rows and indicates for each spec if a deployed stack exists in -the first column. +things as simple rows and indicates for each spec the state of the stack in the +first column. ## OPTIONS -None. + * `--no-legend`: + Don't print indicators legend at the end. diff --git a/man/stackctl.1.ronn b/man/stackctl.1.ronn index fd6782d..8863fb2 100644 --- a/man/stackctl.1.ronn +++ b/man/stackctl.1.ronn @@ -8,7 +8,8 @@ stackctl(1) - manage CloudFormation Stacks through specifications ## OPTIONS * `-d`, `--directory`=: - Where to find specifications. Default is `.`. + Use the stack collection located at (default: current working + directory). * `--filter`=: Restrict specifications to those whose paths match any given . @@ -124,13 +125,22 @@ And these constituent parts are used as follows: **PostDeploy**: run the action after a successful deployment. * `{.Actions[].run}`: - The action to perform on the given event: + An action or list of actions to perform on the given event: **InvokeLambdaByStackOutput**: : invoke the function whose name is found in the given Output of the deployed Stack. **InvokeLambdaByName**: : invoke the given function. + **Exec**: [, ]: execute the given `command` and + `argument`s. + + **Shell**: : execute the given argument via `sh -c`. + + Executed processes will inherit any environment variables and print their + own `stdout` and `stderr`. If they do not exit 0, an exception is thrown and + `stackctl` itself exits. + * `{.Parameters}`: Optional. Parameters to use when deploying the Stack. @@ -244,6 +254,11 @@ See stackctl-changes(1) and stackctl-deploy(1). If set, will be used as in commands that create new specifications. +## FILES + +* `.config/stackctl.yaml`: + The configuration file for Stackctl. See stackctl(5) for details. + ## AUTHOR Freckle Engineering diff --git a/man/stackctl.5.ronn b/man/stackctl.5.ronn new file mode 100644 index 0000000..dbf87dc --- /dev/null +++ b/man/stackctl.5.ronn @@ -0,0 +1,50 @@ +stackctl(5) - configuration file for Stackctl +============================================= + +## SYNOPSIS + +`.stackctl/config.yaml`
+`.stackctl/config.yml`
+`.stackctl.yaml`
+`.stackctl.yml`
+ +The first path to exist will be used. + +## DESCRIPTION + +The configuration file is a YAML object with the following keys: + + * `required_version` :: : + A constraint on the version of Stackctl that must be used. The constraint + can be an exact version, or use an operator to define a minimum, maximum, or + "loose" constraint (see [EXAMPLE](#EXAMPLE)). + + * `defaults.parameters` :: >: + Parameters to use for all deploys, in the same format as the `.Parameters` + key of a stack specification (see **stackctl(1)**). + + * `defaults.tags` :: >: + Tags to use for all deploys, in the same format as the `.Tags` key of a + stack specification (see **stackctl(1)**). + +All keys are optional. + +## EXAMPLE + + + required_version: "=~ 1.7.1" # means >= 1.7.1.0 and < 1.7.2.0 + + defaults: + parameters: # list-of-object syntax + - Key: Foo + Value: Bar + - Key: Baz + Value: Bat + + tags: # object syntax (recommended) + Foo: Bar + Baz: Bat + +## SEE ALSO + +**stackctl(1)** diff --git a/package.yaml b/package.yaml index 23e607c..865ec01 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: stackctl -version: 1.4.3.0 +version: 1.7.2.0 github: freckle/stackctl license: MIT author: Freckle Engineering @@ -20,9 +20,11 @@ ghc-options: - -fwrite-ide-info - -Weverything - -Wno-all-missed-specialisations + - -Wno-missed-specialisations - -Wno-missing-import-lists - -Wno-missing-kind-signatures - -Wno-missing-local-signatures + - -Wno-missing-role-annotations - -Wno-missing-safe-haskell-mode - -Wno-prepositive-qualified-module - -Wno-unsafe @@ -58,7 +60,7 @@ default-extensions: library: source-dirs: src dependencies: - - Blammo >= 1.1.2.1 # getColorsLogger, etc + - Blammo >= 1.1.2.3 # flushLogger bugfix - Glob - QuickCheck - aeson @@ -69,6 +71,7 @@ library: - amazonka-core >= 2.0 - amazonka-ec2 >= 2.0 - amazonka-lambda >= 2.0 + - amazonka-mtl - amazonka-sso >= 2.0 - amazonka-sts >= 2.0 - bytestring @@ -85,13 +88,16 @@ library: - monad-logger - mtl - optparse-applicative + - prettyprinter - resourcet - rio - semigroups - text + - text-metrics - time - transformers - typed-process + - unix - unliftio >= 0.2.25.0 # UnliftIO.Exception.Lens - unordered-containers - uuid @@ -113,10 +119,25 @@ tests: main: Spec.hs source-dirs: test dependencies: + - Blammo + - Glob - QuickCheck - aeson + - amazonka + - amazonka-cloudformation + - amazonka-ec2 + - amazonka-lambda + - amazonka-mtl - bytestring + - filepath - hspec + - hspec-expectations-lifted + - hspec-golden >= 0.2.1.0 + - http-types + - lens - mtl - stackctl + - text + - time + - unliftio - yaml diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..bd84589 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>freckle/renovate-config" + ], + "minimumReleaseAge": "0 days" +} diff --git a/src/Stackctl/AWS/CloudFormation.hs b/src/Stackctl/AWS/CloudFormation.hs index 283f189..bb2e2c7 100644 --- a/src/Stackctl/AWS/CloudFormation.hs +++ b/src/Stackctl/AWS/CloudFormation.hs @@ -1,6 +1,9 @@ +{-# LANGUAGE DuplicateRecordFields #-} + module Stackctl.AWS.CloudFormation ( Stack (..) , stack_stackName + , stack_stackStatus , stackDescription , stackStatusRequiresDeletion , StackId (..) @@ -40,14 +43,17 @@ module Stackctl.AWS.CloudFormation , awsCloudFormationGetStackNamesMatching , awsCloudFormationGetMostRecentStackEventId , awsCloudFormationDeleteStack + , awsCloudFormationCancelUpdateStack , awsCloudFormationWait , awsCloudFormationGetTemplate -- * ChangeSets , ChangeSet (..) + , changeSetFromResponse , changeSetJSON , ChangeSetId (..) , ChangeSetName (..) + , ChangeSetType (..) , Change (..) , ResourceChange (..) , Replacement (..) @@ -64,6 +70,7 @@ module Stackctl.AWS.CloudFormation import Stackctl.Prelude +import Amazonka.CloudFormation.CancelUpdateStack import Amazonka.CloudFormation.CreateChangeSet hiding (id) import Amazonka.CloudFormation.DeleteChangeSet import Amazonka.CloudFormation.DeleteStack @@ -84,7 +91,8 @@ import Amazonka.Core , _MatchServiceError , _ServiceError ) -import Amazonka.Waiter (Accept (..)) +import qualified Amazonka.Env as Amazonka +import Amazonka.Waiter (Accept (..), Wait) import Conduit import Control.Lens ((?~)) import Data.Aeson @@ -95,7 +103,7 @@ import qualified Data.Text as T import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as UUID -import Stackctl.AWS.Core +import Stackctl.AWS.Core as AWS import Stackctl.Sort import Stackctl.StackDescription import System.FilePath.Glob @@ -170,26 +178,28 @@ newChangeSetName = liftIO $ do pure $ ChangeSetName $ T.intercalate "-" $ map pack parts awsCloudFormationDescribeStack - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => StackName -> m Stack + :: (MonadIO m, MonadAWS m) => StackName -> m Stack awsCloudFormationDescribeStack stackName = do let req = newDescribeStacks & describeStacks_stackName ?~ unStackName stackName - awsSimple "DescribeStack" req $ \resp -> do + AWS.simple req $ \resp -> do stacks <- resp ^. describeStacksResponse_stacks listToMaybe stacks awsCloudFormationDescribeStackMaybe - :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadUnliftIO m, MonadAWS m) => StackName -> m (Maybe Stack) awsCloudFormationDescribeStackMaybe stackName = -- AWS gives us a 400 if the stackName doesn't exist, rather than simply -- returning an empty list, so we need to do this through exceptions - handling_ _ValidationError (pure Nothing) $ do - Just <$> awsCloudFormationDescribeStack stackName + handling_ _ValidationError (pure Nothing) + $ awsSilently -- don't log said 400 + $ Just + <$> awsCloudFormationDescribeStack stackName awsCloudFormationDescribeStackOutputs - :: (MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadAWS m) => StackName -> m [Output] awsCloudFormationDescribeStackOutputs stackName = do @@ -197,7 +207,7 @@ awsCloudFormationDescribeStackOutputs stackName = do pure $ fromMaybe [] $ outputs stack awsCloudFormationDescribeStackEvents - :: (MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadAWS m) => StackName -> Maybe Text -- ^ Last-seen Id @@ -206,33 +216,33 @@ awsCloudFormationDescribeStackEvents stackName mLastId = do let req = newDescribeStackEvents & describeStackEvents_stackName - ?~ unStackName stackName + ?~ unStackName stackName runConduit - $ awsPaginate req - .| mapC (fromMaybe [] . (^. describeStackEventsResponse_stackEvents)) - .| concatC - .| takeWhileC (\e -> Just (e ^. stackEvent_eventId) /= mLastId) - .| sinkList + $ AWS.paginate req + .| mapC (fromMaybe [] . (^. describeStackEventsResponse_stackEvents)) + .| concatC + .| takeWhileC (\e -> Just (e ^. stackEvent_eventId) /= mLastId) + .| sinkList awsCloudFormationGetStackNamesMatching - :: (MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadAWS m) => Pattern -> m [StackName] awsCloudFormationGetStackNamesMatching p = do let req = newListStacks & listStacks_stackStatusFilter ?~ runningStatuses runConduit - $ awsPaginate req - .| concatMapC (^. listStacksResponse_stackSummaries) - .| concatC - .| mapC (^. stackSummary_stackName) - .| filterC ((p `match`) . unpack) - .| mapC StackName - .| sinkList + $ AWS.paginate req + .| concatMapC (^. listStacksResponse_stackSummaries) + .| concatC + .| mapC (^. stackSummary_stackName) + .| filterC ((p `match`) . unpack) + .| mapC StackName + .| sinkList awsCloudFormationGetMostRecentStackEventId - :: (MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadAWS m) => StackName -> m (Maybe Text) awsCloudFormationGetMostRecentStackEventId stackName = do @@ -240,7 +250,7 @@ awsCloudFormationGetMostRecentStackEventId stackName = do req = newDescribeStackEvents & describeStackEvents_stackName - ?~ unStackName stackName + ?~ unStackName stackName -- Events are returned most-recent first, so "last" is "first" here getFirstEventId :: [StackEvent] -> Maybe Text @@ -248,56 +258,66 @@ awsCloudFormationGetMostRecentStackEventId stackName = do [] -> Nothing (e : _) -> Just $ e ^. stackEvent_eventId - awsSimple "DescribeStackEvents" req + AWS.simple req $ pure - . getFirstEventId - . fromMaybe [] - . (^. describeStackEventsResponse_stackEvents) + . getFirstEventId + . fromMaybe [] + . (^. describeStackEventsResponse_stackEvents) awsCloudFormationDeleteStack - :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadLogger m, MonadAWS m) => StackName -> m StackDeleteResult awsCloudFormationDeleteStack stackName = do - let - deleteReq = newDeleteStack $ unStackName stackName - describeReq = - newDescribeStacks & describeStacks_stackName ?~ unStackName stackName - - awsSimple "DeleteStack" deleteReq $ const $ pure () + let req = newDeleteStack $ unStackName stackName + AWS.simple req $ const $ pure () logDebug "Awaiting DeleteStack" - stackDeleteResult <$> awsAwait newStackDeleteComplete describeReq + stackDeleteResult <$> awaitStack newStackDeleteComplete stackName + +awsCloudFormationCancelUpdateStack + :: (MonadIO m, MonadLogger m, MonadAWS m) => StackName -> m () +awsCloudFormationCancelUpdateStack stackName = do + let req = newCancelUpdateStack $ unStackName stackName + AWS.simple req $ const $ pure () + + logDebug "Awaiting CancelUpdateStack" + void $ awaitStack newStackRollbackComplete stackName awsCloudFormationWait - :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadUnliftIO m, MonadAWS m) => StackName -> m StackDeployResult awsCloudFormationWait stackName = do either stackCreateResult stackUpdateResult <$> race - (awsAwait newStackCreateComplete req) - (awsAwait newStackUpdateComplete req) - where - req = newDescribeStacks & describeStacks_stackName ?~ unStackName stackName + (awaitStack newStackCreateComplete stackName) + (awaitStack newStackUpdateComplete stackName) awsCloudFormationGetTemplate - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => StackName -> m Value + :: (MonadIO m, MonadAWS m) => StackName -> m Value awsCloudFormationGetTemplate stackName = do let req = newGetTemplate & (getTemplate_stackName ?~ unStackName stackName) - . (getTemplate_templateStage ?~ TemplateStage_Original) + . (getTemplate_templateStage ?~ TemplateStage_Original) -- If decodeStrict fails, assume it's a String of Yaml. See writeStackSpec. decodeTemplateBody body = fromMaybe (toJSON body) $ decodeStrict $ encodeUtf8 body - awsSimple "GetTemplate" req $ \resp -> do + AWS.simple req $ \resp -> do body <- resp ^. getTemplateResponse_templateBody pure $ decodeTemplateBody body +awaitStack + :: (MonadIO m, MonadAWS m) => Wait DescribeStacks -> StackName -> m Accept +awaitStack waiter stackName = + AWS.await waiter + $ newDescribeStacks + & describeStacks_stackName ?~ unStackName stackName + makeParameter :: Text -> Maybe Text -> Parameter makeParameter k v = newParameter & (parameter_parameterKey ?~ k) . (parameter_parameterValue .~ v) @@ -325,6 +345,7 @@ data ChangeSet = ChangeSet { csCreationTime :: UTCTime , csChanges :: Maybe [Change] , csChangeSetName :: ChangeSetName + , csChangeSetType :: ChangeSetType , csExecutionStatus :: ExecutionStatus , csChangeSetId :: ChangeSetId , csParameters :: Maybe [Parameter] @@ -337,6 +358,25 @@ data ChangeSet = ChangeSet , csResponse :: DescribeChangeSetResponse } +changeSetFromResponse + :: ChangeSetType -> DescribeChangeSetResponse -> Maybe ChangeSet +changeSetFromResponse changeSetType resp = + ChangeSet + <$> (resp ^. describeChangeSetResponse_creationTime) + <*> pure (fmap sortChanges $ resp ^. describeChangeSetResponse_changes) + <*> (ChangeSetName <$> resp ^. describeChangeSetResponse_changeSetName) + <*> pure changeSetType + <*> (resp ^. describeChangeSetResponse_executionStatus) + <*> (ChangeSetId <$> resp ^. describeChangeSetResponse_changeSetId) + <*> pure (resp ^. describeChangeSetResponse_parameters) + <*> (StackId <$> resp ^. describeChangeSetResponse_stackId) + <*> pure (resp ^. describeChangeSetResponse_capabilities) + <*> pure (resp ^. describeChangeSetResponse_tags) + <*> (StackName <$> resp ^. describeChangeSetResponse_stackName) + <*> pure (resp ^. describeChangeSetResponse_status) + <*> pure (resp ^. describeChangeSetResponse_statusReason) + <*> pure resp + changeSetJSON :: ChangeSet -> Text changeSetJSON = decodeUtf8 . BSL.toStrict . encodePretty . csResponse @@ -345,10 +385,8 @@ changeSetFailed = (== ChangeSetStatus_FAILED) . csStatus awsCloudFormationCreateChangeSet :: ( MonadUnliftIO m - , MonadResource m , MonadLogger m - , MonadReader env m - , HasAwsEnv env + , MonadAWS m ) => StackName -> Maybe StackDescription @@ -360,6 +398,7 @@ awsCloudFormationCreateChangeSet awsCloudFormationCreateChangeSet stackName mStackDescription stackTemplate parameters capabilities tags = fmap (first formatServiceError) $ trying (_ServiceError . hasStatus 400) + $ awsSilently $ do name <- newChangeSetName @@ -380,44 +419,31 @@ awsCloudFormationCreateChangeSet stackName mStackDescription stackTemplate param let req = newCreateChangeSet (unStackName stackName) (unChangeSetName name) & (createChangeSet_changeSetType ?~ changeSetType) - . (createChangeSet_templateBody ?~ templateBody) - . (createChangeSet_parameters ?~ parameters) - . (createChangeSet_capabilities ?~ capabilities) - . (createChangeSet_tags ?~ tags) + . (createChangeSet_templateBody ?~ templateBody) + . (createChangeSet_parameters ?~ parameters) + . (createChangeSet_capabilities ?~ capabilities) + . (createChangeSet_tags ?~ tags) logInfo $ "Creating changeset..." - :# ["name" .= name, "type" .= changeSetType] - csId <- awsSimple "CreateChangeSet" req (^. createChangeSetResponse_id) + :# ["name" .= name, "type" .= changeSetType] + csId <- AWS.simple req (^. createChangeSetResponse_id) logDebug "Awaiting CREATE_COMPLETE" - void $ awsAwait newChangeSetCreateComplete $ newDescribeChangeSet csId + void $ AWS.await newChangeSetCreateComplete $ newDescribeChangeSet csId logInfo "Retrieving changeset..." - cs <- awsCloudFormationDescribeChangeSet $ ChangeSetId csId + cs <- awsCloudFormationDescribeChangeSet changeSetType $ ChangeSetId csId pure $ cs <$ guard (not $ changeSetFailed cs) awsCloudFormationDescribeChangeSet - :: (MonadResource m, MonadReader env m, HasAwsEnv env) - => ChangeSetId + :: (MonadIO m, MonadAWS m) + => ChangeSetType + -> ChangeSetId -> m ChangeSet -awsCloudFormationDescribeChangeSet changeSetId = do +awsCloudFormationDescribeChangeSet changeSetType changeSetId = do let req = newDescribeChangeSet $ unChangeSetId changeSetId - awsSimple "DescribeChangeSet" req $ \resp -> - ChangeSet - <$> (resp ^. describeChangeSetResponse_creationTime) - <*> pure (fmap sortChanges $ resp ^. describeChangeSetResponse_changes) - <*> (ChangeSetName <$> resp ^. describeChangeSetResponse_changeSetName) - <*> (resp ^. describeChangeSetResponse_executionStatus) - <*> (ChangeSetId <$> resp ^. describeChangeSetResponse_changeSetId) - <*> pure (resp ^. describeChangeSetResponse_parameters) - <*> (StackId <$> resp ^. describeChangeSetResponse_stackId) - <*> pure (resp ^. describeChangeSetResponse_capabilities) - <*> pure (resp ^. describeChangeSetResponse_tags) - <*> (StackName <$> resp ^. describeChangeSetResponse_stackName) - <*> pure (resp ^. describeChangeSetResponse_status) - <*> pure (resp ^. describeChangeSetResponse_statusReason) - <*> pure resp + AWS.simple req $ changeSetFromResponse changeSetType sortChanges :: [Change] -> [Change] sortChanges = sortByDependencies changeName changeCausedBy @@ -437,28 +463,26 @@ detailCausingLogicalResourceId ResourceChangeDetail' {..} = T.takeWhile (/= '.') <$> causingEntity awsCloudFormationExecuteChangeSet - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => ChangeSetId -> m () + :: (MonadIO m, MonadAWS m) => ChangeSetId -> m () awsCloudFormationExecuteChangeSet changeSetId = do - void $ awsSend $ newExecuteChangeSet $ unChangeSetId changeSetId + void $ AWS.send $ newExecuteChangeSet $ unChangeSetId changeSetId awsCloudFormationDeleteAllChangeSets - :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env) - => StackName - -> m () + :: (MonadIO m, MonadLogger m, MonadAWS m) => StackName -> m () awsCloudFormationDeleteAllChangeSets stackName = do logInfo "Deleting all changesets" runConduit - $ awsPaginate (newListChangeSets $ unStackName stackName) - .| concatMapC - ( \resp -> fromMaybe [] $ do - ss <- resp ^. listChangeSetsResponse_summaries - pure $ mapMaybe Summary.changeSetId ss - ) - .| mapM_C - ( \csId -> do - logInfo $ "Enqueing delete" :# ["changeSetId" .= csId] - void $ awsSend $ newDeleteChangeSet csId - ) + $ AWS.paginate (newListChangeSets $ unStackName stackName) + .| concatMapC + ( \resp -> fromMaybe [] $ do + ss <- resp ^. listChangeSetsResponse_summaries + pure $ mapMaybe Summary.changeSetId ss + ) + .| mapM_C + ( \csId -> do + logInfo $ "Enqueing delete" :# ["changeSetId" .= csId] + void $ AWS.send $ newDeleteChangeSet csId + ) -- | Did we abandoned this Stack's first ever ChangeSet? -- @@ -501,3 +525,8 @@ runningStatuses = _ValidationError :: AsError a => Getting (First ServiceError) a ServiceError _ValidationError = _MatchServiceError defaultService "ValidationError" . hasStatus 400 + +awsSilently :: MonadAWS m => m a -> m a +awsSilently = AWS.localEnv $ Amazonka.env_logger .~ noop + where + noop _level _msg = pure () diff --git a/src/Stackctl/AWS/Core.hs b/src/Stackctl/AWS/Core.hs index 73fa269..1cd8657 100644 --- a/src/Stackctl/AWS/Core.hs +++ b/src/Stackctl/AWS/Core.hs @@ -1,151 +1,105 @@ module Stackctl.AWS.Core - ( AwsEnv - , HasAwsEnv (..) - , awsEnvDiscover - , awsWithAuth - , awsSimple - , awsSend - , awsPaginate - , awsAwait - , awsAssumeRole - - -- * Modifiers on 'AwsEnv' - , awsWithin - , awsTimeout - - -- * 'Amazonka' extensions - , AccountId (..) + ( MonadAWS + , send + , paginate + , await + , withAuth + , localEnv + + -- * "Control.Monad.AWS" extensions + , simple + , discover + , withAssumedRole -- * Error-handling + , handlingAuthError , handlingServiceError , formatServiceError - -- * 'Amazonka'/'ResourceT' re-exports + -- * "Amazonka" extensions + , AccountId (..) + + -- * "Amazonka" re-exports , Region (..) , FromText (..) , ToText (..) - , MonadResource - ) where -import Stackctl.Prelude hiding (timeout) + -- * Deprecated + , assumeRole + ) where -import Amazonka hiding (LogLevel (..)) -import qualified Amazonka as AWS +import Stackctl.Prelude + +import Amazonka + ( AWSRequest + , AWSResponse + , Env' (auth) + , Region + , ServiceError + , serviceError_code + , serviceError_message + , serviceError_requestId + , _AuthError + , _Sensitive + , _ServiceError + ) +import qualified Amazonka +import Amazonka.Auth.Background (fetchAuthInBackground) import Amazonka.Auth.Keys (fromSession) import Amazonka.Data.Text (FromText (..), ToText (..)) -import Amazonka.Env (env_auth, env_logger, env_region) +import qualified Amazonka.Env as Amazonka import Amazonka.STS.AssumeRole -import Conduit (ConduitM) +import Control.Monad.AWS import Control.Monad.Logger (defaultLoc, toLogStr) -import Control.Monad.Trans.Resource (MonadResource) +import qualified Data.Text as T +import Data.Typeable (typeRep) import Stackctl.AWS.Orphans () +import UnliftIO.Exception.Lens (handling) -newtype AwsEnv = AwsEnv - { unAwsEnv :: Env - } - -unL :: Lens' AwsEnv Env -unL = lens unAwsEnv $ \x y -> x {unAwsEnv = y} - -awsEnvDiscover :: MonadLoggerIO m => m AwsEnv -awsEnvDiscover = do - env <- liftIO $ newEnv discover - AwsEnv <$> configureLogging env - -configureLogging :: MonadLoggerIO m => Env -> m Env -configureLogging env = do +discover :: MonadLoggerIO m => m Amazonka.Env +discover = do + env <- liftIO $ Amazonka.newEnv Amazonka.discover loggerIO <- askLoggerIO let logger level = do loggerIO - defaultLoc -- TODO: there may be a way to get a CallStack/Loc + defaultLoc "Amazonka" ( case level of - AWS.Info -> LevelInfo - AWS.Error -> LevelError - AWS.Debug -> LevelDebug - AWS.Trace -> LevelOther "trace" + Amazonka.Info -> LevelInfo + Amazonka.Error -> LevelError + Amazonka.Debug -> LevelDebug + Amazonka.Trace -> LevelOther "trace" ) . toLogStr - pure $ env & env_logger .~ logger + pure $ env & Amazonka.env_logger .~ logger -class HasAwsEnv env where - awsEnvL :: Lens' env AwsEnv - -instance HasAwsEnv AwsEnv where - awsEnvL = id - -awsWithAuth - :: (MonadIO m, MonadReader env m, HasAwsEnv env) => (AuthEnv -> m a) -> m a -awsWithAuth f = do - auth <- view $ awsEnvL . unL . env_auth . to runIdentity - withAuth auth f - -awsSimple - :: ( MonadResource m - , MonadReader env m - , HasAwsEnv env +simple + :: forall a m b + . ( HasCallStack + , MonadIO m + , MonadAWS m , AWSRequest a - , Typeable a - , Typeable (AWSResponse a) ) - => Text - -> a + => a -> (AWSResponse a -> Maybe b) -> m b -awsSimple name req post = do - resp <- awsSend req +simple req post = do + resp <- send req + + let + name = show $ typeRep $ Proxy @a + err = name <> " successful, but processing the response failed" + maybe (throwString err) pure $ post resp - where - err = unpack name <> " successful, but processing the response failed" -awsSend - :: ( MonadResource m - , MonadReader env m - , HasAwsEnv env - , AWSRequest a - , Typeable a - , Typeable (AWSResponse a) - ) - => a - -> m (AWSResponse a) -awsSend req = do - AwsEnv env <- view awsEnvL - send env req - -awsPaginate - :: ( MonadResource m - , MonadReader env m - , HasAwsEnv env - , AWSPager a - , Typeable a - , Typeable (AWSResponse a) - ) - => a - -> ConduitM () (AWSResponse a) m () -awsPaginate req = do - AwsEnv env <- view awsEnvL - paginateEither env req >>= hoistEither - -hoistEither :: MonadIO m => Either Error a -> m a -hoistEither = either (liftIO . throwIO) pure - -awsAwait - :: ( MonadResource m - , MonadReader env m - , HasAwsEnv env - , AWSRequest a - , Typeable a - ) - => Wait a - -> a - -> m Accept -awsAwait w req = do - AwsEnv env <- view awsEnvL - await env w req - -awsAssumeRole - :: (MonadResource m, MonadReader env m, HasAwsEnv env) +-- | Use 'withAssumedRole' instead +-- +-- This function is like 'withAssumedRole' except it doesn't spawn a background +-- thread to keep credentials refreshed. You may encounter expired credentials +-- if the block used under 'assumeRole' goes for long enough. +assumeRole + :: (MonadIO m, MonadAWS m) => Text -- ^ Role ARN -> Text @@ -153,45 +107,80 @@ awsAssumeRole -> m a -- ^ Action to run as the assumed role -> m a -awsAssumeRole role sessionName f = do +assumeRole role sessionName f = do let req = newAssumeRole role sessionName - assumeEnv <- awsSimple "sts:AssumeRole" req $ \resp -> do + assumeEnv <- simple req $ \resp -> do let creds = resp ^. assumeRoleResponse_credentials - token <- creds ^. authEnv_sessionToken + token <- creds ^. Amazonka.authEnv_sessionToken let - accessKeyId = creds ^. authEnv_accessKeyId - secretAccessKey = creds ^. authEnv_secretAccessKey . _Sensitive + accessKeyId = creds ^. Amazonka.authEnv_accessKeyId + secretAccessKey = creds ^. Amazonka.authEnv_secretAccessKey . _Sensitive + sessionToken = token ^. _Sensitive - pure $ fromSession accessKeyId secretAccessKey $ token ^. _Sensitive + pure $ fromSession accessKeyId secretAccessKey sessionToken - local (awsEnvL . unL %~ assumeEnv) f + localEnv assumeEnv f +{-# DEPRECATED assumeRole "Use withAssumedRole instead" #-} + +-- | Assume a role using the @sts:AssumeRole@ API and run an action +withAssumedRole + :: (MonadUnliftIO m, MonadAWS m) + => Text + -- ^ Role ARN + -> Text + -- ^ Role session name + -> m a + -- ^ Action to run as the assumed role + -> m a +withAssumedRole roleArn roleSessionName f = do + keys <- withRunInIO $ \runInIO -> do + let getCredentials = do + resp <- + runInIO + $ send + $ newAssumeRole roleArn roleSessionName + pure $ resp ^. assumeRoleResponse_credentials -awsWithin :: (MonadReader env m, HasAwsEnv env) => Region -> m a -> m a -awsWithin r = local $ awsEnvL . unL . env_region .~ r + fetchAuthInBackground getCredentials -awsTimeout :: (MonadReader env m, HasAwsEnv env) => Seconds -> m a -> m a -awsTimeout t = local $ over (awsEnvL . unL) (globalTimeout t) + localEnv (\env -> env {auth = Identity keys}) f newtype AccountId = AccountId { unAccountId :: Text } deriving newtype (Eq, Ord, Show, ToJSON) +-- | Handle 'AuthError', log it and 'exitFailure' +handlingAuthError :: (MonadUnliftIO m, MonadLogger m) => m a -> m a +handlingAuthError = + handling _AuthError $ \e -> do + logError $ msg :# ["exception" .= displayException e] + exitFailure + where + msg = + "No AWS credentials were found in your environment." + <> continuation "For details of where stackctl looks for credentials, see:" + <> continuation + "https://hackage.haskell.org/package/amazonka-2.0/docs/Amazonka-Auth.html#v:discover" + + -- Shift the continuation lines to line up with the first message line + continuation x = "\n" <> T.replicate 32 " " <> x + -- | Handle 'ServiceError', log it and 'exitFailure' -- -- This is useful at the top-level of the app, where we'd be crashing anyway. It -- makes things more readable and easier to debug. handlingServiceError :: (MonadUnliftIO m, MonadLogger m) => m a -> m a handlingServiceError = - handleJust @_ @SomeException (^? _ServiceError) $ \e -> do + handling _ServiceError $ \e -> do logError $ "Exiting due to AWS Service error" - :# [ "code" .= toText (e ^. serviceError_code) - , "message" .= fmap toText (e ^. serviceError_message) - , "requestId" .= fmap toText (e ^. serviceError_requestId) - ] + :# [ "code" .= toText (e ^. serviceError_code) + , "message" .= fmap toText (e ^. serviceError_message) + , "requestId" .= fmap toText (e ^. serviceError_requestId) + ] exitFailure formatServiceError :: ServiceError -> Text diff --git a/src/Stackctl/AWS/EC2.hs b/src/Stackctl/AWS/EC2.hs index 92528f5..a41402b 100644 --- a/src/Stackctl/AWS/EC2.hs +++ b/src/Stackctl/AWS/EC2.hs @@ -6,13 +6,13 @@ import Stackctl.Prelude import Amazonka.EC2.DescribeAvailabilityZones import Amazonka.EC2.Types (AvailabilityZone (..)) -import Stackctl.AWS.Core +import Stackctl.AWS.Core as AWS awsEc2DescribeFirstAvailabilityZoneRegionName - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m Region + :: (MonadIO m, MonadAWS m) => m Region awsEc2DescribeFirstAvailabilityZoneRegionName = do let req = newDescribeAvailabilityZones - awsSimple "DescribeAvailabilityZones" req $ \resp -> do + AWS.simple req $ \resp -> do azs <- resp ^. describeAvailabilityZonesResponse_availabilityZones az <- listToMaybe azs rn <- regionName az diff --git a/src/Stackctl/AWS/Lambda.hs b/src/Stackctl/AWS/Lambda.hs index f111736..c0c9fba 100644 --- a/src/Stackctl/AWS/Lambda.hs +++ b/src/Stackctl/AWS/Lambda.hs @@ -10,10 +10,11 @@ module Stackctl.AWS.Lambda import Stackctl.Prelude hiding (trace) +import Amazonka (globalTimeout) import Amazonka.Lambda.Invoke import Data.Aeson import qualified Data.ByteString.Lazy as BSL -import Stackctl.AWS.Core +import Stackctl.AWS.Core as AWS data LambdaInvokeResult = LambdaInvokeSuccess ByteString @@ -59,14 +60,13 @@ data LambdaError = LambdaError , errorMessage :: Text , trace :: [Text] } - deriving stock (Show, Generic) + deriving stock (Eq, Show, Generic) deriving anyclass (FromJSON, ToJSON) awsLambdaInvoke - :: ( MonadResource m + :: ( MonadIO m , MonadLogger m - , MonadReader env m - , HasAwsEnv env + , MonadAWS m , ToJSON a ) => Text @@ -78,8 +78,8 @@ awsLambdaInvoke name payload = do -- Match Lambda's own limit (15 minutes) and add some buffer resp <- - awsTimeout 905 - $ awsSend + AWS.localEnv (globalTimeout 905) + $ AWS.send $ newInvoke name $ BSL.toStrict $ encode @@ -93,11 +93,11 @@ awsLambdaInvoke name payload = do logDebug $ "Function result" - :# [ "name" .= name - , "status" .= status - , "error" .= mError - , "functionError" .= mFunctionError - ] + :# [ "name" .= name + , "status" .= status + , "error" .= mError + , "functionError" .= mFunctionError + ] pure $ if diff --git a/src/Stackctl/AWS/Orphans.hs b/src/Stackctl/AWS/Orphans.hs index a1867f7..615cbfe 100644 --- a/src/Stackctl/AWS/Orphans.hs +++ b/src/Stackctl/AWS/Orphans.hs @@ -14,8 +14,20 @@ import Amazonka.CloudFormation.Types import Data.Aeson import GHC.Generics (Rep) +-- TODO: upstream +deriving newtype instance MonadUnliftIO m => MonadUnliftIO (WithLogger env m) + -- Makes it syntactally easier to do a bunch of these newtype Generically a = Generically {unGenerically :: a} + +instance + ( Generic a + , GFromJSON Zero (Rep a) + ) + => FromJSON (Generically a) + where + parseJSON = fmap Generically . genericParseJSON defaultOptions + instance ( Generic a , GToJSON' Value Zero (Rep a) @@ -26,43 +38,25 @@ instance toJSON = genericToJSON defaultOptions . unGenerically toEncoding = genericToEncoding defaultOptions . unGenerically -deriving via - (Generically DescribeChangeSetResponse) - instance - ToJSON DescribeChangeSetResponse -deriving via - (Generically Tag) - instance - ToJSON Tag -deriving via - (Generically Parameter) - instance - ToJSON Parameter -deriving via - (Generically RollbackConfiguration) - instance - ToJSON RollbackConfiguration -deriving via - (Generically RollbackTrigger) - instance - ToJSON RollbackTrigger -deriving via - (Generically Change) - instance - ToJSON Change -deriving via - (Generically ResourceChange) - instance - ToJSON ResourceChange -deriving via - (Generically ModuleInfo) - instance - ToJSON ModuleInfo -deriving via - (Generically ResourceChangeDetail) - instance - ToJSON ResourceChangeDetail -deriving via - (Generically ResourceTargetDefinition) - instance - ToJSON ResourceTargetDefinition +{- FOURMOLU_DISABLE -} + +deriving via (Generically Change) instance FromJSON Change +deriving via (Generically Change) instance ToJSON Change +deriving via (Generically DescribeChangeSetResponse) instance FromJSON DescribeChangeSetResponse +deriving via (Generically DescribeChangeSetResponse) instance ToJSON DescribeChangeSetResponse +deriving via (Generically ModuleInfo) instance FromJSON ModuleInfo +deriving via (Generically ModuleInfo) instance ToJSON ModuleInfo +deriving via (Generically Parameter) instance FromJSON Parameter +deriving via (Generically Parameter) instance ToJSON Parameter +deriving via (Generically ResourceChange) instance FromJSON ResourceChange +deriving via (Generically ResourceChange) instance ToJSON ResourceChange +deriving via (Generically ResourceChangeDetail) instance FromJSON ResourceChangeDetail +deriving via (Generically ResourceChangeDetail) instance ToJSON ResourceChangeDetail +deriving via (Generically ResourceTargetDefinition) instance FromJSON ResourceTargetDefinition +deriving via (Generically ResourceTargetDefinition) instance ToJSON ResourceTargetDefinition +deriving via (Generically RollbackConfiguration) instance FromJSON RollbackConfiguration +deriving via (Generically RollbackConfiguration) instance ToJSON RollbackConfiguration +deriving via (Generically RollbackTrigger) instance FromJSON RollbackTrigger +deriving via (Generically RollbackTrigger) instance ToJSON RollbackTrigger +deriving via (Generically Tag) instance FromJSON Tag +deriving via (Generically Tag) instance ToJSON Tag diff --git a/src/Stackctl/AWS/STS.hs b/src/Stackctl/AWS/STS.hs index 316da27..0f8534c 100644 --- a/src/Stackctl/AWS/STS.hs +++ b/src/Stackctl/AWS/STS.hs @@ -5,10 +5,9 @@ module Stackctl.AWS.STS import Stackctl.Prelude import Amazonka.STS.GetCallerIdentity -import Stackctl.AWS.Core +import Stackctl.AWS.Core as AWS -awsGetCallerIdentityAccount - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m AccountId +awsGetCallerIdentityAccount :: (MonadIO m, MonadAWS m) => m AccountId awsGetCallerIdentityAccount = do - awsSimple "GetCallerIdentity" newGetCallerIdentity $ \resp -> do + AWS.simple newGetCallerIdentity $ \resp -> do AccountId <$> resp ^. getCallerIdentityResponse_account diff --git a/src/Stackctl/AWS/Scope.hs b/src/Stackctl/AWS/Scope.hs index 6846ba9..0b56e99 100644 --- a/src/Stackctl/AWS/Scope.hs +++ b/src/Stackctl/AWS/Scope.hs @@ -26,20 +26,16 @@ awsScopeSpecPatterns :: AwsScope -> [Pattern] awsScopeSpecPatterns AwsScope {..} = [ compile $ "stacks" - unpack (unAccountId awsAccountId) - <> ".*" - unpack (fromRegion awsRegion) - <> "**" - "*" - <.> "yaml" + unpack (unAccountId awsAccountId) <> ".*" + unpack (fromRegion awsRegion) + "**" + "*" <.> "yaml" , compile $ "stacks" - "*." - <> unpack (unAccountId awsAccountId) - unpack (fromRegion awsRegion) - <> "**" - "*" - <.> "yaml" + "*." <> unpack (unAccountId awsAccountId) + unpack (fromRegion awsRegion) + "**" + "*" <.> "yaml" ] awsScopeSpecStackName :: AwsScope -> FilePath -> Maybe StackName @@ -50,13 +46,13 @@ awsScopeSpecStackName scope path = do -- pretty fast and loose with the "parsing" step pure $ path -- stacks/account/region/x/y.yaml - & splitPath -- [stacks/, account/, region/, x/, y.yaml] - & drop 3 -- [x, y.yaml] - & joinPath -- x/y.yaml - & dropExtension -- x/y - & pack - & T.replace "/" "-" -- x-y - & StackName + & splitPath -- [stacks/, account/, region/, x/, y.yaml] + & drop 3 -- [x, y.yaml] + & joinPath -- x/y.yaml + & dropExtension -- x/y + & pack + & T.replace "/" "-" -- x-y + & StackName class HasAwsScope env where awsScopeL :: Lens' env AwsScope @@ -64,8 +60,7 @@ class HasAwsScope env where instance HasAwsScope AwsScope where awsScopeL = id -fetchAwsScope - :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m AwsScope +fetchAwsScope :: (MonadIO m, MonadAWS m) => m AwsScope fetchAwsScope = AwsScope <$> awsGetCallerIdentityAccount diff --git a/src/Stackctl/Action.hs b/src/Stackctl/Action.hs index 294db29..ed05c0a 100644 --- a/src/Stackctl/Action.hs +++ b/src/Stackctl/Action.hs @@ -21,20 +21,25 @@ module Stackctl.Action import Stackctl.Prelude hiding (on) +import Blammo.Logging.Logger (flushLogger) import Data.Aeson import Data.List (find) +import qualified Data.List.NonEmpty as NE import Stackctl.AWS import Stackctl.AWS.Lambda +import Stackctl.OneOrListOf +import qualified Stackctl.OneOrListOf as OneOrListOf +import System.Process.Typed data Action = Action { on :: ActionOn - , run :: ActionRun + , run :: OneOrListOf ActionRun } deriving stock (Eq, Show, Generic) deriving anyclass (FromJSON, ToJSON) -newAction :: ActionOn -> ActionRun -> Action -newAction = Action +newAction :: ActionOn -> [ActionRun] -> Action +newAction on runs = Action {on, run = OneOrListOf.fromList runs} data ActionOn = PostDeploy deriving stock (Eq, Show, Generic) @@ -54,31 +59,45 @@ instance ToJSON ActionOn where data ActionRun = InvokeLambdaByStackOutput Text | InvokeLambdaByName Text + | Exec (NonEmpty String) + | Shell String deriving stock (Eq, Show) instance FromJSON ActionRun where parseJSON = withObject "ActionRun" $ \o -> (InvokeLambdaByStackOutput <$> o .: "InvokeLambdaByStackOutput") <|> (InvokeLambdaByName <$> o .: "InvokeLambdaByName") + <|> (Exec <$> o .: "Exec") + <|> (Shell <$> o .: "Shell") instance ToJSON ActionRun where toJSON = object . \case InvokeLambdaByStackOutput name -> ["InvokeLambdaByStackOutput" .= name] InvokeLambdaByName name -> ["InvokeLambdaByName" .= name] + Exec args -> ["Exec" .= args] + Shell arg -> ["Shell" .= arg] toEncoding = pairs . \case InvokeLambdaByStackOutput name -> "InvokeLambdaByStackOutput" .= name InvokeLambdaByName name -> "InvokeLambdaByName" .= name + Exec args -> "Exec" .= args + Shell arg -> "Shell" .= arg data ActionFailure = NoSuchOutput | InvokeLambdaFailure + | ExecFailure ExitCode deriving stock (Show) deriving anyclass (Exception) runActions - :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env) + :: ( MonadIO m + , MonadLogger m + , MonadAWS m + , MonadReader env m + , HasLogger env + ) => StackName -> ActionOn -> [Action] @@ -90,27 +109,34 @@ shouldRunOn :: Action -> ActionOn -> Bool shouldRunOn Action {on} on' = on == on' runAction - :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env) + :: ( MonadIO m + , MonadLogger m + , MonadAWS m + , MonadReader env m + , HasLogger env + ) => StackName -> Action -> m () runAction stackName Action {on, run} = do logInfo $ "Running action" :# ["on" .= on, "run" .= run] - case run of + for_ run $ \case InvokeLambdaByStackOutput outputName -> do outputs <- awsCloudFormationDescribeStackOutputs stackName case findOutputValue outputName outputs of Nothing -> do logError $ "Output not found" - :# [ "stackName" .= stackName - , "desiredOutput" .= outputName - , "availableOutputs" .= map (^. output_outputKey) outputs - ] + :# [ "stackName" .= stackName + , "desiredOutput" .= outputName + , "availableOutputs" .= map (^. output_outputKey) outputs + ] throwIO NoSuchOutput Just name -> invoke name InvokeLambdaByName name -> invoke name + Exec args -> execProcessAction (NE.head args) (NE.tail args) + Shell arg -> execProcessAction "sh" ["-c", arg] where invoke name = do result <- awsLambdaInvoke name payload @@ -122,3 +148,15 @@ runAction stackName Action {on, run} = do findOutputValue :: Text -> [Output] -> Maybe Text findOutputValue name = view output_outputValue <=< find ((== Just name) . view output_outputKey) + +execProcessAction + :: (MonadIO m, MonadLogger m, MonadReader env m, HasLogger env) + => String + -> [String] + -> m () +execProcessAction cmd args = do + logDebug $ "runProcess" :# ["command" .= (cmd : args)] + flushLogger + + ec <- runProcess $ proc cmd args + unless (ec == ExitSuccess) $ throwIO $ ExecFailure ec diff --git a/src/Stackctl/AutoSSO.hs b/src/Stackctl/AutoSSO.hs index cf20e3a..6d03a26 100644 --- a/src/Stackctl/AutoSSO.hs +++ b/src/Stackctl/AutoSSO.hs @@ -13,7 +13,7 @@ import Amazonka.SSO (_UnauthorizedException) import Data.Semigroup (Last (..)) import qualified Env import Options.Applicative -import Stackctl.AWS.Core (formatServiceError) +import Stackctl.AWS.Core as AWS (formatServiceError) import Stackctl.Prompt import System.Process.Typed import UnliftIO.Exception.Lens (catching) diff --git a/src/Stackctl/CLI.hs b/src/Stackctl/CLI.hs index 1bebca1..e12671e 100644 --- a/src/Stackctl/CLI.hs +++ b/src/Stackctl/CLI.hs @@ -7,10 +7,14 @@ module Stackctl.CLI import Stackctl.Prelude +import Blammo.Logging.LogSettings import qualified Blammo.Logging.LogSettings.Env as LoggingEnv +import Control.Monad.AWS as AWS +import Control.Monad.AWS.ViaReader as AWS import Control.Monad.Catch (MonadCatch) -import Control.Monad.Trans.Resource (ResourceT, runResourceT) -import Stackctl.AWS +import Control.Monad.Trans.Resource (MonadResource, ResourceT, runResourceT) +import Stackctl.AWS.Core (handlingAuthError) +import qualified Stackctl.AWS.Core as AWS import Stackctl.AWS.Scope import Stackctl.AutoSSO import Stackctl.ColorOption @@ -24,7 +28,7 @@ data App options = App , appConfig :: Config , appOptions :: options , appAwsScope :: AwsScope - , appAwsEnv :: AwsEnv + , appAwsEnv :: AWS.Env } optionsL :: Lens' (App options) options @@ -39,8 +43,8 @@ instance HasConfig (App options) where instance HasAwsScope (App options) where awsScopeL = lens appAwsScope $ \x y -> x {appAwsScope = y} -instance HasAwsEnv (App options) where - awsEnvL = lens appAwsEnv $ \x y -> x {appAwsEnv = y} +instance AWS.HasEnv (App options) where + envL = lens appAwsEnv $ \x y -> x {appAwsEnv = y} instance HasDirectoryOption options => HasDirectoryOption (App options) where directoryOptionL = optionsL . directoryOptionL @@ -58,7 +62,7 @@ instance HasAutoSSOOption options => HasAutoSSOOption (App options) where autoSSOOptionL = optionsL . autoSSOOptionL newtype AppT app m a = AppT - { unAppT :: ReaderT app (LoggingT (ResourceT m)) a + { unAppT :: ReaderT app (ResourceT m) a } deriving newtype ( Functor @@ -68,11 +72,13 @@ newtype AppT app m a = AppT , MonadUnliftIO , MonadResource , MonadReader app - , MonadLogger , MonadThrow , MonadCatch , MonadMask ) + deriving (MonadAWS) via (ReaderAWS (AppT app m)) + deriving (MonadLogger) via (WithLogger app (ResourceT m)) + deriving (MonadLoggerIO) via (WithLogger app (ResourceT m)) runAppT :: ( MonadMask m @@ -91,36 +97,38 @@ runAppT options f = do . setLogSettingsConcurrency (Just 1) $ defaultLogSettings - logger <- - newLogger - $ adjustLogSettings - (options ^. colorOptionL) - (options ^. verboseOptionL) - envLogSettings - - app <- runResourceT $ runLoggerLoggingT logger $ do - aws <- runReaderT (handleAutoSSO options awsEnvDiscover) logger - - App logger - <$> loadConfigOrExit - <*> pure options - <*> runReaderT fetchAwsScope aws - <*> pure aws - - let - AwsScope {..} = appAwsScope app - - context = - [ "region" .= awsRegion - , "accountId" .= awsAccountId - , "accountName" .= awsAccountName - ] - - runResourceT - $ runLoggerLoggingT app - $ flip runReaderT app - $ withThreadContext context - $ unAppT f + let logSettings = + adjustLogSettings + (options ^. colorOptionL) + (options ^. verboseOptionL) + envLogSettings + + withLogger logSettings $ \appLogger -> do + appAwsEnv <- runWithLogger appLogger + $ handleAutoSSO options + $ handlingAuthError + $ do + logDebug "Discovering AWS credentials" + AWS.discover + appConfig <- runWithLogger appLogger loadConfigOrExit + appAwsScope <- AWS.runEnvT fetchAwsScope appAwsEnv + + let + AwsScope {..} = appAwsScope + + context = + [ "region" .= awsRegion + , "accountId" .= awsAccountId + , "accountName" .= awsAccountName + ] + + appOptions = options + app = App {..} + + runResourceT + $ flip runReaderT app + $ withThreadContext context + $ unAppT f adjustLogSettings :: Maybe ColorOption -> Verbosity -> LogSettings -> LogSettings diff --git a/src/Stackctl/CancelHandler.hs b/src/Stackctl/CancelHandler.hs new file mode 100644 index 0000000..05ec02e --- /dev/null +++ b/src/Stackctl/CancelHandler.hs @@ -0,0 +1,33 @@ +module Stackctl.CancelHandler + ( with + , install + , remove + , trigger + ) where + +import Stackctl.Prelude + +import System.Posix.Signals + +-- | Install a 'keyboardSignal' handler, run an action, then remove it +with :: MonadUnliftIO m => m a -> m b -> m b +with f = bracket_ (install f) remove + +-- | Install a 'keyboardSignal' handler that runs the given action once +install :: MonadUnliftIO m => m a -> m () +install f = do + withRunInIO $ \runInIO -> do + let handler = Catch $ void $ do + remove -- so next Ctl-C will truly cancel + runInIO f + void $ installHandler keyboardSignal handler Nothing + +-- | Remove the current handler for 'keyboardSignal' (i.e. install 'Default') +remove :: MonadIO m => m () +remove = liftIO $ void $ installHandler keyboardSignal Default Nothing + +-- | Trigger the installed 'keyboardSignal' handler +-- +-- This is used by our test suite. +trigger :: MonadIO m => m () +trigger = liftIO $ raiseSignal keyboardSignal diff --git a/src/Stackctl/Config.hs b/src/Stackctl/Config.hs index 925a6ea..2092179 100644 --- a/src/Stackctl/Config.hs +++ b/src/Stackctl/Config.hs @@ -63,7 +63,8 @@ configErrorMessage = \case :# ["error" .= Yaml.prettyPrintParseException ex] ConfigInvalid errs -> "Invalid configuration" :# ["errors" .= errs] ConfigVersionNotSatisfied rv v -> - "Incompatible Stackctl version" :# ["current" .= v, "required" .= show rv] + "Incompatible Stackctl version" + :# ["current" .= v, "required" .= show (requiredVersionToText rv)] loadConfigOrExit :: (MonadIO m, MonadLogger m) => m Config loadConfigOrExit = either die pure =<< loadConfig @@ -74,9 +75,10 @@ loadConfigOrExit = either die pure =<< loadConfig loadConfig :: MonadIO m => m (Either ConfigError Config) loadConfig = - runExceptT $ getConfigFile >>= \case - Nothing -> pure emptyConfig - Just cf -> loadConfigFrom cf + runExceptT + $ getConfigFile >>= \case + Nothing -> pure emptyConfig + Just cf -> loadConfigFrom cf loadConfigFrom :: (MonadIO m, MonadError ConfigError m) => FilePath -> m Config loadConfigFrom path = loadConfigFromBytes =<< liftIO (readFileBinary path) diff --git a/src/Stackctl/Config/RequiredVersion.hs b/src/Stackctl/Config/RequiredVersion.hs index 0d83a22..842f2f3 100644 --- a/src/Stackctl/Config/RequiredVersion.hs +++ b/src/Stackctl/Config/RequiredVersion.hs @@ -24,7 +24,7 @@ data RequiredVersion = RequiredVersion { requiredVersionOp :: RequiredVersionOp , requiredVersionCompareWith :: Version } - deriving stock (Eq, Show) + deriving stock (Eq, Ord, Show) instance Arbitrary RequiredVersion where arbitrary = RequiredVersion <$> arbitrary <*> arbitrary @@ -54,7 +54,7 @@ requiredVersionFromText = fromWords . T.words ws -> Left $ show (unpack $ T.unwords ws) - <> " did not parse as optional operator and version string" + <> " did not parse as optional operator and version string" parseRequiredVersion :: Text -> Text -> Either String RequiredVersion parseRequiredVersion op w = RequiredVersion <$> parseOp op <*> parseVersion w @@ -71,8 +71,8 @@ requiredVersionFromText = fromWords . T.words op -> Left $ "Invalid comparison operator (" - <> unpack op - <> "), may only be =, <, <=, >, >=, or =~" + <> unpack op + <> "), may only be =, <, <=, >, >=, or =~" parseVersion :: Text -> Either String Version parseVersion t = @@ -96,7 +96,7 @@ data RequiredVersionOp | RequiredVersionGT | RequiredVersionGTE | RequiredVersionIsh - deriving stock (Eq, Show, Bounded, Enum) + deriving stock (Eq, Ord, Show, Bounded, Enum) instance Arbitrary RequiredVersionOp where arbitrary = arbitraryBoundedEnum diff --git a/src/Stackctl/DirectoryOption.hs b/src/Stackctl/DirectoryOption.hs index a156a84..9acb7d9 100644 --- a/src/Stackctl/DirectoryOption.hs +++ b/src/Stackctl/DirectoryOption.hs @@ -30,7 +30,7 @@ instance HasDirectoryOption DirectoryOption where envDirectoryOption :: Env.Parser Env.Error DirectoryOption envDirectoryOption = Env.var (Env.str <=< Env.nonempty) "DIRECTORY" - $ Env.help "Operate on specifications in this directory" + $ Env.help directoryHelp directoryOption :: Parser DirectoryOption directoryOption = @@ -39,6 +39,10 @@ directoryOption = [ short 'd' , long "directory" , metavar "PATH" - , help "Operate on specifications in PATH" + , help directoryHelp , action "directory" ] + +directoryHelp :: String +directoryHelp = + "Use the stack collection located at PATH (default: current working directory)" diff --git a/src/Stackctl/FilterOption.hs b/src/Stackctl/FilterOption.hs index 985dfbe..90ae154 100644 --- a/src/Stackctl/FilterOption.hs +++ b/src/Stackctl/FilterOption.hs @@ -44,8 +44,8 @@ envFilterOption items = var "FILTERS" <|> var "FILTER" Env.var (first Env.UnreadError . readFilterOption) name $ Env.help $ "Filter " - <> items - <> " by patterns" + <> items + <> " by patterns" filterOption :: String -> Parser FilterOption filterOption items = @@ -77,7 +77,7 @@ expandPatterns t = map compile $ s : expanded suffixed | "*" `T.isSuffixOf` t || hasExtension s = [] - | otherwise = (s "*") : map (s <.>) extensions + | otherwise = (s "**" "*") : map (s <.>) extensions extensions = ["json", "yaml"] diff --git a/src/Stackctl/OneOrListOf.hs b/src/Stackctl/OneOrListOf.hs new file mode 100644 index 0000000..0c4d401 --- /dev/null +++ b/src/Stackctl/OneOrListOf.hs @@ -0,0 +1,56 @@ +module Stackctl.OneOrListOf + ( OneOrListOf + , fromList + ) where + +import Stackctl.Prelude + +import Data.Aeson + +-- | Type representing one @a@ or a list of @a@ +-- +-- This type is isomorphic both @'NonEmpty' a@ and @'Either' a [a]@. Its primary +-- use-case is to parse Yaml (through its 'FromJSON') where users may specify a +-- list of values, but specifying a single value is worth supporting, typically +-- for backwards-compatibility: +-- +-- @ +-- something: +-- field: +-- - one +-- - two +-- +-- something: +-- field: one # => should be treated like field: [one] +-- @ +-- +-- The 'Foldable' instance should be used to treat the value like a list, such +-- as extracting it directly via 'toList'. +-- +-- Implementation note: this type preserves the form in which it was decoded (in +-- other words, it's not a @newtype@ over one of the isomorphic types mentioned +-- above), so that we can encode it back out in the same format. +data OneOrListOf a = One a | List [a] + deriving stock (Eq, Show, Generic, Foldable) + +fromList :: [a] -> OneOrListOf a +fromList = List + +instance Semigroup (OneOrListOf a) where + One a <> One b = List [a, b] + One a <> List bs = List $ a : bs + List as <> One b = List $ as <> [b] + List as <> List bs = List $ as <> bs + +instance FromJSON a => FromJSON (OneOrListOf a) where + parseJSON = \case + Array xs -> List . toList <$> traverse parseJSON xs + v -> One <$> parseJSON v + +instance ToJSON a => ToJSON (OneOrListOf a) where + toJSON = \case + One a -> toJSON a + List as -> toJSON as + toEncoding = \case + One a -> toEncoding a + List as -> toEncoding as diff --git a/src/Stackctl/Options.hs b/src/Stackctl/Options.hs index ba3f927..6f472bc 100644 --- a/src/Stackctl/Options.hs +++ b/src/Stackctl/Options.hs @@ -55,11 +55,11 @@ envParser :: Env.Parser Env.Error Options envParser = Env.prefixed "STACKCTL_" $ Options - <$> optional envDirectoryOption - <*> optional (envFilterOption "specifications") - <*> pure mempty -- use LOG_COLOR - <*> pure mempty -- use LOG_LEVEL - <*> optional envAutoSSOOption + <$> optional envDirectoryOption + <*> optional (envFilterOption "specifications") + <*> pure mempty -- use LOG_COLOR + <*> pure mempty -- use LOG_LEVEL + <*> optional envAutoSSOOption -- brittany-disable-next-binding diff --git a/src/Stackctl/Prelude.hs b/src/Stackctl/Prelude.hs index 614ec85..370271a 100644 --- a/src/Stackctl/Prelude.hs +++ b/src/Stackctl/Prelude.hs @@ -20,6 +20,8 @@ import RIO as X hiding ) import Blammo.Logging as X +import Blammo.Logging.Setup as X +import Blammo.Logging.ThreadContext as X import Control.Error.Util as X (hush, note) import Data.Aeson as X (ToJSON (..), object) import Data.Text as X (pack, unpack) diff --git a/src/Stackctl/RemovedStack.hs b/src/Stackctl/RemovedStack.hs index 3c3ef14..12c2ace 100644 --- a/src/Stackctl/RemovedStack.hs +++ b/src/Stackctl/RemovedStack.hs @@ -7,37 +7,41 @@ import Stackctl.Prelude import Control.Error.Util (hoistMaybe) import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) import Stackctl.AWS.CloudFormation -import Stackctl.AWS.Core +import Stackctl.AWS.Core as AWS import Stackctl.AWS.Scope +import Stackctl.DirectoryOption import Stackctl.FilterOption import UnliftIO.Directory (doesFileExist) inferRemovedStacks :: ( MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadReader env m - , HasAwsEnv env , HasAwsScope env + , HasDirectoryOption env , HasFilterOption env ) => m [Stack] inferRemovedStacks = do scope <- view awsScopeL paths <- view $ filterOptionL . to filterOptionToPaths - catMaybes <$> traverse (findRemovedStack scope) paths + dir <- view $ directoryOptionL . to unDirectoryOption + catMaybes <$> traverse (findRemovedStack scope dir) paths findRemovedStack - :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env) + :: (MonadUnliftIO m, MonadAWS m) => AwsScope -> FilePath + -- ^ Root directory + -> FilePath -> m (Maybe Stack) -findRemovedStack scope path = runMaybeT $ do +findRemovedStack scope dir path = runMaybeT $ do -- The filter is a full path to a specification in the current -- account/region... stackName <- hoistMaybe $ awsScopeSpecStackName scope path -- that no longer exists... - guard . not =<< doesFileExist path + guard . not =<< doesFileExist (dir path) -- but the Stack it would point to does MaybeT $ awsCloudFormationDescribeStackMaybe stackName diff --git a/src/Stackctl/Spec/Capture.hs b/src/Stackctl/Spec/Capture.hs index 0771783..d71d790 100644 --- a/src/Stackctl/Spec/Capture.hs +++ b/src/Stackctl/Spec/Capture.hs @@ -13,6 +13,13 @@ import Stackctl.Config (HasConfig) import Stackctl.DirectoryOption (HasDirectoryOption) import Stackctl.Spec.Generate import Stackctl.StackSpec +import Stackctl.StackSpecYaml + ( StackSpecYaml (..) + , TagYaml (..) + , parameterYaml + , parametersYaml + , tagsYaml + ) import System.FilePath.Glob data CaptureOptions = CaptureOptions @@ -77,11 +84,10 @@ parseCaptureOptions = runCapture :: ( MonadMask m , MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasAwsScope env - , HasAwsEnv env , HasConfig env , HasDirectoryOption env ) @@ -92,7 +98,7 @@ runCapture CaptureOptions {..} = do setScopeName scope = maybe scope (\name -> scope {awsAccountName = name}) scoAccountName - generate' stack template path templatePath = do + generate' stack template mPath mTemplatePath = do let stackName = StackName $ stack ^. stack_stackName templateBody = templateBodyFromValue template @@ -100,30 +106,34 @@ runCapture CaptureOptions {..} = do void $ local (awsScopeL %~ setScopeName) $ generate - Generate - { gDescription = stackDescription stack - , gDepends = scoDepends - , gActions = Nothing - , gParameters = parameters stack - , gCapabilities = capabilities stack - , gTags = tags stack - , gSpec = case path of - Nothing -> GenerateSpec stackName - Just sp -> GenerateSpecTo stackName sp - , gTemplate = case templatePath of - Nothing -> GenerateTemplate templateBody scoTemplateFormat - Just tp -> GenerateTemplateTo templateBody tp - , gOverwrite = False - } + False + ( case mPath of + Nothing -> GenerateSpec stackName + Just sp -> GenerateSpecTo stackName sp + ) + ( case mTemplatePath of + Nothing -> GenerateTemplate templateBody scoTemplateFormat + Just tp -> GenerateTemplateTo templateBody tp + ) + ( \templatePath -> + StackSpecYaml + { ssyDescription = stackDescription stack + , ssyTemplate = templatePath + , ssyDepends = scoDepends + , ssyActions = Nothing + , ssyParameters = parametersYaml . mapMaybe parameterYaml <$> parameters stack + , ssyCapabilities = capabilities stack + , ssyTags = tagsYaml . map TagYaml <$> tags stack + } + ) results <- awsCloudFormationGetStackNamesMatching scoStackName case results of [] -> do logError - $ "No Active Stacks match " - <> pack (decompile scoStackName) - :# [] + $ "No Active Stacks match " <> pack (decompile scoStackName) + :# [] exitFailure [stackName] -> do stack <- awsCloudFormationDescribeStack stackName diff --git a/src/Stackctl/Spec/Cat.hs b/src/Stackctl/Spec/Cat.hs index b438326..3ef1ffa 100644 --- a/src/Stackctl/Spec/Cat.hs +++ b/src/Stackctl/Spec/Cat.hs @@ -54,8 +54,8 @@ parseCatOptions = ) runCat - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m , MonadLogger m , MonadReader env m , HasLogger env @@ -138,11 +138,11 @@ prettyPrintStackSpecYaml Colors {..} name StackSpecYaml {..} = kvs <- f <$> mA pure $ [cyan label <> ":"] - <> map - ( \(k, mV) -> - " " <> cyan k <> ":" <> maybe "" (\v -> " " <> green v) mV - ) - kvs + <> map + ( \(k, mV) -> + " " <> cyan k <> ":" <> maybe "" (\v -> " " <> green v) mV + ) + kvs ppList :: Text -> (a -> [Text]) -> Maybe a -> [Text] ppList label f = maybe [] (((cyan label <> ":") :) . f) @@ -186,8 +186,8 @@ prettyPrintTemplate Colors {..} val = displayObjectProperty = displayPropertyWith @(HashMap Text Value) $ map ((" - " <>) . green) - . sort - . HashMap.keys + . sort + . HashMap.keys displayPropertyWith :: (FromJSON a, ToJSON a) => (a -> [Text]) -> Text -> [Text] diff --git a/src/Stackctl/Spec/Changes.hs b/src/Stackctl/Spec/Changes.hs index e3fc021..662f3f8 100644 --- a/src/Stackctl/Spec/Changes.hs +++ b/src/Stackctl/Spec/Changes.hs @@ -52,12 +52,11 @@ parseChangesOptions = runChanges :: ( MonadMask m , MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env , HasAwsScope env - , HasAwsEnv env , HasConfig env , HasDirectoryOption env , HasFilterOption env diff --git a/src/Stackctl/Spec/Changes/Format.hs b/src/Stackctl/Spec/Changes/Format.hs index 0ba7b16..359a27c 100644 --- a/src/Stackctl/Spec/Changes/Format.hs +++ b/src/Stackctl/Spec/Changes/Format.hs @@ -19,6 +19,7 @@ import Stackctl.Colors data Format = FormatTTY | FormatPullRequest + deriving stock (Bounded, Enum, Show) data OmitFull = OmitFull @@ -75,10 +76,11 @@ formatTTY colors@Colors {..} name mChangeSet = case (mChangeSet, rChanges) of (Nothing, _) -> "No changes for " <> name (_, Nothing) -> "Metadata only changes (e.g. Tags or Outputs)" (_, Just rcs) -> - ("\n" <>) $ (<> "\n") $ mconcat $ ("Changes for " <> cyan name <> ":") - : map - (("\n " <>) . formatResourceChange) - (NE.toList rcs) + ("\n" <>) + $ (<> "\n") + $ mconcat + $ ("Changes for " <> cyan name <> ":") + : map (("\n " <>) . formatResourceChange) (NE.toList rcs) where rChanges = do cs <- mChangeSet @@ -148,20 +150,20 @@ commentBody omitFull cs rcs = , "\n| Action | Logical Id | Physical Id | Type | Replacement | Scope | Details |" , "\n| --- | --- | --- | --- | --- | --- | --- |" ] - <> map commentTableRow (NE.toList rcs) - <> case omitFull of - OmitFull -> [] - IncludeFull -> - [ "\n" - , "\n
" - , "\nFull changes" - , "\n" - , "\n```json" - , "\n" <> changeSetJSON cs - , "\n```" - , "\n" - , "\n
" - ] + <> map commentTableRow (NE.toList rcs) + <> case omitFull of + OmitFull -> [] + IncludeFull -> + [ "\n" + , "\n
" + , "\nFull changes" + , "\n" + , "\n```json" + , "\n" <> changeSetJSON cs + , "\n```" + , "\n" + , "\n
" + ] commentTableRow :: ResourceChange -> Text commentTableRow ResourceChange' {..} = @@ -193,10 +195,10 @@ formatDetail Colors {..} ResourceChangeDetail' {..} = do pure $ toText c - <> maybe "" ((" in " <>) . toText) attr - <> maybe "" (\x -> " (" <> magenta (toText x) <> ")") n - <> maybe "" ((", recreation " <>) . formatRR) rr - <> maybe "" ((", caused by " <>) . toText) causingEntity + <> maybe "" ((" in " <>) . toText) attr + <> maybe "" (\x -> " (" <> magenta (toText x) <> ")") n + <> maybe "" ((", recreation " <>) . formatRR) rr + <> maybe "" ((", caused by " <>) . toText) causingEntity where formatRR = \case x@RequiresRecreation_Always -> red (toText x) diff --git a/src/Stackctl/Spec/Deploy.hs b/src/Stackctl/Spec/Deploy.hs index ffc632d..d8c2913 100644 --- a/src/Stackctl/Spec/Deploy.hs +++ b/src/Stackctl/Spec/Deploy.hs @@ -14,6 +14,7 @@ import Options.Applicative import Stackctl.AWS hiding (action) import Stackctl.AWS.Scope import Stackctl.Action +import qualified Stackctl.CancelHandler as CancelHandler import Stackctl.Colors import Stackctl.Config (HasConfig) import Stackctl.DirectoryOption (HasDirectoryOption) @@ -71,12 +72,11 @@ parseDeployOptions = runDeploy :: ( MonadMask m , MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env , HasAwsScope env - , HasAwsEnv env , HasConfig env , HasDirectoryOption env , HasFilterOption env @@ -114,12 +114,12 @@ runDeploy DeployOptions {..} = do when sdoClean $ awsCloudFormationDeleteAllChangeSets stackName deleteRemovedStack - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => DeployConfirmation -> Stack @@ -146,11 +146,10 @@ data DeployConfirmation checkIfStackRequiresDeletion :: ( MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => DeployConfirmation -> StackName @@ -174,7 +173,7 @@ checkIfStackRequiresDeletion confirmation stackName = do deleteStack stackName deleteStack - :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env) + :: (MonadIO m, MonadAWS m, MonadLogger m) => StackName -> m () deleteStack stackName = do @@ -186,11 +185,10 @@ deleteStack stackName = do deployChangeSet :: ( MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => DeployConfirmation -> ChangeSet @@ -209,8 +207,16 @@ deployChangeSet confirmation changeSet = do mLastId <- awsCloudFormationGetMostRecentStackEventId stackName asyncTail <- async $ tailStackEventsSince stackName mLastId + let onCancel = do + logInfo "Canceling stack update, press ^C again to abort" + case csChangeSetType changeSet of + ChangeSetType_UPDATE -> do + awsCloudFormationCancelUpdateStack stackName + cancel asyncTail + t -> logWarn $ "Cannot cancel change-set of this type" :# ["type" .= t] + logInfo $ "Executing ChangeSet" :# ["changeSetId" .= changeSetId] - result <- do + result <- CancelHandler.with onCancel $ do awsCloudFormationExecuteChangeSet changeSetId awsCloudFormationWait stackName @@ -232,11 +238,11 @@ deployChangeSet confirmation changeSet = do changeSetId = csChangeSetId changeSet tailStackEventsSince - :: ( MonadResource m + :: ( MonadIO m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => StackName -> Maybe Text @@ -260,8 +266,7 @@ formatStackEvent Colors {..} e = do timestamp <- liftIO $ formatTime defaultTimeLocale "%F %T %Z" - <$> utcToLocalZonedTime - (e ^. stackEvent_timestamp) + <$> utcToLocalZonedTime (e ^. stackEvent_timestamp) pure $ mconcat @@ -270,8 +275,7 @@ formatStackEvent Colors {..} e = do , maybe "" colorStatus $ e ^. stackEvent_resourceStatus , maybe "" (magenta . (" " <>)) $ e ^. stackEvent_logicalResourceId , maybe "" ((\x -> " (" <> x <> ")") . T.strip) - $ e - ^. stackEvent_resourceStatusReason + $ e ^. stackEvent_resourceStatusReason ] where colorStatus = \case diff --git a/src/Stackctl/Spec/Discover.hs b/src/Stackctl/Spec/Discover.hs index 6fddb20..bcec218 100644 --- a/src/Stackctl/Spec/Discover.hs +++ b/src/Stackctl/Spec/Discover.hs @@ -8,6 +8,8 @@ import Stackctl.Prelude import Data.List.Extra (dropPrefix) import qualified Data.List.NonEmpty as NE +import qualified Data.List.NonEmpty.Extra as NE +import Data.Text.Metrics (levenshtein) import Stackctl.AWS import Stackctl.AWS.Scope import Stackctl.Config (HasConfig) @@ -19,8 +21,8 @@ import System.FilePath (isPathSeparator) import System.FilePath.Glob forEachSpec_ - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m , MonadLogger m , MonadReader env m , HasAwsScope env @@ -33,8 +35,8 @@ forEachSpec_ forEachSpec_ f = traverse_ f =<< discoverSpecs discoverSpecs - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m , MonadLogger m , MonadReader env m , HasAwsScope env @@ -64,13 +66,28 @@ discoverSpecs = do withThreadContext context $ do checkForDuplicateStackNames specPaths - specs <- - sortStackSpecs - . filterStackSpecs filterOption - <$> traverse (readStackSpec dir) specPaths + mAllSpecs <- NE.nonEmpty <$> traverse (readStackSpec dir) specPaths - when (null specs) $ logWarn "No specs found" - specs <$ logDebug ("Discovered specs" :# ["matched" .= length specs]) + case mAllSpecs of + Nothing -> do + [] + <$ logWarn + ( "Missing or empty specification directory" + :# [ "directory" .= dir + , "hint" .= ("Is this the correct directory?" :: Text) + ] + ) + Just allSpecs -> do + let + known = stackSpecStackName <$> allSpecs + specs = + sortStackSpecs + $ filterStackSpecs filterOption + $ NE.toList allSpecs + + traverse_ (checkForUnknownDepends known) specs + when (null specs) $ logWarn "No specs matched filters" + specs <$ logDebug ("Discovered specs" :# ["matched" .= length specs]) checkForDuplicateStackNames :: (MonadIO m, MonadLogger m) => [StackSpecPath] -> m () @@ -88,12 +105,45 @@ checkForDuplicateStackNames = logError $ "Multiple specifications produced the same Stack name" - :# [ "name" .= stackSpecPathStackName (NE.head specPaths) - , "paths" .= collidingPaths - ] + :# [ "name" .= stackSpecPathStackName (NE.head specPaths) + , "paths" .= collidingPaths + ] exitFailure +-- | Warn if a 'StackSpec' depends on a name not in the given 'StackName's +-- +-- The 'StackName's are built from all specs, but we only run this with specs +-- that are filtered in. +-- +-- NB. This function is written so it can easily be made into a fatal error +-- (like 'checkForDuplicateStackNames'), but we only warn for now. +checkForUnknownDepends + :: MonadLogger m => NonEmpty StackName -> StackSpec -> m () +checkForUnknownDepends known spec = + traverse_ reportUnknownDepends + $ NE.nonEmpty + $ filter (`notElem` known) + $ stackSpecDepends spec + where + reportUnknownDepends depends = do + for_ depends $ \depend -> do + let (nearest, _distance) = + NE.minimumBy1 (comparing snd) + $ (id &&& getDistance depend) <$> known + + logWarn + $ "Stack lists dependency that does not exist" + :# [ "dependency" + .= ( unStackName (stackSpecStackName spec) + <> " -> " + <> unStackName depend + ) + , "hint" .= ("Did you mean " <> unStackName nearest <> "?") + ] + + getDistance = levenshtein `on` unStackName + buildSpecPath :: (MonadReader env m, HasAwsScope env) => StackName diff --git a/src/Stackctl/Spec/Generate.hs b/src/Stackctl/Spec/Generate.hs index c3b22db..b6a68e5 100644 --- a/src/Stackctl/Spec/Generate.hs +++ b/src/Stackctl/Spec/Generate.hs @@ -1,6 +1,5 @@ module Stackctl.Spec.Generate - ( Generate (..) - , GenerateSpec (..) + ( GenerateSpec (..) , GenerateTemplate (..) , generate , TemplateFormat (..) @@ -10,7 +9,6 @@ import Stackctl.Prelude import Stackctl.AWS import Stackctl.AWS.Scope -import Stackctl.Action import Stackctl.Config (HasConfig) import Stackctl.DirectoryOption import Stackctl.Spec.Discover (buildSpecPath) @@ -18,18 +16,6 @@ import Stackctl.StackSpec import Stackctl.StackSpecPath import Stackctl.StackSpecYaml -data Generate = Generate - { gDescription :: Maybe StackDescription - , gDepends :: Maybe [StackName] - , gActions :: Maybe [Action] - , gParameters :: Maybe [Parameter] - , gCapabilities :: Maybe [Capability] - , gTags :: Maybe [Tag] - , gSpec :: GenerateSpec - , gTemplate :: GenerateTemplate - , gOverwrite :: Bool - } - data GenerateSpec = -- | Generate at an inferred name GenerateSpec StackName @@ -57,15 +43,18 @@ generate , HasAwsScope env , HasDirectoryOption env ) - => Generate + => Bool + -> GenerateSpec + -> GenerateTemplate + -> (FilePath -> StackSpecYaml) -> m FilePath -generate Generate {..} = do +generate overwrite spec template toStackSpecYaml = do let - (stackName, stackPath) = case gSpec of + (stackName, stackPath) = case spec of GenerateSpec name -> (name, unpack (unStackName name) <> ".yaml") GenerateSpecTo name path -> (name, path) - (mTemplateBody, templatePath) = case gTemplate of + (mTemplateBody, templatePath) = case template of GenerateTemplate body format -> ( Just body , case format of @@ -75,21 +64,12 @@ generate Generate {..} = do GenerateTemplateTo body path -> (Just body, path) UseExistingTemplate path -> (Nothing, path) - specYaml = - StackSpecYaml - { ssyDescription = gDescription - , ssyTemplate = templatePath - , ssyDepends = gDepends - , ssyActions = gActions - , ssyParameters = parametersYaml . mapMaybe parameterYaml <$> gParameters - , ssyCapabilities = gCapabilities - , ssyTags = tagsYaml . map TagYaml <$> gTags - } + specYaml = toStackSpecYaml templatePath dir <- view $ directoryOptionL . to unDirectoryOption specPath <- buildSpecPath stackName stackPath stackSpec <- buildStackSpec dir specPath specYaml withThreadContext ["stackName" .= stackSpecStackName stackSpec] $ do - writeStackSpec gOverwrite stackSpec mTemplateBody + writeStackSpec overwrite stackSpec mTemplateBody pure $ stackSpecPathFilePath specPath diff --git a/src/Stackctl/Spec/List.hs b/src/Stackctl/Spec/List.hs index b12dceb..097717a 100644 --- a/src/Stackctl/Spec/List.hs +++ b/src/Stackctl/Spec/List.hs @@ -7,6 +7,7 @@ module Stackctl.Spec.List import Stackctl.Prelude import Blammo.Logging.Logger (pushLoggerLn) +import qualified Data.Text as T import Options.Applicative import Stackctl.AWS import Stackctl.AWS.Scope @@ -17,21 +18,29 @@ import Stackctl.FilterOption (HasFilterOption) import Stackctl.Spec.Discover import Stackctl.StackSpec -data ListOptions = ListOptions - --- brittany-disable-next-binding +newtype ListOptions = ListOptions + { loLegend :: Bool + } parseListOptions :: Parser ListOptions -parseListOptions = pure ListOptions +parseListOptions = + ListOptions + <$> ( not + <$> switch + ( mconcat + [ long "no-legend" + , help "Don't print indicators legend at the end" + ] + ) + ) runList :: ( MonadUnliftIO m , MonadMask m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasAwsScope env - , HasAwsEnv env , HasLogger env , HasConfig env , HasDirectoryOption env @@ -39,23 +48,74 @@ runList ) => ListOptions -> m () -runList _ = do - Colors {..} <- getColorsLogger +runList ListOptions {..} = do + colors@Colors {..} <- getColorsLogger forEachSpec_ $ \spec -> do let path = stackSpecFilePath spec name = stackSpecStackName spec - exists <- isJust <$> awsCloudFormationDescribeStackMaybe name + mStackStatus <- + fmap (^. stack_stackStatus) + <$> awsCloudFormationDescribeStackMaybe name let + indicator = maybe NotDeployed statusIndicator mStackStatus + formatted :: Text formatted = " " - <> (if exists then green "✓ " else yellow "✗ ") + <> indicatorIcon colors indicator + <> " " <> cyan (unStackName name) <> " => " <> magenta (pack path) pushLoggerLn formatted + + let legendItem i = indicatorIcon colors i <> " " <> indicatorDescription i + + when loLegend + $ pushLoggerLn + $ "\nLegend:\n " + <> T.intercalate ", " (map legendItem [minBound .. maxBound]) + +data Indicator + = Deployed + | DeployFailed + | NotDeployed + | Reviewing + | Deploying + | Unknown + deriving stock (Bounded, Enum) + +indicatorIcon :: Colors -> Indicator -> Text +indicatorIcon Colors {..} = \case + Deployed -> green "✓" + DeployFailed -> red "✗" + NotDeployed -> yellow "_" + Reviewing -> yellow "∇" + Deploying -> cyan "⋅" + Unknown -> magenta "?" + +indicatorDescription :: Indicator -> Text +indicatorDescription = \case + Deployed -> "deployed" + DeployFailed -> "failed or rolled back" + NotDeployed -> "doesn't exist" + Reviewing -> "reviewing" + Deploying -> "deploying" + Unknown -> "unknown" + +statusIndicator :: StackStatus -> Indicator +statusIndicator = \case + StackStatus_REVIEW_IN_PROGRESS -> Reviewing + StackStatus_ROLLBACK_COMPLETE -> DeployFailed + x | statusSuffixed "_IN_PROGRESS" x -> Deploying + x | statusSuffixed "_FAILED" x -> DeployFailed + x | statusSuffixed "_ROLLBACK_COMPLETE" x -> DeployFailed + x | statusSuffixed "_COMPLETE" x -> Deployed + _ -> Unknown + where + statusSuffixed x = (x `T.isSuffixOf`) . fromStackStatus diff --git a/src/Stackctl/StackSpec.hs b/src/Stackctl/StackSpec.hs index 8cc7c18..e2e0300 100644 --- a/src/Stackctl/StackSpec.hs +++ b/src/Stackctl/StackSpec.hs @@ -5,6 +5,7 @@ module Stackctl.StackSpec , stackSpecSpecBody , stackSpecStackName , stackSpecStackDescription + , stackSpecDepends , stackSpecActions , stackSpecParameters , stackSpecCapabilities @@ -80,8 +81,7 @@ stackSpecTemplate :: StackSpec -> StackTemplate stackSpecTemplate spec = StackTemplate $ FilePath.normalise - $ ssSpecRoot spec - stackSpecTemplateFile spec + $ ssSpecRoot spec stackSpecTemplateFile spec stackSpecParameters :: StackSpec -> [Parameter] stackSpecParameters = @@ -184,10 +184,8 @@ readStackSpec dir specPath = do -- | Create a Change Set between a Stack Specification and deployed state createChangeSet :: ( MonadUnliftIO m - , MonadResource m , MonadLogger m - , MonadReader env m - , HasAwsEnv env + , MonadAWS m ) => StackSpec -> [Parameter] @@ -198,8 +196,7 @@ createChangeSet spec parameters tags = (stackSpecStackName spec) (stackSpecStackDescription spec) (stackSpecTemplate spec) - ( nubOrdOn (^. parameter_parameterKey) $ parameters <> stackSpecParameters spec - ) + (nubOrdOn (^. parameter_parameterKey) $ parameters <> stackSpecParameters spec) (stackSpecCapabilities spec) (nubOrdOn (^. tag_key) $ tags <> stackSpecTags spec) diff --git a/src/Stackctl/StackSpecPath.hs b/src/Stackctl/StackSpecPath.hs index a5ce673..8500b61 100644 --- a/src/Stackctl/StackSpecPath.hs +++ b/src/Stackctl/StackSpecPath.hs @@ -83,16 +83,16 @@ stackSpecPathFromFilePath awsScope@AwsScope {..} path = unless (pathAccountId == awsAccountId) $ Left $ "Unexpected account: " - <> unpack (unAccountId pathAccountId) - <> " != " - <> unpack (unAccountId awsAccountId) + <> unpack (unAccountId pathAccountId) + <> " != " + <> unpack (unAccountId awsAccountId) unless (unpack (fromRegion awsRegion) == pathRegion) $ Left $ "Unexpected region: " - <> pathRegion - <> " != " - <> unpack (fromRegion awsRegion) + <> pathRegion + <> " != " + <> unpack (fromRegion awsRegion) stackName <- maybe (Left "Must end in .yaml") (Right . StackName) @@ -117,6 +117,6 @@ parseAccountPath path = case second (T.drop 1) $ T.breakOn "." $ pack path of _ -> Left $ "Path matches neither {account-id}.{account-name}, nor {account-name}.{account-id}: " - <> path + <> path where isAccountId x = T.length x == 12 && T.all isDigit x diff --git a/src/Stackctl/StackSpecYaml.hs b/src/Stackctl/StackSpecYaml.hs index bc13edd..aebc651 100644 --- a/src/Stackctl/StackSpecYaml.hs +++ b/src/Stackctl/StackSpecYaml.hs @@ -4,18 +4,24 @@ -- Template: -- -- Depends: --- - +-- - -- -- Parameters: --- - ParameterKey: --- ParameterValue: +-- - ParameterKey: +-- ParameterValue: +-- +-- # Or +-- : -- -- Capabilities: --- - +-- - -- -- Tags: --- - Key: --- Value: +-- - Key: +-- Value: +-- +-- # Or +-- : -- @ module Stackctl.StackSpecYaml ( StackSpecYaml (..) @@ -24,7 +30,11 @@ module Stackctl.StackSpecYaml , unParametersYaml , ParameterYaml , parameterYaml + , mkParameterYaml , unParameterYaml + , ParameterValue + , parameterValueFromText + , parameterValueTemplate , TagsYaml , tagsYaml , unTagsYaml @@ -39,6 +49,7 @@ import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap import Data.Aeson.Types (typeMismatch) import qualified Data.HashMap.Strict as HashMap +import Data.List.Extra (dropSuffix) import Data.Monoid (Last (..)) import qualified Data.Text as T import Stackctl.AWS @@ -74,8 +85,7 @@ instance Semigroup ParametersYaml where $ KeyMap.toList $ KeyMap.fromListWith (<>) $ map (pyKey &&& pyValue) - $ bs -- flipped to make sure Last-wins - <> as + $ bs <> as -- flipped to make sure Last-wins instance FromJSON ParametersYaml where parseJSON = \case @@ -97,7 +107,7 @@ instance ToJSON ParametersYaml where toJSON = object . parametersYamlPairs toEncoding = pairs . mconcat . parametersYamlPairs -parametersYamlPairs :: KeyValue kv => ParametersYaml -> [kv] +parametersYamlPairs :: KeyValue e kv => ParametersYaml -> [kv] parametersYamlPairs = map parameterYamlPair . unParametersYaml parametersYaml :: [ParameterYaml] -> ParametersYaml @@ -114,33 +124,83 @@ instance FromJSON ParameterYaml where (mkParameterYaml <$> o .: "Name" <*> o .:? "Value") <|> (mkParameterYaml <$> o .: "ParameterKey" <*> o .:? "ParameterValue") -parameterYamlPair :: KeyValue kv => ParameterYaml -> kv +parameterYamlPair :: KeyValue e kv => ParameterYaml -> kv parameterYamlPair ParameterYaml {..} = pyKey .= pyValue -mkParameterYaml :: Text -> Maybe ParameterValue -> ParameterYaml -mkParameterYaml k = ParameterYaml (Key.fromText k) . Last - parameterYaml :: Parameter -> Maybe ParameterYaml parameterYaml p = do k <- p ^. parameter_parameterKey let mv = p ^. parameter_parameterValue - pure $ mkParameterYaml k $ ParameterValue <$> mv + pure $ mkParameterYaml k $ parameterValueFromText <$> mv + +mkParameterYaml :: Text -> Maybe ParameterValue -> ParameterYaml +mkParameterYaml k = ParameterYaml (Key.fromText k) . Last unParameterYaml :: ParameterYaml -> Parameter unParameterYaml (ParameterYaml k v) = - makeParameter (Key.toText k) $ unParameterValue <$> getLast v + makeParameter (Key.toText k) $ parameterValueToText <$> getLast v -newtype ParameterValue = ParameterValue - { unParameterValue :: Text - } +data ParameterValue + = StringParameter Text + | NumberParameter Double + | -- | Encodes as String True|False + BooleanParameter Bool deriving stock (Eq, Show) - deriving newtype (Semigroup, ToJSON) instance FromJSON ParameterValue where parseJSON = \case - String x -> pure $ ParameterValue x - Number x -> pure $ ParameterValue $ dropSuffix ".0" $ pack $ show x - x -> fail $ "Expected String or Number, got: " <> show x + String t -> pure $ StringParameter t + Number s -> pure $ NumberParameter $ realToFrac s + Bool b -> pure $ BooleanParameter b + x -> typeMismatch "String, Number or Bool" x + +instance ToJSON ParameterValue where + toJSON = \case + StringParameter t -> toJSON t + NumberParameter d -> toJSON d + BooleanParameter b -> toJSON $ pack $ show b + toEncoding = \case + StringParameter t -> toEncoding t + NumberParameter d -> toEncoding d + BooleanParameter b -> toEncoding $ pack $ show b + +parameterValueToText :: ParameterValue -> Text +parameterValueToText = \case + StringParameter t -> t + NumberParameter d -> pack $ dropSuffix ".0" $ show d + BooleanParameter b -> pack $ show b + +parameterValueFromText :: Text -> ParameterValue +parameterValueFromText = \case + v | T.toLower v == "true" -> BooleanParameter True + v | T.toLower v == "false" -> BooleanParameter False + v | Just d <- readMaybe (unpack v) -> NumberParameter d + v -> StringParameter v + +-- | For use as the value in a @Parameters@ object of a CFN Template +parameterValueTemplate :: ParameterValue -> Value +parameterValueTemplate v = + object + $ catMaybes + [ Just $ "Type" .= parameterValueType v + , Just $ "Default" .= parameterValueDefault v + , ("AllowedValues" .=) <$> parameterValueAllowedValues v + ] + +parameterValueType :: ParameterValue -> Text +parameterValueType = \case + StringParameter {} -> "String" + NumberParameter {} -> "Number" + BooleanParameter {} -> "String" + +parameterValueDefault :: ParameterValue -> Value +parameterValueDefault = toJSON + +parameterValueAllowedValues :: ParameterValue -> Maybe Value +parameterValueAllowedValues = \case + StringParameter {} -> Nothing + NumberParameter {} -> Nothing + BooleanParameter {} -> Just $ toJSON [String "True", String "False"] newtype TagsYaml = TagsYaml { unTagsYaml :: [TagYaml] @@ -154,8 +214,7 @@ instance Semigroup TagsYaml where $ HashMap.toList $ HashMap.fromList $ map (toPair . unTagYaml) - $ as - <> bs + $ as <> bs where toPair :: Tag -> (Text, Text) toPair = (^. tag_key) &&& (^. tag_value) @@ -176,7 +235,7 @@ instance ToJSON TagsYaml where toJSON = object . tagsYamlPairs toEncoding = pairs . mconcat . tagsYamlPairs -tagsYamlPairs :: KeyValue kv => TagsYaml -> [kv] +tagsYamlPairs :: KeyValue e kv => TagsYaml -> [kv] tagsYamlPairs = map tagYamlPair . unTagsYaml tagsYaml :: [TagYaml] -> TagsYaml @@ -192,8 +251,5 @@ instance FromJSON TagYaml where t <- newTag <$> o .: "Key" <*> o .: "Value" pure $ TagYaml t -tagYamlPair :: KeyValue kv => TagYaml -> kv +tagYamlPair :: KeyValue e kv => TagYaml -> kv tagYamlPair (TagYaml t) = Key.fromText (t ^. tag_key) .= (t ^. tag_value) - -dropSuffix :: Text -> Text -> Text -dropSuffix suffix t = fromMaybe t $ T.stripSuffix suffix t diff --git a/src/Stackctl/Subcommand.hs b/src/Stackctl/Subcommand.hs index eb84864..ec7d33c 100644 --- a/src/Stackctl/Subcommand.hs +++ b/src/Stackctl/Subcommand.hs @@ -10,6 +10,8 @@ import Stackctl.Prelude import qualified Env import Options.Applicative +import Prettyprinter (pretty, vsep) +import Prettyprinter.Util (reflow) import Stackctl.AWS (handlingServiceError) import Stackctl.AutoSSO import Stackctl.CLI @@ -46,7 +48,9 @@ runSubcommand' title parseEnv parseCLI sp = do (options, act) <- applyEnv <$> Env.parse (Env.header $ unpack title) parseEnv - <*> execParser (withInfo title $ (,) <$> parseCLI <*> subparser sp) + <*> customExecParser + (prefs helpShowGlobals) + (withInfo title $ (,) <$> parseCLI <*> subparser sp) act options where @@ -78,4 +82,15 @@ runAppSubcommand f subOptions options = $ f subOptions withInfo :: Text -> Parser a -> ParserInfo a -withInfo d p = info (p <**> helper) $ progDesc (unpack d) <> fullDesc +withInfo d p = + info (p <**> helper) + $ progDescDoc + $ Just + $ vsep + [ pretty d + , "" + , reflow + $ "By default, this will operate on the entire stack collection. To" + <> " operate on a specific stack or set of stacks, use the --filter" + <> " argument to filter the collection by file path." + ] diff --git a/src/Stackctl/VerboseOption.hs b/src/Stackctl/VerboseOption.hs index 9359c2c..1a6decf 100644 --- a/src/Stackctl/VerboseOption.hs +++ b/src/Stackctl/VerboseOption.hs @@ -7,6 +7,7 @@ module Stackctl.VerboseOption import Stackctl.Prelude +import Blammo.Logging.LogSettings import Blammo.Logging.LogSettings.LogLevels import Options.Applicative diff --git a/src/Stackctl/Version.hs b/src/Stackctl/Version.hs index b768081..f1871df 100644 --- a/src/Stackctl/Version.hs +++ b/src/Stackctl/Version.hs @@ -3,10 +3,10 @@ module Stackctl.Version ) where import Stackctl.Prelude +import Prelude (putStrLn) import Data.Version import qualified Paths_stackctl as Pkg -import Prelude (putStrLn) logVersion :: MonadIO m => m () logVersion = liftIO $ putStrLn $ ("Stackctl v" <>) $ showVersion Pkg.version diff --git a/stack.yaml b/stack.yaml index 3a4757c..7597741 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,19 +1,17 @@ -resolver: lts-20.4 +resolver: lts-23.7 extra-deps: - - Blammo-1.1.2.1 - - cfn-flip-0.1.0.3 - - unliftio-0.2.25.0 - - - amazonka-2.0 - - amazonka-core-2.0 - - amazonka-certificatemanager-2.0 - - amazonka-cloudformation-2.0 - - amazonka-ec2-2.0 - - amazonka-ecr-2.0 - - amazonka-lambda-2.0 - - amazonka-sso-2.0 - - amazonka-sts-2.0 + - github: brendanhay/amazonka + commit: cf174ae30fa914439f4d1fa1c3dbd9b69b935141 # main + #1029 + subdirs: + - lib/amazonka + - lib/amazonka-core + - lib/services/amazonka-cloudformation + - lib/services/amazonka-ec2 + - lib/services/amazonka-lambda + - lib/services/amazonka-sso + - lib/services/amazonka-sts - # For amazonka-core-2.0 - - crypton-0.33 + - amazonka-mtl-0.1.1.0 + - cfn-flip-0.1.0.3 + - microlens-pro-0.2.0.2 diff --git a/stack.yaml.lock b/stack.yaml.lock index 7659034..60debb7 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -1,103 +1,124 @@ # This file was autogenerated by Stack. # You should not edit this file by hand. # For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files +# https://docs.haskellstack.org/en/stable/topics/lock_files packages: - completed: - hackage: Blammo-1.1.2.1@sha256:b74d553fb3557bb10381b806bd34b8bad0b800883f02dfd1cc847f58db40958c,4084 + name: amazonka pantry-tree: - sha256: bd28931f07beaaae8565a87d8c3b55d3e9ff5c332ae93dc32c1090a4c814e620 - size: 1567 + sha256: 6a4df9d7ef86e2ecffb44ef528844a97b2339e6a6703bd304a605341c6db9842 + size: 1529 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/amazonka + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: Blammo-1.1.2.1 + subdir: lib/amazonka + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: cfn-flip-0.1.0.3@sha256:8737882d818d74b29d3b1791a4df4dc89995870312374989c47c29352ea503ec,5615 + name: amazonka-core pantry-tree: - sha256: 715102dfcca7053390eda5be0504485fb93b8b84226fe373a6e62d297090d49b - size: 3139 - original: - hackage: cfn-flip-0.1.0.3 -- completed: - hackage: unliftio-0.2.25.0@sha256:d015242554890370bcbc3a575019be691d0edc279736ef97d29412fb9d0c4349,3410 - pantry-tree: - sha256: 08c62f256e740e1a78b175907c26cb06439a1b486ceb8021c5a2e4425ebb6c5b - size: 2494 - original: - hackage: unliftio-0.2.25.0 -- completed: - hackage: amazonka-2.0@sha256:3481da2fda6b210d15d41c1db7a588adf68123cfb7ea3882797a6230003259db,3505 - pantry-tree: - sha256: 01c7121bd5e4a3918a71ea6502412292c97facf20c9620f07af96e423d6437e2 - size: 1528 - original: - hackage: amazonka-2.0 -- completed: - hackage: amazonka-core-2.0@sha256:d9f0533c272ac92bd7b18699077038b6b51b3552e91b65743af4ce646286b4f8,4383 - pantry-tree: - sha256: 46e7e4de910b08ee2df98db9cda2becf388ce49510024018289a46c43e175ee0 + sha256: fbd62e7df53cf2f5b944a99d0ef024c77a10e3bde2e519fb95bcb262aed29fc4 size: 3222 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/amazonka-core + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-core-2.0 + subdir: lib/amazonka-core + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-certificatemanager-2.0@sha256:9a203a46ec1eaae2c59aa891efa480f84411783d02ba973820d67e95cc67756c,5226 + name: amazonka-cloudformation pantry-tree: - sha256: 9ee7f26c6166f2b01f32efcf41d4a6315ff681823c698f49036f5b471ffb6e9c - size: 7191 + sha256: 0cacf4a7cae64a63855bf1cce2b947084e4353f46756f36e65dd351087a7f63e + size: 27257 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/services/amazonka-cloudformation + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-certificatemanager-2.0 + subdir: lib/services/amazonka-cloudformation + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-cloudformation-2.0@sha256:7a9618bf697cdaf0a51c2d7be557ad47820b926416d79f5138ff3befdbfcbafb,11870 + name: amazonka-ec2 pantry-tree: - sha256: 177fbc16ea2fa072a7fca9f4a3b1d64f4d5e8fc7cd493e4e841f337c572745bd - size: 27257 + sha256: dc171159485af8773de82731ee1cf1df56acdf1e6c6fe76864dcf24d5d6b7e85 + size: 234434 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/services/amazonka-ec2 + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-cloudformation-2.0 + subdir: lib/services/amazonka-ec2 + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-ec2-2.0@sha256:9344b87d8f8328fd91023b96565e79e7676aa5e7dd40b87b3f3f3a22a9da7736,74154 + name: amazonka-lambda pantry-tree: - sha256: d1f2d4fce5b0664605d730d4232b25f26a5f49e3b7d07f4b282e8c36773e5ffd - size: 234434 + sha256: 249b7557046e64a2fae70acd3e7d7e20422ef7b3db49bf01d56c619e1d0a4470 + size: 21343 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/services/amazonka-lambda + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-ec2-2.0 + subdir: lib/services/amazonka-lambda + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-ecr-2.0@sha256:88ec5dffb3c07f9e49eb4d9672ac62c175b6cf2c3e044ec0e4c705cd6bff3487,6925 + name: amazonka-sso pantry-tree: - sha256: d0d5dc0ed4aab28f0d6183657e77985693c5ed011dd0cc40d335b6a334b1939a - size: 15627 + sha256: c4575f7b7cf61c3de65e43d0d77a14dfa14c47ebff5f1a3dcd2f6e1313aaaf0a + size: 1817 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/services/amazonka-sso + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-ecr-2.0 + subdir: lib/services/amazonka-sso + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-lambda-2.0@sha256:aa74299380318b04429980eb76b7f0499a8241ff01de859042b0ff09bd7ef420,8281 + name: amazonka-sts pantry-tree: - sha256: da8f346de9d1eb0fb12afa91e44f8179ac05176043919346e2e72a7880b7a9e5 - size: 21343 + sha256: e0cb89013938230d257a2e546a78170dfdb6d507f37c6cb763a6cdf6290edb66 + size: 2880 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 + subdir: lib/services/amazonka-sts + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz + version: '2.0' original: - hackage: amazonka-lambda-2.0 + subdir: lib/services/amazonka-sts + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: - hackage: amazonka-sso-2.0@sha256:902be13b604e4a3b51a9b8e1adc6a32f42322ae11f738a72a8c737b2d0a91a5e,2995 + hackage: amazonka-mtl-0.1.1.0@sha256:90b45a950c0e398b0e48d1447766f331c2ac3d5a72e15be2bf0be3b3c56159c3,6572 pantry-tree: - sha256: f87dd959a78bf54295bd6f8c7da58f7f8f860251d5548ecb05ab758e03cba50b - size: 1817 + sha256: c85849d4d5caa36a3597323185d7593cb624cadee6e4f05219d3ccd498a7b270 + size: 965 original: - hackage: amazonka-sso-2.0 + hackage: amazonka-mtl-0.1.1.0 - completed: - hackage: amazonka-sts-2.0@sha256:5c721083e8d80883a893176de6105c27bbbd8176f467c27ac5f8d548a5e726d8,3209 + hackage: cfn-flip-0.1.0.3@sha256:40f33714827c35a9fd3cebde06002f54448c3efa34252efbe5e48445065f2620,5934 pantry-tree: - sha256: bde4691af7cac74e0a3705271b4d3ac05515863bfb6f668112e3f3950a27cb41 - size: 2880 + sha256: 4d5fc2c97d269deb4a34432db02a725850392542d1852a11f10c76832611e2c8 + size: 3139 original: - hackage: amazonka-sts-2.0 + hackage: cfn-flip-0.1.0.3 - completed: - hackage: crypton-0.33@sha256:5e92f29b9b7104d91fcdda1dec9400c9ad1f1791c231cc41ceebd783fb517dee,18202 + hackage: microlens-pro-0.2.0.2@sha256:2fd14b7f87d6aa76700dabf65fcdda835aa329a4fdd8a44eebdf399e798af7ab,3377 pantry-tree: - sha256: 38809499d7f9775ef45cd29ab5c3dc9b283a813f34c1cdc56681b24f8cf8bb4f - size: 23148 + sha256: be8ac1093c45ec46d640c56c06d8826d364ad1243d601e731e37581e8579e9c3 + size: 430 original: - hackage: crypton-0.33 + hackage: microlens-pro-0.2.0.2 snapshots: - completed: - sha256: 3770dfd79f5aed67acdcc65c4e7730adddffe6dba79ea723cfb0918356fc0f94 - size: 648660 - url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/20/4.yaml - original: lts-20.4 + sha256: 4ef79c30b9efcf07335cb3de532983a7ac4c5a4180bc17f6212a86b09ce2ff75 + size: 680777 + url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/23/7.yaml + original: lts-23.7 diff --git a/stackctl.cabal b/stackctl.cabal index 7ab2b95..c920407 100644 --- a/stackctl.cabal +++ b/stackctl.cabal @@ -1,11 +1,11 @@ cabal-version: 1.18 --- This file has been generated from package.yaml by hpack version 0.35.2. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack name: stackctl -version: 1.4.3.0 +version: 1.7.2.0 description: Please see homepage: https://github.com/freckle/stackctl#readme bug-reports: https://github.com/freckle/stackctl/issues @@ -35,6 +35,7 @@ library Stackctl.AWS.Orphans Stackctl.AWS.Scope Stackctl.AWS.STS + Stackctl.CancelHandler Stackctl.CLI Stackctl.ColorOption Stackctl.Colors @@ -43,6 +44,7 @@ library Stackctl.Config.RequiredVersion Stackctl.DirectoryOption Stackctl.FilterOption + Stackctl.OneOrListOf Stackctl.Options Stackctl.ParameterOption Stackctl.Prelude @@ -95,9 +97,9 @@ library StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path + ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-role-annotations -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path build-depends: - Blammo >=1.1.2.1 + Blammo >=1.1.2.3 , Glob , QuickCheck , aeson @@ -108,6 +110,7 @@ library , amazonka-core >=2.0 , amazonka-ec2 >=2.0 , amazonka-lambda >=2.0 + , amazonka-mtl , amazonka-sso >=2.0 , amazonka-sts >=2.0 , base ==4.* @@ -125,13 +128,16 @@ library , monad-logger , mtl , optparse-applicative + , prettyprinter , resourcet , rio , semigroups , text + , text-metrics , time , transformers , typed-process + , unix , unliftio >=0.2.25.0 , unordered-containers , uuid @@ -170,7 +176,7 @@ executable stackctl StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path -threaded -rtsopts -with-rtsopts=-N + ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-role-annotations -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path -threaded -rtsopts -with-rtsopts=-N build-depends: base ==4.* , stackctl @@ -181,13 +187,20 @@ test-suite spec main-is: Spec.hs other-modules: Stackctl.AWS.CloudFormationSpec + Stackctl.AWS.EC2Spec + Stackctl.AWS.LambdaSpec Stackctl.AWS.ScopeSpec + Stackctl.CancelHandlerSpec Stackctl.Config.RequiredVersionSpec Stackctl.ConfigSpec Stackctl.FilterOptionSpec + Stackctl.OneOrListOfSpec + Stackctl.RemovedStackSpec + Stackctl.Spec.Changes.FormatSpec Stackctl.StackDescriptionSpec Stackctl.StackSpecSpec Stackctl.StackSpecYamlSpec + Stackctl.Test.App Paths_stackctl hs-source-dirs: test @@ -217,14 +230,29 @@ test-suite spec StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path + ghc-options: -fignore-optim-changes -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missed-specialisations -Wno-missing-import-lists -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-role-annotations -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path build-depends: - QuickCheck + Blammo + , Glob + , QuickCheck , aeson + , amazonka + , amazonka-cloudformation + , amazonka-ec2 + , amazonka-lambda + , amazonka-mtl , base ==4.* , bytestring + , filepath , hspec + , hspec-expectations-lifted + , hspec-golden >=0.2.1.0 + , http-types + , lens , mtl , stackctl + , text + , time + , unliftio , yaml default-language: Haskell2010 diff --git a/test/Stackctl/AWS/CloudFormationSpec.hs b/test/Stackctl/AWS/CloudFormationSpec.hs index f99bb0f..399df53 100644 --- a/test/Stackctl/AWS/CloudFormationSpec.hs +++ b/test/Stackctl/AWS/CloudFormationSpec.hs @@ -2,27 +2,95 @@ module Stackctl.AWS.CloudFormationSpec ( spec ) where -import Stackctl.Prelude +import Stackctl.Test.App +import Amazonka.CloudFormation.DeleteChangeSet +import Amazonka.CloudFormation.ListChangeSets +import Amazonka.CloudFormation.Types.ChangeSetSummary +import Blammo.Logging.Logger (LoggedMessage (..), getLoggedMessagesUnsafe) +import qualified Data.Aeson.KeyMap as KeyMap import Data.List (isSuffixOf) import Stackctl.AWS.CloudFormation -import Test.Hspec spec :: Spec spec = do describe "readParameter" $ do - it "refuses empty key" $ do + it "refuses empty key" $ example $ do readParameter "=Value" `shouldSatisfy` either ("empty KEY" `isSuffixOf`) (const False) - it "refuses empty value" $ do + it "refuses empty value" $ example $ do readParameter "Key" `shouldSatisfy` either ("empty VALUE" `isSuffixOf`) (const False) - it "refuses empty value (with =)" $ do + it "refuses empty value (with =)" $ example $ do readParameter "Key=" `shouldSatisfy` either ("empty VALUE" `isSuffixOf`) (const False) - it "creates a parameter when valid" $ do + it "creates a parameter when valid" $ example $ do readParameter "Key=Value=More" `shouldBe` Right (makeParameter "Key" $ Just "Value=More") + + describe "awsCloudFormationDeleteAllChangeSets" $ do + it "deletes all listed changesets" $ example $ runTestAppT $ do + let + stackName :: Text + stackName = "some-stack" + + cs1 :: Text + cs1 = "some-changeset-1" + + cs2 :: Text + cs2 = "some-changeset-2" + + cs3 :: Text + cs3 = "some-changeset-3" + + isListChangeSetsPage :: Maybe Text -> ListChangeSets -> Bool + isListChangeSetsPage p req = + and + [ req ^. listChangeSets_stackName == stackName + , req ^. listChangeSets_nextToken == p + ] + + isDeleteChangeSet :: Text -> DeleteChangeSet -> Bool + isDeleteChangeSet cs req = req ^. deleteChangeSet_changeSetName == cs + + summary1 = newChangeSetSummary & changeSetSummary_changeSetId ?~ cs1 + summary2 = newChangeSetSummary & changeSetSummary_changeSetId ?~ cs2 + summary3 = newChangeSetSummary & changeSetSummary_changeSetId ?~ cs3 + + matchers = + [ SendMatcher (isListChangeSetsPage Nothing) + $ Right + $ newListChangeSetsResponse 200 + & (listChangeSetsResponse_summaries ?~ [summary1, summary2]) + & (listChangeSetsResponse_nextToken ?~ "p2") + , SendMatcher (isListChangeSetsPage $ Just "p2") + $ Right + $ newListChangeSetsResponse 200 + & (listChangeSetsResponse_summaries ?~ [summary3]) + , SendMatcher (isDeleteChangeSet cs1) + $ Right + $ newDeleteChangeSetResponse 200 + , SendMatcher (isDeleteChangeSet cs2) + $ Right + $ newDeleteChangeSetResponse 200 + , SendMatcher (isDeleteChangeSet cs3) + $ Right + $ newDeleteChangeSetResponse 200 + ] + + withMatchers matchers $ do + awsCloudFormationDeleteAllChangeSets $ StackName stackName + + messages <- + map (loggedMessageText &&& loggedMessageMeta) + <$> getLoggedMessagesUnsafe + + messages + `shouldBe` [ ("Deleting all changesets", mempty) + , ("Enqueing delete", KeyMap.fromList [("changeSetId", toJSON cs1)]) + , ("Enqueing delete", KeyMap.fromList [("changeSetId", toJSON cs2)]) + , ("Enqueing delete", KeyMap.fromList [("changeSetId", toJSON cs3)]) + ] diff --git a/test/Stackctl/AWS/EC2Spec.hs b/test/Stackctl/AWS/EC2Spec.hs new file mode 100644 index 0000000..4f1d8a0 --- /dev/null +++ b/test/Stackctl/AWS/EC2Spec.hs @@ -0,0 +1,29 @@ +module Stackctl.AWS.EC2Spec + ( spec + ) where + +import Stackctl.Test.App + +import Amazonka.EC2.DescribeAvailabilityZones +import Amazonka.EC2.Types.AvailabilityZone +import Stackctl.AWS.EC2 + +spec :: Spec +spec = do + describe "awsEc2DescribeFirstAvailabilityZoneRegionName" $ do + it "returns the first AZ's region name" $ example $ runTestAppT $ do + let + zones = + [ newAvailabilityZone & availabilityZone_regionName ?~ "us-east-1" + , newAvailabilityZone & availabilityZone_regionName ?~ "us-east-2" + , newAvailabilityZone & availabilityZone_regionName ?~ "us-west-1" + ] + matcher = + SendMatcher (const @_ @DescribeAvailabilityZones True) + $ Right + $ newDescribeAvailabilityZonesResponse 200 + & describeAvailabilityZonesResponse_availabilityZones + ?~ zones + + withMatcher matcher awsEc2DescribeFirstAvailabilityZoneRegionName + `shouldReturn` "us-east-1" diff --git a/test/Stackctl/AWS/LambdaSpec.hs b/test/Stackctl/AWS/LambdaSpec.hs new file mode 100644 index 0000000..6226718 --- /dev/null +++ b/test/Stackctl/AWS/LambdaSpec.hs @@ -0,0 +1,63 @@ +module Stackctl.AWS.LambdaSpec + ( spec + ) where + +import Stackctl.Test.App + +import Amazonka.Lambda.Invoke +import Data.Aeson +import qualified Data.ByteString.Lazy as BSL +import Stackctl.AWS.Lambda + +spec :: Spec +spec = do + describe "awsLambdaInvoke" $ do + it "invokes a lambda" $ example $ runTestAppT $ do + let + emptyObject = object [] + + isInvocation name invoke = + and + [ invoke ^. invoke_functionName == name + , invoke ^. invoke_payload == "{}" + ] + + lambdaError = + LambdaError + { errorType = "exception" + , errorMessage = "oops" + , trace = [] + } + + matchers = + [ SendMatcher (isInvocation "lambda-1") + $ Right + $ newInvokeResponse 200 + & invokeResponse_payload ?~ "" + , SendMatcher (isInvocation "lambda-2") + $ Right + $ newInvokeResponse 200 + & invokeResponse_payload ?~ BSL.toStrict (encode lambdaError) + , SendMatcher (isInvocation "lambda-3") + $ Right + $ newInvokeResponse 500 + & (invokeResponse_payload ?~ "") + . (invokeResponse_functionError ?~ "") + ] + + withMatchers matchers $ do + LambdaInvokeSuccess successPayload <- + awsLambdaInvoke "lambda-1" emptyObject + + successPayload `shouldBe` "" + + LambdaInvokeError errorPayload _ <- + awsLambdaInvoke "lambda-2" emptyObject + + errorPayload `shouldBe` lambdaError + + LambdaInvokeFailure failureStatus failureFunctionError <- + awsLambdaInvoke "lambda-3" emptyObject + + failureStatus `shouldBe` 500 + failureFunctionError `shouldBe` Just "" diff --git a/test/Stackctl/CancelHandlerSpec.hs b/test/Stackctl/CancelHandlerSpec.hs new file mode 100644 index 0000000..e32ea5e --- /dev/null +++ b/test/Stackctl/CancelHandlerSpec.hs @@ -0,0 +1,19 @@ +module Stackctl.CancelHandlerSpec + ( spec + ) where + +import Stackctl.Prelude + +import qualified Stackctl.CancelHandler as CancelHandler +import Test.Hspec + +spec :: Spec +spec = do + describe "with" $ do + it "installs a handler for the duration of a block" $ example $ do + done <- newEmptyMVar + + CancelHandler.install $ putMVar done () + CancelHandler.trigger + + takeMVar done `shouldReturn` () diff --git a/test/Stackctl/ConfigSpec.hs b/test/Stackctl/ConfigSpec.hs index 971c25b..90fd683 100644 --- a/test/Stackctl/ConfigSpec.hs +++ b/test/Stackctl/ConfigSpec.hs @@ -32,8 +32,7 @@ spec = do case result of Left err -> do expectationFailure - $ "Expected to load a Config, got error: " - <> show err + $ "Expected to load a Config, got error: " <> show err Right config -> do configParameters config `shouldBe` Just (toParametersYaml [("Some", Just "Parameter")]) @@ -56,15 +55,15 @@ spec = do Right config = loadConfigFromBytes $ "defaults:" - <> "\n tags:" - <> "\n From: Defaults" - <> "\n Keep: \"You?\"" + <> "\n tags:" + <> "\n From: Defaults" + <> "\n Keep: \"You?\"" Just tags = ssyTags (applyConfig config specYaml) tags `shouldBe` toTagsYaml - [("From", "Defaults"), ("Hi", "There"), ("Keep", "Me")] + [("Hi", "There"), ("From", "Defaults"), ("Keep", "Me")] loadConfigFromLines :: MonadError ConfigError m => [ByteString] -> m Config loadConfigFromLines = loadConfigFromBytes . mconcat . map (<> "\n") diff --git a/test/Stackctl/OneOrListOfSpec.hs b/test/Stackctl/OneOrListOfSpec.hs new file mode 100644 index 0000000..4261c89 --- /dev/null +++ b/test/Stackctl/OneOrListOfSpec.hs @@ -0,0 +1,47 @@ +module Stackctl.OneOrListOfSpec + ( spec + ) where + +import Stackctl.Prelude + +import Data.Aeson +import qualified Data.Yaml as Yaml +import Stackctl.OneOrListOf +import Test.Hspec + +data ExampleObject = ExampleObject + { oneOf :: OneOrListOf Text + , listOf :: OneOrListOf Text + } + deriving stock (Generic) + deriving anyclass (FromJSON, ToJSON) + +-- N.B. the sorting and indentation must match what encode will do in order for +-- the round-trip spec to pass. +exampleBS :: ByteString +exampleBS = + mconcat + [ "listOf:\n" + , "- one\n" + , "- two\n" + , "oneOf: one\n" + ] + +spec :: Spec +spec = do + it "Foldable" $ do + ExampleObject {..} <- Yaml.decodeThrow exampleBS + + toList oneOf `shouldBe` ["one"] + + toList listOf `shouldBe` ["one", "two"] + + it "Semigroup" $ do + ExampleObject {..} <- Yaml.decodeThrow exampleBS + + toList (oneOf <> listOf) `shouldBe` ["one", "one", "two"] + + it "From/ToJSON" $ do + decoded <- Yaml.decodeThrow @_ @ExampleObject exampleBS + + Yaml.encode decoded `shouldBe` exampleBS diff --git a/test/Stackctl/RemovedStackSpec.hs b/test/Stackctl/RemovedStackSpec.hs new file mode 100644 index 0000000..6137c91 --- /dev/null +++ b/test/Stackctl/RemovedStackSpec.hs @@ -0,0 +1,117 @@ +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + +module Stackctl.RemovedStackSpec + ( spec + ) where + +import Stackctl.Test.App + +import qualified Amazonka +import qualified Amazonka.CloudFormation as CloudFormation +import Amazonka.CloudFormation.DescribeStacks +import Amazonka.CloudFormation.Types.Stack +import qualified Data.Text as T +import Data.Time (UTCTime (..)) +import Data.Time.Calendar (DayOfMonth, MonthOfYear, Year, fromGregorian) +import Network.HTTP.Types.Status (status400) +import Stackctl.AWS.CloudFormation +import Stackctl.DirectoryOption (DirectoryOption (..), directoryOptionL) +import Stackctl.FilterOption (filterOptionFromText, filterOptionL) +import Stackctl.RemovedStack +import UnliftIO.Directory (createDirectoryIfMissing) + +spec :: Spec +spec = do + describe "inferRemovedStacks" $ do + it "returns stacks in filters that aren't on disk" $ example $ runTestAppT $ do + let + Just filterOption = + filterOptionFromText + $ T.intercalate + "," + [ pack $ testAppStackFilePath "stack-exists" + , pack $ testAppStackFilePath "stack-is-missing" + , "stacks/0123456789.test/us-east-2/wrong-region.yaml" + , "stacks/2123456789.test/us-east-1/wrong-account.yaml" + ] + + setup :: TestApp -> TestApp + setup = filterOptionL .~ filterOption + + matchers = + [ describeStackMatcher "stack-exists" $ Just $ someStack "stack-exists" + , describeStackMatcher "stack-is-missing" Nothing + , describeStackMatcher "wrong-region" Nothing + , describeStackMatcher "wrong-account" Nothing + ] + + stacks <- local setup $ withMatchers matchers inferRemovedStacks + map (^. stack_stackName) stacks `shouldBe` ["stack-exists"] + + -- If we don't check for file existence respecting STACKCTL_DIRECTORY, then + -- any non-default value will cause all specs to appear non-existent and be + -- flagged for removal. Eek. + it "respects STACKCTL_DIRECTORY" $ example $ runTestAppT $ do + let + dir = "/tmp/stackctl-test" + toRemove = "stack-to-remove" + toKeep = "stack-to-keep" + relativeToRemove = testAppStackFilePath toRemove + relativeToKeep = testAppStackFilePath toKeep + absoluteToKeep = dir relativeToKeep + Just filterOption = + filterOptionFromText + $ pack relativeToRemove + <> "," + <> pack relativeToKeep + + setup :: TestApp -> TestApp + setup app = + app + & filterOptionL .~ filterOption + & directoryOptionL .~ DirectoryOption dir + + matchers = + [ describeStackMatcher toRemove $ Just $ someStack toRemove + , describeStackMatcher toKeep $ Just $ someStack toKeep + ] + + -- Create a spec on disk for toKeep, then we should only find toRemove + createDirectoryIfMissing True $ takeDirectory absoluteToKeep + writeFileUtf8 absoluteToKeep "{}" + + stacks <- local setup $ withMatchers matchers inferRemovedStacks + map (^. stack_stackName) stacks `shouldBe` [toRemove] + +describeStackMatcher :: Text -> Maybe Stack -> Matcher +describeStackMatcher name = + SendMatcher ((== Just name) . (^. describeStacks_stackName)) + . maybe + (Left cloudFormationValidationError) + ( \stack -> + Right + $ newDescribeStacksResponse 200 + & describeStacksResponse_stacks ?~ [stack] + ) + +someStack :: Text -> Stack +someStack name = newStack name (midnight 2024 1 1) StackStatus_CREATE_COMPLETE + +midnight :: Year -> MonthOfYear -> DayOfMonth -> UTCTime +midnight y m d = + UTCTime + { utctDay = fromGregorian y m d + , utctDayTime = 0 + } + +cloudFormationValidationError :: Amazonka.Error +cloudFormationValidationError = + Amazonka.ServiceError + $ Amazonka.ServiceError' + { Amazonka.abbrev = CloudFormation.defaultService ^. Amazonka.service_abbrev + , Amazonka.status = status400 + , Amazonka.headers = [] + , Amazonka.code = "ValidationError" + , Amazonka.message = Nothing + , Amazonka.requestId = Nothing + } diff --git a/test/Stackctl/Spec/Changes/FormatSpec.hs b/test/Stackctl/Spec/Changes/FormatSpec.hs new file mode 100644 index 0000000..ca5c8ae --- /dev/null +++ b/test/Stackctl/Spec/Changes/FormatSpec.hs @@ -0,0 +1,48 @@ +module Stackctl.Spec.Changes.FormatSpec + ( spec + ) +where + +import Stackctl.Prelude + +import Data.Aeson +import Stackctl.AWS.CloudFormation (ChangeSetType (..), changeSetFromResponse) +import Stackctl.Colors +import Stackctl.Spec.Changes.Format +import System.FilePath ((-<.>)) +import System.FilePath.Glob (globDir1) +import Test.Hspec +import Test.Hspec.Golden + +spec :: Spec +spec = do + describe "formatChangeSet" $ do + paths <- runIO $ globDir1 "**/*.json" "test/files/change-sets" + + for_ paths $ \path -> do + for_ [minBound .. maxBound] $ \fmt -> do + it (path <> " as " <> show fmt) $ do + formatChangeSetGolden path fmt + +formatChangeSetGolden :: FilePath -> Format -> IO (Golden Text) +formatChangeSetGolden path fmt = do + actual <- + formatChangeSet noColors OmitFull "some-stack" fmt + . (changeSetFromResponse ChangeSetType_UPDATE <=< decodeStrict) + . encodeUtf8 + <$> readFileUtf8 path + + pure + $ Golden + { output = actual + , encodePretty = unpack + , writeToFile = writeFileUtf8 + , readFromFile = readFileUtf8 + , goldenFile = path -<.> ext + , actualFile = Nothing + , failFirstTime = False + } + where + ext = case fmt of + FormatTTY -> "txt" + FormatPullRequest -> "md" diff --git a/test/Stackctl/StackSpecYamlSpec.hs b/test/Stackctl/StackSpecYamlSpec.hs index 91942dc..1dd9f09 100644 --- a/test/Stackctl/StackSpecYamlSpec.hs +++ b/test/Stackctl/StackSpecYamlSpec.hs @@ -24,7 +24,7 @@ spec = do , ssyDepends = Just [StackName "a-stack", StackName "another-stack"] , ssyActions = Just - [newAction PostDeploy $ InvokeLambdaByName "a-lambda"] + [newAction PostDeploy [InvokeLambdaByName "a-lambda"]] , ssyParameters = Just $ parametersYaml @@ -80,28 +80,6 @@ spec = do param ^. parameter_parameterKey `shouldBe` Just "Pie" param ^. parameter_parameterValue `shouldBe` Just "3.14" - it "has informative errors" $ do - let Left ex = - Yaml.decodeEither' @StackSpecYaml - $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - ParameterKey: Norway\n" - , " ParameterValue: no\n" - ] - - show ex - `shouldBe` "AesonException \"Error in $.Parameters[0].ParameterValue: Expected String or Number, got: Bool False\"" - - it "has informative errors in Object form" $ do - let Left ex = - Yaml.decodeEither' @StackSpecYaml - $ mconcat - ["Template: foo.yaml\n", "Parameters:\n", " Norway: no\n"] - - show ex - `shouldBe` "AesonException \"Error in $.Parameters.Norway: Expected String or Number, got: Bool False\"" - it "handles null Value" $ do StackSpecYaml {..} <- Yaml.decodeThrow diff --git a/test/Stackctl/Test/App.hs b/test/Stackctl/Test/App.hs new file mode 100644 index 0000000..053d613 --- /dev/null +++ b/test/Stackctl/Test/App.hs @@ -0,0 +1,96 @@ +module Stackctl.Test.App + ( TestApp + , testAppAwsScope + , testAppStackFilePath + , TestAppT + , runTestAppT + + -- * Re-exports + , module Stackctl.Prelude + , module Control.Lens + , module Control.Monad.AWS.ViaMock + , module Test.Hspec + , module Test.Hspec.Expectations.Lifted + ) where + +import Stackctl.Prelude + +import Blammo.Logging.LogSettings (defaultLogSettings) +import Blammo.Logging.Logger (newTestLogger) +import Control.Lens ((?~)) +import Control.Monad.AWS +import Control.Monad.AWS.ViaMock +import Stackctl.AWS.Core (AccountId (..)) +import Stackctl.AWS.Scope +import Stackctl.DirectoryOption +import Stackctl.FilterOption +import Test.Hspec (Spec, describe, example, it) +import Test.Hspec.Expectations.Lifted + +data TestApp = TestApp + { taLogger :: Logger + , taMatchers :: Matchers + , taAwsScope :: AwsScope + , taFilterOption :: FilterOption + , taDirectoryOption :: DirectoryOption + } + +instance HasLogger TestApp where + loggerL = lens taLogger $ \x y -> x {taLogger = y} + +instance HasMatchers TestApp where + matchersL = lens taMatchers $ \x y -> x {taMatchers = y} + +instance HasAwsScope TestApp where + awsScopeL = lens taAwsScope $ \x y -> x {taAwsScope = y} + +instance HasFilterOption TestApp where + filterOptionL = lens taFilterOption $ \x y -> x {taFilterOption = y} + +instance HasDirectoryOption TestApp where + directoryOptionL = lens taDirectoryOption $ \x y -> x {taDirectoryOption = y} + +newtype TestAppT m a = TestAppT + { unTestAppT :: ReaderT TestApp (LoggingT m) a + } + deriving newtype + ( Functor + , Applicative + , Monad + , MonadIO + , MonadUnliftIO + , MonadLogger + , MonadReader TestApp + ) + deriving (MonadAWS) via (MockAWS (TestAppT m)) + +instance MonadIO m => MonadFail (TestAppT m) where + fail msg = expectationFailure msg >> error "unreachable" + +runTestAppT :: MonadUnliftIO m => TestAppT m a -> m a +runTestAppT f = do + app <- + TestApp + <$> newTestLogger defaultLogSettings + <*> pure mempty + <*> pure testAppAwsScope + <*> pure defaultFilterOption + <*> pure defaultDirectoryOption + + runLoggerLoggingT app $ runReaderT (unTestAppT f) app + +testAppAwsScope :: AwsScope +testAppAwsScope = + AwsScope + { awsAccountId = AccountId "0123456789" + , awsAccountName = "test" + , awsRegion = "us-east-1" + } + +-- | Gives a filepath relative to 'testAwsScope' +testAppStackFilePath :: Text -> FilePath +testAppStackFilePath base = + "stacks" + "0123456789.test" + "us-east-1" + unpack base <.> "yaml" diff --git a/test/files/change-sets/prod-faktory.json b/test/files/change-sets/prod-faktory.json new file mode 100644 index 0000000..25f319f --- /dev/null +++ b/test/files/change-sets/prod-faktory.json @@ -0,0 +1,1114 @@ +{ + "capabilities": [ + "CAPABILITY_IAM" + ], + "changeSetId": "arn:aws:cloudformation:us-east-1:999999999999:changeSet/stackctl-202307312011-484b4202-2b16-44d5-ad51-673ec8ec057e/87b2b49a-5bac-4202-ba2f-25e2a4f18315", + "changeSetName": "stackctl-202307312011-484b4202-2b16-44d5-ad51-673ec8ec057e", + "changes": [ + { + "resourceChange": { + "action": "Add", + "changeSetId": null, + "details": null, + "logicalResourceId": "ASGDepends", + "moduleInfo": null, + "physicalResourceId": null, + "replacement": null, + "resourceType": "AWS::CloudFormation::WaitConditionHandle", + "scope": null + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "ASGLambdaExecutionRole", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-ASGLambdaExecutionRole-U9NPRW10WHGZ", + "replacement": "False", + "resourceType": "AWS::IAM::Role", + "scope": [ + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "FunctionName", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "ASGLifecycleLambdaFunction.Arn", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "FunctionName", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "ASGLambdaInvokePermission", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-ASGLambdaInvokePermission-1475JOG38K6P", + "replacement": "Conditional", + "resourceType": "AWS::Lambda::Permission", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "ASGLifecycleLambdaFunction.Arn", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Endpoint", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Endpoint", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "ASGLambdaSNSSubscription", + "moduleInfo": null, + "physicalResourceId": "arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ:4fec6f06-f72b-488b-9d97-c7a4621be7ca", + "replacement": "Conditional", + "resourceType": "AWS::SNS::Subscription", + "scope": [ + "Properties" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Role", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "ASGLambdaExecutionRole.Arn", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Role", + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "ASGLifecycleLambdaFunction", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-ASGLifecycleLambdaFunctio-C1RAZ4U57W7R", + "replacement": "False", + "resourceType": "AWS::Lambda::Function", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "ASGLifecycleLambdaFunction.Arn", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Subscription", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "Subscription", + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "ASGSNSTopic", + "moduleInfo": null, + "physicalResourceId": "arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ", + "replacement": "False", + "resourceType": "AWS::SNS::Topic", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "SNSLambdaRole.Arn", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "RoleARN", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "RoleARN", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "AutoScalingGroup", + "changeSource": "ResourceReference", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AutoScalingGroupName", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "ASGTerminateHook", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-ASGTerminateHook-18CDA25XDPELR", + "replacement": "Conditional", + "resourceType": "AWS::AutoScaling::LifecycleHook", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "LaunchConfiguration", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "LaunchConfigurationName", + "requiresRecreation": "Conditionally" + } + }, + { + "causingEntity": "NLBTargetGroup", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "TargetGroupARNs", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "VPCZoneIdentifier", + "requiresRecreation": "Conditionally" + } + }, + { + "causingEntity": "Environment", + "changeSource": "ParameterReference", + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "AutoScalingGroup", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-AutoScalingGroup-13NOD2PK314EV", + "replacement": "Conditional", + "resourceType": "AWS::AutoScaling::AutoScalingGroup", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "GroupDescription", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "SecurityGroupIngress", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "IngressTo", + "changeSource": "ParameterReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SecurityGroupIngress", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "VpcId", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": "Name", + "changeSource": "ParameterReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "GroupDescription", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": "IngressFrom", + "changeSource": "ParameterReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SecurityGroupIngress", + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "ClusterSecurityGroup", + "moduleInfo": null, + "physicalResourceId": "sg-04a524560191c2e42", + "replacement": "True", + "resourceType": "AWS::EC2::SecurityGroup", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "ECSCluster", + "moduleInfo": null, + "physicalResourceId": "prod-faktory", + "replacement": "False", + "resourceType": "AWS::ECS::Cluster", + "scope": [ + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "EFSFileSystem", + "moduleInfo": null, + "physicalResourceId": "fs-9529dc77", + "replacement": "False", + "resourceType": "AWS::EFS::FileSystem", + "scope": [ + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "EFSSecurityGroup", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SecurityGroups", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SubnetId", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "EFSMountTarget", + "moduleInfo": null, + "physicalResourceId": "fsmt-e4d41504", + "replacement": "True", + "resourceType": "AWS::EFS::MountTarget", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "ClusterSecurityGroup", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SecurityGroupIngress", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "SecurityGroupIngress", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "VpcId", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "EFSSecurityGroup", + "moduleInfo": null, + "physicalResourceId": "sg-07bd7bd588bafb48b", + "replacement": "True", + "resourceType": "AWS::EC2::SecurityGroup", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "InstanceProfile", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-InstanceProfile-1W0KE43Y2TWDU", + "replacement": "False", + "resourceType": "AWS::IAM::InstanceProfile", + "scope": [ + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "Policies", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "InstanceRole", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-InstanceRole-1EEWHD4VKB56Y", + "replacement": "False", + "resourceType": "AWS::IAM::Role", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "ImageId", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "UserData", + "requiresRecreation": "Always" + } + }, + { + "causingEntity": "ClusterSecurityGroup", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "SecurityGroups", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "LaunchConfiguration", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-LaunchConfiguration-Mko0Jp8XTuKr", + "replacement": "True", + "resourceType": "AWS::AutoScaling::LaunchConfiguration", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "NLB.CanonicalHostedZoneID", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AliasTarget", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AliasTarget", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "NLB.DNSName", + "changeSource": "ResourceAttribute", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AliasTarget", + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "NLBDNSAliasRecord", + "moduleInfo": null, + "physicalResourceId": "faktory-internal.freckle.com", + "replacement": "False", + "resourceType": "AWS::Route53::RecordSet", + "scope": [ + "Properties" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": "NLBTargetGroup", + "changeSource": "ResourceReference", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "DefaultActions", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "NLBListener", + "moduleInfo": null, + "physicalResourceId": "arn:aws:elasticloadbalancing:us-east-1:999999999999:listener/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594/80372abc7d907982", + "replacement": "False", + "resourceType": "AWS::ElasticLoadBalancingV2::Listener", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "VpcId", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "NLBTargetGroup", + "moduleInfo": null, + "physicalResourceId": "arn:aws:elasticloadbalancing:us-east-1:999999999999:targetgroup/prod-NLBTa-RNPHZHDRPVIZ/73c88e6d9e0e5b01", + "replacement": "True", + "resourceType": "AWS::ElasticLoadBalancingV2::TargetGroup", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": "DirectModification", + "evaluation": "Static", + "target": { + "attribute": "Properties", + "name": "Subnets", + "requiresRecreation": "Never" + } + }, + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "NLB", + "moduleInfo": null, + "physicalResourceId": "arn:aws:elasticloadbalancing:us-east-1:999999999999:loadbalancer/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594", + "replacement": "False", + "resourceType": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + } + ], + "logicalResourceId": "SNSLambdaRole", + "moduleInfo": null, + "physicalResourceId": "prod-faktory-ecs-cluster-SNSLambdaRole-YSXPOSGWHAYA", + "replacement": "False", + "resourceType": "AWS::IAM::Role", + "scope": [ + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "AutoScalingGroup", + "changeSource": "ResourceReference", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AutoScalingGroupName", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "ScaleDownScheduledAction", + "moduleInfo": null, + "physicalResourceId": "prod-Scale-1KE1OLZRQSSXA", + "replacement": "Conditional", + "resourceType": "AWS::AutoScaling::ScheduledAction", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + }, + { + "resourceChange": { + "action": "Modify", + "changeSetId": null, + "details": [ + { + "causingEntity": null, + "changeSource": null, + "evaluation": "Static", + "target": { + "attribute": "Tags", + "name": null, + "requiresRecreation": "Never" + } + }, + { + "causingEntity": "AutoScalingGroup", + "changeSource": "ResourceReference", + "evaluation": "Dynamic", + "target": { + "attribute": "Properties", + "name": "AutoScalingGroupName", + "requiresRecreation": "Always" + } + } + ], + "logicalResourceId": "ScaleUpScheduledAction", + "moduleInfo": null, + "physicalResourceId": "prod-Scale-13PIRUZGZW86O", + "replacement": "Conditional", + "resourceType": "AWS::AutoScaling::ScheduledAction", + "scope": [ + "Properties", + "Tags" + ] + }, + "type'": "Resource" + } + ], + "creationTime": "2023-07-31T20:11:25Z", + "description": null, + "executionStatus": "AVAILABLE", + "httpStatus": 200, + "includeNestedStacks": false, + "nextToken": null, + "notificationARNs": null, + "parameters": [ + { + "parameterKey": "IngressFrom", + "parameterValue": "7419", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "IngressTo", + "parameterValue": "7420", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "ScaleUpRecurrence", + "parameterValue": "0 1 * * 2", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "NLBSubDomain", + "parameterValue": "faktory-internal", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "ClusterRole", + "parameterValue": null, + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "Name", + "parameterValue": "faktory", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "NLBDomain", + "parameterValue": "freckle.com", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "ProvisionedThroughputInMibps", + "parameterValue": "3", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "Environment", + "parameterValue": "prod", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "NLBPort", + "parameterValue": "7419", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "InstanceType", + "parameterValue": "t3.medium", + "resolvedValue": null, + "usePreviousValue": null + }, + { + "parameterKey": "ECSAMI", + "parameterValue": "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id", + "resolvedValue": "ami-05aca8932be1b68c3", + "usePreviousValue": null + }, + { + "parameterKey": "ScaleDownRecurrence", + "parameterValue": "10 1 * * 2", + "resolvedValue": null, + "usePreviousValue": null + } + ], + "parentChangeSetId": null, + "rollbackConfiguration": null, + "rootChangeSetId": null, + "stackId": "arn:aws:cloudformation:us-east-1:999999999999:stack/prod-faktory-ecs-cluster/f09dbb60-b244-11e9-a24c-120371d9064c", + "stackName": "prod-faktory-ecs-cluster", + "status": "CREATE_COMPLETE", + "statusReason": null, + "tags": [ + { + "key": "Owner", + "value": "Platform" + }, + { + "key": "DeployedBy", + "value": "github:freckle/infa" + }, + { + "key": "service", + "value": "faktory" + }, + { + "key": "env", + "value": "prod" + } + ] +} diff --git a/test/files/change-sets/prod-faktory.md b/test/files/change-sets/prod-faktory.md new file mode 100644 index 0000000..b5f37da --- /dev/null +++ b/test/files/change-sets/prod-faktory.md @@ -0,0 +1,27 @@ +:warning: This PR generates **23** changes for `some-stack`. + +| Action | Logical Id | Physical Id | Type | Replacement | Scope | Details | +| --- | --- | --- | --- | --- | --- | --- | +| Add | ASGDepends | | AWS::CloudFormation::WaitConditionHandle | | | | +| Modify | ASGLambdaExecutionRole | prod-faktory-ecs-cluster-ASGLambdaExecutionRole-U9NPRW10WHGZ | AWS::IAM::Role | False | Tags |
    | +| Modify | ASGLifecycleLambdaFunction | prod-faktory-ecs-cluster-ASGLifecycleLambdaFunctio-C1RAZ4U57W7R | AWS::Lambda::Function | False | Properties, Tags |
    • DirectModification in Properties (Role), recreation Never
    • ResourceAttribute in Properties (Role), recreation Never, caused by ASGLambdaExecutionRole.Arn
    | +| Modify | ASGLambdaInvokePermission | prod-faktory-ecs-cluster-ASGLambdaInvokePermission-1475JOG38K6P | AWS::Lambda::Permission | Conditional | Properties, Tags |
    • DirectModification in Properties (FunctionName), recreation Always
    • ResourceAttribute in Properties (FunctionName), recreation Always, caused by ASGLifecycleLambdaFunction.Arn
    | +| Modify | ASGLambdaSNSSubscription | arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ:4fec6f06-f72b-488b-9d97-c7a4621be7ca | AWS::SNS::Subscription | Conditional | Properties |
    • ResourceAttribute in Properties (Endpoint), recreation Always, caused by ASGLifecycleLambdaFunction.Arn
    • DirectModification in Properties (Endpoint), recreation Always
    | +| Modify | ASGSNSTopic | arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ | AWS::SNS::Topic | False | Properties, Tags |
    • ResourceAttribute in Properties (Subscription), recreation Never, caused by ASGLifecycleLambdaFunction.Arn
    • DirectModification in Properties (Subscription), recreation Never
    | +| Modify | SNSLambdaRole | prod-faktory-ecs-cluster-SNSLambdaRole-YSXPOSGWHAYA | AWS::IAM::Role | False | Tags |
      | +| Modify | ClusterSecurityGroup | sg-04a524560191c2e42 | AWS::EC2::SecurityGroup | True | Properties, Tags |
      • DirectModification in Properties (GroupDescription), recreation Always
      • DirectModification in Properties (SecurityGroupIngress), recreation Never
      • ParameterReference in Properties (SecurityGroupIngress), recreation Never, caused by IngressTo
      • DirectModification in Properties (VpcId), recreation Always
      • ParameterReference in Properties (GroupDescription), recreation Always, caused by Name
      • ParameterReference in Properties (SecurityGroupIngress), recreation Never, caused by IngressFrom
      | +| Modify | LaunchConfiguration | prod-faktory-ecs-cluster-LaunchConfiguration-Mko0Jp8XTuKr | AWS::AutoScaling::LaunchConfiguration | True | Properties, Tags |
      • DirectModification in Properties (ImageId), recreation Always
      • DirectModification in Properties (UserData), recreation Always
      • ResourceReference in Properties (SecurityGroups), recreation Always, caused by ClusterSecurityGroup
      | +| Modify | NLBTargetGroup | arn:aws:elasticloadbalancing:us-east-1:999999999999:targetgroup/prod-NLBTa-RNPHZHDRPVIZ/73c88e6d9e0e5b01 | AWS::ElasticLoadBalancingV2::TargetGroup | True | Properties, Tags |
      • DirectModification in Properties (VpcId), recreation Always
      | +| Modify | AutoScalingGroup | prod-faktory-ecs-cluster-AutoScalingGroup-13NOD2PK314EV | AWS::AutoScaling::AutoScalingGroup | Conditional | Properties, Tags |
      • ResourceReference in Properties (LaunchConfigurationName), recreation Conditionally, caused by LaunchConfiguration
      • ResourceReference in Properties (TargetGroupARNs), recreation Never, caused by NLBTargetGroup
      • DirectModification in Properties (VPCZoneIdentifier), recreation Conditionally
      • ParameterReference in Tags, recreation Never, caused by Environment
      • DirectModification in Tags, recreation Never
      | +| Modify | ASGTerminateHook | prod-faktory-ecs-cluster-ASGTerminateHook-18CDA25XDPELR | AWS::AutoScaling::LifecycleHook | Conditional | Properties, Tags |
      • ResourceAttribute in Properties (RoleARN), recreation Never, caused by SNSLambdaRole.Arn
      • DirectModification in Properties (RoleARN), recreation Never
      • ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup
      | +| Modify | ECSCluster | prod-faktory | AWS::ECS::Cluster | False | Tags |
        | +| Modify | EFSFileSystem | fs-9529dc77 | AWS::EFS::FileSystem | False | Tags |
          | +| Modify | EFSSecurityGroup | sg-07bd7bd588bafb48b | AWS::EC2::SecurityGroup | True | Properties, Tags |
          • ResourceReference in Properties (SecurityGroupIngress), recreation Never, caused by ClusterSecurityGroup
          • DirectModification in Properties (SecurityGroupIngress), recreation Never
          • DirectModification in Properties (VpcId), recreation Always
          | +| Modify | EFSMountTarget | fsmt-e4d41504 | AWS::EFS::MountTarget | True | Properties, Tags |
          • ResourceReference in Properties (SecurityGroups), recreation Never, caused by EFSSecurityGroup
          • DirectModification in Properties (SubnetId), recreation Always
          | +| Modify | InstanceProfile | prod-faktory-ecs-cluster-InstanceProfile-1W0KE43Y2TWDU | AWS::IAM::InstanceProfile | False | Tags |
            | +| Modify | InstanceRole | prod-faktory-ecs-cluster-InstanceRole-1EEWHD4VKB56Y | AWS::IAM::Role | False | Properties, Tags |
            • DirectModification in Properties (Policies), recreation Never
            | +| Modify | NLB | arn:aws:elasticloadbalancing:us-east-1:999999999999:loadbalancer/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594 | AWS::ElasticLoadBalancingV2::LoadBalancer | False | Properties, Tags |
            • DirectModification in Properties (Subnets), recreation Never
            | +| Modify | NLBDNSAliasRecord | faktory-internal.freckle.com | AWS::Route53::RecordSet | False | Properties |
            • ResourceAttribute in Properties (AliasTarget), recreation Never, caused by NLB.CanonicalHostedZoneID
            • DirectModification in Properties (AliasTarget), recreation Never
            • ResourceAttribute in Properties (AliasTarget), recreation Never, caused by NLB.DNSName
            | +| Modify | NLBListener | arn:aws:elasticloadbalancing:us-east-1:999999999999:listener/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594/80372abc7d907982 | AWS::ElasticLoadBalancingV2::Listener | False | Properties, Tags |
            • ResourceReference in Properties (DefaultActions), recreation Never, caused by NLBTargetGroup
            | +| Modify | ScaleDownScheduledAction | prod-Scale-1KE1OLZRQSSXA | AWS::AutoScaling::ScheduledAction | Conditional | Properties, Tags |
            • ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup
            | +| Modify | ScaleUpScheduledAction | prod-Scale-13PIRUZGZW86O | AWS::AutoScaling::ScheduledAction | Conditional | Properties, Tags |
            • ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup
            | diff --git a/test/files/change-sets/prod-faktory.txt b/test/files/change-sets/prod-faktory.txt new file mode 100644 index 0000000..7b09992 --- /dev/null +++ b/test/files/change-sets/prod-faktory.txt @@ -0,0 +1,130 @@ + +Changes for some-stack: + Add ASGDepends (AWS::CloudFormation::WaitConditionHandle) + Modify ASGLambdaExecutionRole (AWS::IAM::Role) prod-faktory-ecs-cluster-ASGLambdaExecutionRole-U9NPRW10WHGZ + Replacement: False + Scope: Tags + Details: + Modify ASGLifecycleLambdaFunction (AWS::Lambda::Function) prod-faktory-ecs-cluster-ASGLifecycleLambdaFunctio-C1RAZ4U57W7R + Replacement: False + Scope: Properties, Tags + Details: + * DirectModification in Properties (Role), recreation Never + * ResourceAttribute in Properties (Role), recreation Never, caused by ASGLambdaExecutionRole.Arn + Modify ASGLambdaInvokePermission (AWS::Lambda::Permission) prod-faktory-ecs-cluster-ASGLambdaInvokePermission-1475JOG38K6P + Replacement: Conditional + Scope: Properties, Tags + Details: + * DirectModification in Properties (FunctionName), recreation Always + * ResourceAttribute in Properties (FunctionName), recreation Always, caused by ASGLifecycleLambdaFunction.Arn + Modify ASGLambdaSNSSubscription (AWS::SNS::Subscription) arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ:4fec6f06-f72b-488b-9d97-c7a4621be7ca + Replacement: Conditional + Scope: Properties + Details: + * ResourceAttribute in Properties (Endpoint), recreation Always, caused by ASGLifecycleLambdaFunction.Arn + * DirectModification in Properties (Endpoint), recreation Always + Modify ASGSNSTopic (AWS::SNS::Topic) arn:aws:sns:us-east-1:999999999999:prod-faktory-ecs-cluster-ASGSNSTopic-1GZCULO2RMPPZ + Replacement: False + Scope: Properties, Tags + Details: + * ResourceAttribute in Properties (Subscription), recreation Never, caused by ASGLifecycleLambdaFunction.Arn + * DirectModification in Properties (Subscription), recreation Never + Modify SNSLambdaRole (AWS::IAM::Role) prod-faktory-ecs-cluster-SNSLambdaRole-YSXPOSGWHAYA + Replacement: False + Scope: Tags + Details: + Modify ClusterSecurityGroup (AWS::EC2::SecurityGroup) sg-04a524560191c2e42 + Replacement: True + Scope: Properties, Tags + Details: + * DirectModification in Properties (GroupDescription), recreation Always + * DirectModification in Properties (SecurityGroupIngress), recreation Never + * ParameterReference in Properties (SecurityGroupIngress), recreation Never, caused by IngressTo + * DirectModification in Properties (VpcId), recreation Always + * ParameterReference in Properties (GroupDescription), recreation Always, caused by Name + * ParameterReference in Properties (SecurityGroupIngress), recreation Never, caused by IngressFrom + Modify LaunchConfiguration (AWS::AutoScaling::LaunchConfiguration) prod-faktory-ecs-cluster-LaunchConfiguration-Mko0Jp8XTuKr + Replacement: True + Scope: Properties, Tags + Details: + * DirectModification in Properties (ImageId), recreation Always + * DirectModification in Properties (UserData), recreation Always + * ResourceReference in Properties (SecurityGroups), recreation Always, caused by ClusterSecurityGroup + Modify NLBTargetGroup (AWS::ElasticLoadBalancingV2::TargetGroup) arn:aws:elasticloadbalancing:us-east-1:999999999999:targetgroup/prod-NLBTa-RNPHZHDRPVIZ/73c88e6d9e0e5b01 + Replacement: True + Scope: Properties, Tags + Details: + * DirectModification in Properties (VpcId), recreation Always + Modify AutoScalingGroup (AWS::AutoScaling::AutoScalingGroup) prod-faktory-ecs-cluster-AutoScalingGroup-13NOD2PK314EV + Replacement: Conditional + Scope: Properties, Tags + Details: + * ResourceReference in Properties (LaunchConfigurationName), recreation Conditionally, caused by LaunchConfiguration + * ResourceReference in Properties (TargetGroupARNs), recreation Never, caused by NLBTargetGroup + * DirectModification in Properties (VPCZoneIdentifier), recreation Conditionally + * ParameterReference in Tags, recreation Never, caused by Environment + * DirectModification in Tags, recreation Never + Modify ASGTerminateHook (AWS::AutoScaling::LifecycleHook) prod-faktory-ecs-cluster-ASGTerminateHook-18CDA25XDPELR + Replacement: Conditional + Scope: Properties, Tags + Details: + * ResourceAttribute in Properties (RoleARN), recreation Never, caused by SNSLambdaRole.Arn + * DirectModification in Properties (RoleARN), recreation Never + * ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup + Modify ECSCluster (AWS::ECS::Cluster) prod-faktory + Replacement: False + Scope: Tags + Details: + Modify EFSFileSystem (AWS::EFS::FileSystem) fs-9529dc77 + Replacement: False + Scope: Tags + Details: + Modify EFSSecurityGroup (AWS::EC2::SecurityGroup) sg-07bd7bd588bafb48b + Replacement: True + Scope: Properties, Tags + Details: + * ResourceReference in Properties (SecurityGroupIngress), recreation Never, caused by ClusterSecurityGroup + * DirectModification in Properties (SecurityGroupIngress), recreation Never + * DirectModification in Properties (VpcId), recreation Always + Modify EFSMountTarget (AWS::EFS::MountTarget) fsmt-e4d41504 + Replacement: True + Scope: Properties, Tags + Details: + * ResourceReference in Properties (SecurityGroups), recreation Never, caused by EFSSecurityGroup + * DirectModification in Properties (SubnetId), recreation Always + Modify InstanceProfile (AWS::IAM::InstanceProfile) prod-faktory-ecs-cluster-InstanceProfile-1W0KE43Y2TWDU + Replacement: False + Scope: Tags + Details: + Modify InstanceRole (AWS::IAM::Role) prod-faktory-ecs-cluster-InstanceRole-1EEWHD4VKB56Y + Replacement: False + Scope: Properties, Tags + Details: + * DirectModification in Properties (Policies), recreation Never + Modify NLB (AWS::ElasticLoadBalancingV2::LoadBalancer) arn:aws:elasticloadbalancing:us-east-1:999999999999:loadbalancer/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594 + Replacement: False + Scope: Properties, Tags + Details: + * DirectModification in Properties (Subnets), recreation Never + Modify NLBDNSAliasRecord (AWS::Route53::RecordSet) faktory-internal.freckle.com + Replacement: False + Scope: Properties + Details: + * ResourceAttribute in Properties (AliasTarget), recreation Never, caused by NLB.CanonicalHostedZoneID + * DirectModification in Properties (AliasTarget), recreation Never + * ResourceAttribute in Properties (AliasTarget), recreation Never, caused by NLB.DNSName + Modify NLBListener (AWS::ElasticLoadBalancingV2::Listener) arn:aws:elasticloadbalancing:us-east-1:999999999999:listener/net/prod-fa-NLB-HHEBROLH5J6T/6b647d08dcc2d594/80372abc7d907982 + Replacement: False + Scope: Properties, Tags + Details: + * ResourceReference in Properties (DefaultActions), recreation Never, caused by NLBTargetGroup + Modify ScaleDownScheduledAction (AWS::AutoScaling::ScheduledAction) prod-Scale-1KE1OLZRQSSXA + Replacement: Conditional + Scope: Properties, Tags + Details: + * ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup + Modify ScaleUpScheduledAction (AWS::AutoScaling::ScheduledAction) prod-Scale-13PIRUZGZW86O + Replacement: Conditional + Scope: Properties, Tags + Details: + * ResourceReference in Properties (AutoScalingGroupName), recreation Always, caused by AutoScalingGroup