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 85ca0ee..0742ac4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,22 +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..d29d504 --- /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.2.0 + with: + quarantine-days: -1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..b013786 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Pages + +on: + push: + branches: "main" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - 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 + + - name: Generate HTML man-pages + run: ronn --style toc,custom --html man/*.ronn + env: + RONN_STYLE: ./man + RONN_ORGANIZATION: Freckle Engineering + + - name: Copy HTML sources to _site + run: | + mkdir -p _site + cp -v man/*.html _site/ + cp -v _site/stackctl.1.html _site/index.html + + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f091f2e..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,28 +19,61 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 - - uses: freckle/stack-cache-action@v2 - - uses: r-lib/actions/setup-pandoc@v2 - if: ${{ runner.os == 'macOS' }} run: brew install coreutils # need GNU install - - run: make install.check PANDOC=pandoc - - 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 85e0c44..20a7c51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ *.hie .stack-work dist/ +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 dbf806a..c25b270 100644 --- a/.restyled.yaml +++ b/.restyled.yaml @@ -1,6 +1,12 @@ restylers_version: dev restylers: - - brittany + - cabal-fmt: + enabled: false + - fourmolu: + image: + tag: v0.17.0.0 + - stylish-haskell: + enabled: false - prettier-markdown: enabled: false - whitespace: @@ -9,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/.stylish-haskell.yaml b/.stylish-haskell.yaml deleted file mode 100644 index b146c9e..0000000 --- a/.stylish-haskell.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -steps: - - simple_align: - cases: false - top_level_patterns: false - records: false - - imports: - align: none - list_align: after_alias - pad_module_names: false - long_list_align: new_line_multiline - empty_list_align: right_after - list_padding: 2 - separate_lists: false - space_surround: false - - language_pragmas: - style: vertical - align: false - remove_redundant: false - - trailing_whitespace: {} -columns: 80 -newline: native - -# Infer extensions from .cabal file -cabal: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e43f0..f63b56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,137 +1 @@ -## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.3.0.0...main) - -## [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 361c565..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 \ @@ -30,11 +31,9 @@ dist/stackctl/completion/%: dist/stackctl/stackctl mkdir -p ./dist/stackctl/completion ./$< --$(@F)-completion-script stackctl > dist/stackctl/completion/$(@F) -PANDOC ?= stack exec pandoc -- - -dist/stackctl/doc/%: doc/%.md +dist/stackctl/doc/%: man/%.ronn mkdir -p ./dist/stackctl/doc - $(PANDOC) --standalone $< --to man >$@ + ronn --organization "Freckle Engineering" --roff <"$<" >"$@" dist/stackctl/Makefile: Makefile mkdir -p dist/stackctl @@ -58,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 @@ -71,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 a14ebd6..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. @@ -24,6 +24,7 @@ This project also contains a Haskell library for doing the same. - Have `~/.local/bin` on your `$PATH` - Have `~/.local/share/man` on your `$MANPATH` (for documentation) - If on OSX, `brew install coreutils` (i.e. have `ginstall` available) +- If on OSX, `brew install jq` ### Scripted @@ -31,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 @@ -72,8 +74,48 @@ Once installed, see: - `man 1 stackctl`, or - `man 1 stackctl ` -The man pages are also available [in-repository](./doc), but contain -documentation as of `main`, and not your installed version. +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 diff --git a/app/Main.hs b/app/Main.hs index 90e0b00..3c4b678 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -11,7 +11,8 @@ main :: IO () main = runSubcommand $ subcommand Commands.cat - <> subcommand Commands.capture - <> subcommand Commands.changes - <> subcommand Commands.deploy - <> subcommand Commands.version + <> subcommand Commands.capture + <> subcommand Commands.changes + <> subcommand Commands.deploy + <> subcommand Commands.list + <> subcommand Commands.version diff --git a/brittany.yaml b/brittany.yaml deleted file mode 100644 index 368522f..0000000 --- a/brittany.yaml +++ /dev/null @@ -1,71 +0,0 @@ ---- -conf_debug: - dconf_roundtrip_exactprint_only: false - dconf_dump_bridoc_simpl_par: false - dconf_dump_ast_unknown: false - dconf_dump_bridoc_simpl_floating: false - dconf_dump_config: false - dconf_dump_bridoc_raw: false - dconf_dump_bridoc_final: false - dconf_dump_bridoc_simpl_alt: false - dconf_dump_bridoc_simpl_indent: false - dconf_dump_annotations: false - dconf_dump_bridoc_simpl_columns: false - dconf_dump_ast_full: false -conf_forward: - options_ghc: - - -XBangPatterns - - -XDataKinds - - -XDeriveAnyClass - - -XDeriveFoldable - - -XDeriveFunctor - - -XDeriveGeneric - - -XDeriveLift - - -XDeriveTraversable - - -XDerivingStrategies - - -XDerivingVia - - -XFlexibleContexts - - -XFlexibleInstances - - -XGADTs - - -XGeneralizedNewtypeDeriving - - -XLambdaCase - - -XMultiParamTypeClasses - - -XNoImplicitPrelude - - -XNoMonomorphismRestriction - - -XOverloadedStrings - - -XRankNTypes - - -XRecordWildCards - - -XScopedTypeVariables - - -XStandaloneDeriving - - -XTypeApplications - - -XTypeFamilies -conf_errorHandling: - econf_ExactPrintFallback: ExactPrintFallbackModeInline - econf_Werror: false - econf_omit_output_valid_check: false - econf_produceOutputOnErrors: false -conf_preprocessor: - ppconf_CPPMode: CPPModeAbort - ppconf_hackAroundIncludes: false -conf_obfuscate: false -conf_roundtrip_exactprint_only: false -conf_version: 1 -conf_layout: - lconfig_reformatModulePreamble: true - lconfig_altChooser: - tag: AltChooserBoundedSearch - contents: 3 - lconfig_allowSingleLineExportList: false - lconfig_importColumn: 60 - lconfig_hangingTypeSignature: false - lconfig_importAsColumn: 50 - lconfig_alignmentLimit: 1 - lconfig_indentListSpecial: true - lconfig_indentAmount: 2 - lconfig_alignmentBreakOnMultiline: true - lconfig_cols: 80 - lconfig_indentPolicy: IndentPolicyLeft - lconfig_indentWhereSpecial: true - lconfig_columnAlignMode: - tag: ColumnAlignModeDisabled - contents: 0.7 diff --git a/doc/stackctl-capture.1.md b/doc/stackctl-capture.1.md deleted file mode 100644 index 8ed33a5..0000000 --- a/doc/stackctl-capture.1.md +++ /dev/null @@ -1,57 +0,0 @@ -% STACKCTL-CAPTURE(1) User Manual -% -% March 2022 - -# NAME - -stackctl capture - Generate stack specifications from deployed stacks - -# SYNOPSIS - -*stackctl capture* \[options] - -# DESCRIPTION - -Fetches the CloudFormation Template and currently supplied Parameters of a -deployed Stack and stores it as a stack specification under the -currently-authorized AWS Account and Region. - -If files already exist at the inferred locations, they will be overwritten. - -# OPTIONS - -**\-n**, **\--account-name** *\*\ - -> Write specs paths to **stacks/{account-id}.NAME/...**. If not given, we will -> use **${AWS_PROFILE:-unknown}**. - -**\-t**, **\--template-path** *\*\ - -> Relative path for template. Default is **${STACK}.yaml**. - -**\-p**, **\--path** *\*\ - -> Relative path for specification. Default is **${STACK}.yaml**. - -**\--no-flip**\ - -> Don't flip JSON templates to Yaml. This option is ignored if the template is -> not JSON. - -**STACK**\ - -> Name of Stack to capture. -> -> Globs are also supported and all matching Stacks will be captured. When there -> are multiple Stacks being captured, the **\--path** and **\--template-path** -> will be ignored and all Stacks will be captured to their inferred paths. - -# ENVIRONMENT - -*AWS_PROFILE*\ - -> If set, will be used when defaulting **-n**. - -# STACKCTL - -Part of the **stackctl(1)** suite diff --git a/doc/stackctl-cat.1.md b/doc/stackctl-cat.1.md deleted file mode 100644 index 189b657..0000000 --- a/doc/stackctl-cat.1.md +++ /dev/null @@ -1,35 +0,0 @@ -% STACKCTL-CAT(1) User Manual -% -% March 2022 - -# NAME - -stackctl cat - Pretty-print stack specifications and templates - -# SYNOPSIS - -*stackctl cat* \[options] - -# DESCRIPTION - -This command locates **stacks/** for the currently-authorized AWS Account and -Region and lists them in a tree-like display along with abbreviated contents. It -then lists any **templates/** files used by those stacks in a similar fashion. - -# OPTIONS - -**\--no-stacks**\ - -> Don't print **stacks/**. - -**\--no-templates**\ - -> Don't print **templates/**. - -**\-b**, **\--brief**\ - -> Don't print file contents, only paths. - -# STACKCTL - -Part of the **stackctl(1)** suite diff --git a/doc/stackctl-changes.1.md b/doc/stackctl-changes.1.md deleted file mode 100644 index bac3d00..0000000 --- a/doc/stackctl-changes.1.md +++ /dev/null @@ -1,52 +0,0 @@ -% STACKCTL-CHANGES(1) User Manual -% -% March 2022 - -# NAME - -stackctl changes - Create and present Change Sets for stack specifications - -# SYNOPSIS - -*stackctl changes* \[options] - -# DESCRIPTION - -For each stack specification in the currently-active AWS Account and Region, -creates a Change Set and prints it. The Change Set is not removed after -successful operation. - -# OPTIONS - -**\-f**, **\--format** *\*\ - -> Output changes in **FORMAT**. See dedicated section. - -**\-p**, **\--parameter** *\*\ - -> Override the given Parameter for this operation. Omitting *VALUE* will result -> in overriding the Parameter as an empty string. May be specified 0 or more -> times. - -**\-t**, **\--tag** *\*\ - -> Override the given Tag for this operation. Omitting *VALUE* will result in -> overriding the Tag as an empty string. May be specified 0 or more times. - -**PATH**\ - -> Write changes to **PATH**, instead of printing them. - -# AVAILABLE FORMATS - -**tty**\ - -> The default. Produces a simplified but colorized (unless redirected) listing. - -**pr**\ - -> Produces markdown suitable to post as a comment to a GitHub Pull Request. - -# STACKCTL - -Part of the **stackctl(1)** suite diff --git a/doc/stackctl-deploy.1.md b/doc/stackctl-deploy.1.md deleted file mode 100644 index 1e69cee..0000000 --- a/doc/stackctl-deploy.1.md +++ /dev/null @@ -1,45 +0,0 @@ -% STACKCTL-DEPLOY(1) User Manual -% -% March 2022 - -# NAME - -stackctl deploy - deploy stack specifications - -# SYNOPSIS - -*stackctl deploy* \[options] - -# DESCRIPTION - -For each stack specification in the currently-active AWS Account and Region, -creates a Change Set and executes it after confirmation. - -# OPTIONS - -**\-p**, **\--parameter** *\*\ - -> Override the given Parameter for this operation. Omitting *VALUE* will result -> in overriding the Parameter as an empty string. May be specified 0 or more -> times. - -**\-t**, **\--tag** *\*\ - -> Override the given Tag for this operation. Omitting *VALUE* will result in -> overriding the Tag as an empty string. May be specified 0 or more times. - -**\--save-change-sets** *\*\ - -> Save generated Change Sets to **PATH/STACK.json** - -**\--no-confirm**\ - -> Don't confirm before deployment. - -**\--clean**\ - -> If successful, remove all Change Sets from the deployed Stack. - -# STACKCTL - -Part of the **stackctl(1)** suite diff --git a/doc/stackctl-version.1.md b/doc/stackctl-version.1.md deleted file mode 100644 index 1fecd87..0000000 --- a/doc/stackctl-version.1.md +++ /dev/null @@ -1,19 +0,0 @@ -% STACKCTL-VERSION(1) User Manual -% -% March 2022 - -# NAME - -stackctl version - Display version information about Stackctl - -# SYNOPSIS - -*stackctl version* \[options] - -# OPTIONS - -None. - -# STACKCTL - -Part of the **stackctl(1)** suite diff --git a/doc/stackctl.1.md b/doc/stackctl.1.md deleted file mode 100644 index fa6ea63..0000000 --- a/doc/stackctl.1.md +++ /dev/null @@ -1,295 +0,0 @@ -% STACKCTL(1) User Manual -% -% January 2023 - -# NAME - -stackctl - manage CloudFormation Stacks through specifications - -# SYNOPSIS - -*stackctl* \[options] \ \ - -# OPTIONS - -**\-d**, **\--directory** *\*\ - -> Where to find specifications. Default is **.**. - -**\--filter** *\*\ - -> Restrict specifications to those whose paths match any of the given -> **PATTERN**s. - -**\--color** *\*\ - -> When to colorize output. **auto** (the default) will colorize output when -> connected to a terminal. - -**\-v**, **\--verbose**\ - -> Log more verbosely - -# COMMANDS - -**cat**\ - -> Pretty-print specifications. - -**capture**\ - -> Generate specifications for already-deployed Stacks. - -**changes**\ - -> Show changes between on-disk specifications and their deployed state. - -**deploy**\ - -> Make deployed state match on-disk specifications. - -**version**\ - -> Print the CLI's version. - -Run **man stackctl \** for more details. - -# Stack Specifications - -A *Stack Specification* is a file format and file-system structure used to fully -describe a deployed (or deployable) CloudFormation Stack. *stackctl* is your way -of creating, displaying, and using such files. - -## Format - -Specification files ("specs") have the following path structure: - -> stacks/*{account-id}*.*{account-name}*/*{region}*/*{stack-name}*.yaml - -Its constituent parts are used as follows: - -*{account-id}*\ - -> The AWS Account Id in which to deploy this Stack. - -*{account-name}*\ - -> A friendly name for this Account. This is never used logically and can be - whatever you find useful for identifying this Account. - -*{region}*\ - -> The AWS Region in which to deploy this Stack - -*{stack-name}*\ - -> The name to use for this Stack. -> -> *{stack/name}*.yaml is also supported so that directories can be used for -> your own organization. Such paths will have directory-separators replaced by -> hyphens when used. - -These files' contents should be: - -``` -Description: - -Template: - -Depends: - - - -Actions: - - on: - run: - : - -Parameters: Object - -Capabilities: - - - -Tags: Object -``` - -And these constituent parts are used as follows: - -*{.Description}*\ - -> Optional. Set the Stack's description. -> -> This value will be inserted as the *Description* key in the template body on -> deployment, which becomes the deployed Stack's description. If the template -> already contains a description, the specification value will be ignored. - -*{.Template}*\ - -> Required. The template to use when deploying this Stack. Must be a relative -> path under `templates/`. - -*{.Depends}*\ - -> Optional. Other Stacks (by name) that should be ordered before this one if -> deployed together. - -*{.Actions}*\ - -> Optional. Actions to run when certain Stack management events occur. - -*{.Actions[].on}*\ - -> The event on which to perform the action: -> -> - **PostDeploy**: run the action after a successful deployment - -*{.Actions[].run}*\ - -> The action 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 - -*{.Parameters}*\ - -> Optional. Parameters to use when deploying the Stack. -> -> The *Parameters* key can be specified in any of 3 forms: -> -> ``` -> # Natural (recommended) -> Parameters: -> Foo: Bar -> Baz: Bat -> -> # CloudFormation -> Parameters: -> - ParameterKey: Foo -> ParameterValue: Bar -> - ParameterKey: Baz -> ParameterValue: Bat -> -> # CloudGenesis -> Parameters: -> - Key: Foo -> Value: Bar -> - Key: Baz -> Value: Bat -> ``` - -*{.Capabilities}*\ - -> Optional. Capabilities to use when deploying the Stack. -> -> Valid *Capabilities* are, -> -> **CAPABILITY_AUTO_EXPAND**,\ -> **CAPABILITY_IAM**, and\ -> **CAPABILITY_NAMED_IAM** - -*{.Tags}*\ - -> Optional. Tags to use when deploying the Stack. -> -> The *Tags* key can be specified in either of 2 forms: -> -> ``` -> # Natural (recommended) -> Tags: -> Foo: Bar -> Baz: Bat -> -> # CloudFormation / CloudGenesis -> Parameters: -> - Key: Foo -> Value: Bar -> - Key: Baz -> Value: Bat -> ``` - -## Example - -The following example shares a single Template between two deployments in two -regions of a single account. - -``` -stacks/ - 111111111111.prod/ - us-east-1/ - my-app.yaml - | Template: web.yaml - | Parameters: - | ... - - us-west-2/ - my-app.yaml - | Template: web.yaml - | Parameters: - | ... - -templates/ - web.yaml - | Parameters: - | ... - | Resources: - | ... -``` - -## Deployment - -Once we have a specification, deployment is *conceptually* simple: - -```sh -aws configure # for {account-id} - -aws --region {region} cloudformation deploy \ - --stack-name {stack-name} \ - --template-file templates/{.Template} \ - --parameter-overrides {.Parameters} \ - --capabilities {.Capabilities} \ - --tags {.Tags} -``` - -In reality, we create changesets, optionally present them for review, execute -them, wait, stream events, and finally clean up. - -See **stackctl-changes(1)** and **stackctl-deploy(1)**. - -# ENVIRONMENT - -*STACKCTL_DIRECTORY*\ - -> Environment-based alternative for *\--directory*. - -*STACKCTL_FILTERS*\ - -> Environment-based alternative for *\--filters*. - -*LOG_\**\ - -> Variables such as *LOG_COLOR* or *LOG_LEVEL* will be respected by the -> underlying logging framework (Blammo). Please see its documentation for -> complete details: -> -> https://github.com/freckle/blammo#configuration - -*AWS_PROFILE*\ - -> If set, will be used as account name in commands that create new -> specifications. - -# AUTHOR - -Freckle Engineering - -# SEE ALSO - -**stackctl-cat(1)**, **stackctl-capture(1)**, **stackctl-changes(1)**, -**stackctl-deploy(1)**, **stackctl-version(1)**. - -# ACKNOWLEDGEMENTS - -The specification format and semantics is a minor extension of that used by the -CloudGenesis project, capturing more of a CloudFormation Stack's deployed state -statically is terraform-inspired, and GitOps as an approach was pioneered for -Kubernetes by Flux CD. 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 new file mode 100644 index 0000000..292304b --- /dev/null +++ b/fourmolu.yaml @@ -0,0 +1,35 @@ +indentation: 2 +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 # 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 + +# fourmolu can't figure this out because of the re-exports we use +fixities: + - "infixl 1 &" + - "infixr 4 .~" + - "infixr 4 ?~" diff --git a/man/custom.css b/man/custom.css new file mode 100644 index 0000000..c12e532 --- /dev/null +++ b/man/custom.css @@ -0,0 +1,13 @@ +.mp h1, +.mp h2, +.mp h3, +.mp h4, +.mp h5, +.mp h6, +.mp code { + color: #A00000; +} + +.mp var { + color: #006000; +} diff --git a/man/index.txt b/man/index.txt new file mode 100644 index 0000000..a697daa --- /dev/null +++ b/man/index.txt @@ -0,0 +1,10 @@ +# 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 +stackctl-deploy(1) stackctl-deploy.1.ronn +stackctl-version(1) stackctl-version.1.ronn + +# external manuals diff --git a/man/stackctl-capture.1.ronn b/man/stackctl-capture.1.ronn new file mode 100644 index 0000000..9239cd8 --- /dev/null +++ b/man/stackctl-capture.1.ronn @@ -0,0 +1,42 @@ +stackctl-capture(1) - Generate stack specifications from deployed stacks +======================================================================== + +## SYNOPSIS + +`stackctl capture` [] + +## DESCRIPTION + +Fetches the CloudFormation Template and currently supplied Parameters of a +deployed Stack and stores it as a stack specification under the +currently-authorized AWS Account and Region. + +If files already exist at the inferred locations, they will be overwritten. + +## OPTIONS + + * `-n`, `--account-name`=: + Write specs paths to `stacks/{account-id}.NAME/...`. If not given, we will + use `${AWS_PROFILE:-unknown}`. + + * `-t`, `--template-path`=: + Relative path for template. Default is `${STACK}.yaml`. + + * `-p`, `--path`=: + Relative path for specification. Default is `${STACK}.yaml`. + + * `--no-flip`: + Don't flip JSON templates to Yaml. This option is ignored if the template is + not JSON. + + * `STACK`: + Name of Stack to capture. + + Globs are also supported and all matching Stacks will be captured. When + there are multiple Stacks being captured, the `--path` and `--template-path` + will be ignored and all Stacks will be captured to their inferred paths. + +## ENVIRONMENT + + * `AWS_PROFILE`: + If set, will be used when defaulting `-n`. diff --git a/man/stackctl-cat.1.ronn b/man/stackctl-cat.1.ronn new file mode 100644 index 0000000..3d0b1bb --- /dev/null +++ b/man/stackctl-cat.1.ronn @@ -0,0 +1,23 @@ +stackctl-cat(1) - pretty-print stack specifications and templates +================================================================= + +## SYNOPSIS + +`stackctl cat` [] + +## DESCRIPTION + +This command locates `stacks/` for the currently-authorized AWS Account and +Region and lists them in a tree-like display along with abbreviated contents. It +then lists any `templates/` files used by those stacks in a similar fashion. + +## OPTIONS + + * `--no-stacks`: + Don't print `stacks/`. + + * `--no-templates`: + Don't print `templates/`. + + * `-b`, `--brief`: + Don't print file contents, only paths. diff --git a/man/stackctl-changes.1.ronn b/man/stackctl-changes.1.ronn new file mode 100644 index 0000000..5f3fa22 --- /dev/null +++ b/man/stackctl-changes.1.ronn @@ -0,0 +1,42 @@ +stackctl-changes(1) - create and present Change Sets for stack specifications +============================================================================= + +## SYNOPSIS + +`stackctl changes` [] + +## DESCRIPTION + +For each stack specification in the currently-active AWS Account and Region, +creates a Change Set and prints it. The Change Set is not removed after +successful operation. + +## OPTIONS + + * `-f`, `--format`=: + Output changes in . See dedicated section. + + * `--no-include-full`: + Don't include full Change Set JSON details. This option only applies to the + format. + + * `-p`, `--parameter`=: + Override the given Parameter for this operation. Omitting will + result in overriding the Parameter as an empty string. May be specified 0 or + more times. + + * `-t`, `--tag`=: + Override the given Tag for this operation. Omitting will result in + overriding the Tag as an empty string. May be specified 0 or more times. + + * `PATH`: + Write changes to , instead of printing them. + +## AVAILABLE FORMATS + + * `tty`: + The default. Produces a simplified but colorized (unless redirected) + listing. + + * `pr`: + Produces markdown suitable to post as a comment to a GitHub Pull Request. diff --git a/man/stackctl-deploy.1.ronn b/man/stackctl-deploy.1.ronn new file mode 100644 index 0000000..d5514cc --- /dev/null +++ b/man/stackctl-deploy.1.ronn @@ -0,0 +1,31 @@ +stackctl-deploy(1) - deploy stack specifications +================================================ + +## SYNOPSIS + +`stackctl deploy` [] + +## DESCRIPTION + +For each stack specification in the currently-active AWS Account and Region, +creates a Change Set and executes it after confirmation. + +## OPTIONS + + * `-p`, `--parameter`=: + Override the given Parameter for this operation. Omitting will + result in overriding the Parameter as an empty string. May be specified 0 or + more times. + + * `-t`, `--tag`=: + Override the given Tag for this operation. Omitting will result in + overriding the Tag as an empty string. May be specified 0 or more times. + + * `--save-change-sets`=: + Save generated Change Sets to `{PATH}/{STACK}.json`. + + * `--no-confirm`: + Don't confirm before deployment. + + * `--clean`: + If successful, remove all Change Sets from the deployed Stack. diff --git a/man/stackctl-ls.1.ronn b/man/stackctl-ls.1.ronn new file mode 100644 index 0000000..2bd1a65 --- /dev/null +++ b/man/stackctl-ls.1.ronn @@ -0,0 +1,20 @@ +stackctl-ls(1) - list stack specifications +========================================== + +## SYNOPSIS + +`stackctl ls` [] + +## DESCRIPTION + +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 the state of the stack in the +first column. + +## OPTIONS + + * `--no-legend`: + Don't print indicators legend at the end. diff --git a/man/stackctl-version.1.ronn b/man/stackctl-version.1.ronn new file mode 100644 index 0000000..1ab44f6 --- /dev/null +++ b/man/stackctl-version.1.ronn @@ -0,0 +1,10 @@ +stackctl-version(1) - display version information about Stackctl +================================================================ + +## SYNOPSIS + +`stackctl version` + +## OPTIONS + +None. diff --git a/man/stackctl.1.ronn b/man/stackctl.1.ronn new file mode 100644 index 0000000..8863fb2 --- /dev/null +++ b/man/stackctl.1.ronn @@ -0,0 +1,276 @@ +stackctl(1) - manage CloudFormation Stacks through specifications +================================================================= + +## SYNOPSIS + +`stackctl` [] + +## OPTIONS + + * `-d`, `--directory`=: + Use the stack collection located at (default: current working + directory). + + * `--filter`=: + Restrict specifications to those whose paths match any given . + + * `--color`=: + When to colorize output. `auto` (the default) will colorize output when + connected to a terminal. + + * `-v`, `--verbose`: + Log more verbosely + + * `--auto-sso`=: + When to automatically run `aws sso login` in response to AWS SSO + authorization errors. `always`, `ask`, or `never`. Default is to `ask`. + +## COMMANDS + + * `cat`: + Pretty-print specifications. + + * `capture`: + Generate specifications for already-deployed Stacks. + + * `changes`: + Show changes between on-disk specifications and their deployed state. + + * `deploy`: + Make deployed state match on-disk specifications. + + * `ls`: + List specifications. + + * `version`: + Print the CLI's version. + +Run `man stackctl ` for more details. + +## STACK SPECIFICATIONS + +A *Stack Specification* is a file format and file-system structure used to fully +describe a deployed (or deployable) CloudFormation Stack. *stackctl* is your way +of creating, displaying, and using such files. + +### FORMAT + +Specification files ("specs") have the following path structure: + + stacks/{account-id}.{account-name}/{region}/{stack-name}.yaml + +Its constituent parts are used as follows: + + * `{account-id}`: + The AWS Account Id in which to deploy this Stack. + + * `{account-name}`: + A friendly name for this Account. This is never used logically and can be + whatever you find useful for identifying this Account. + + * `{region}`: + The AWS Region in which to deploy this Stack. + + * `{stack-name}`: + The name to use for this Stack. + + `{stack/name}`.yaml is also supported, so that directories can be used for + your own organization. Such paths will have directory-separators replaced by + hyphens when used. + +These files' contents should be: + + Description: + + Template: + + Depends: + - + + Actions: + - on: + run: + : + + Parameters: Object + + Capabilities: + - + + Tags: Object + +And these constituent parts are used as follows: + + * `{.Description}`: + Optional. Set the Stack's description. + + This value will be inserted as the *Description* key in the template body on + deployment, which becomes the deployed Stack's description. If the template + already contains a description, the specification value will be ignored. + + * `{.Template}`: + Required. The template to use when deploying this Stack. Must be a relative + path under `templates/`. + + * `{.Depends}`: + Optional. Other Stacks (by name) that should be ordered before this one if + deployed together. + + * `{.Actions}`: + Optional. Actions to run when certain Stack management events occur. + + * `{.Actions[].on}`: + The event on which to perform the action: + + **PostDeploy**: run the action after a successful deployment. + + * `{.Actions[].run}`: + 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. + + The _Parameters_ key can be specified in any of 3 forms: + + # Natural (recommended) + Parameters: + Foo: Bar + Baz: Bat + + # CloudFormation + Parameters: + - ParameterKey: Foo + ParameterValue: Bar + - ParameterKey: Baz + ParameterValue: Bat + + # CloudGenesis + Parameters: + - Key: Foo + Value: Bar + - Key: Baz + Value: Bat + + * `{.Capabilities}`: + Optional. Capabilities to use when deploying the Stack. + + Valid _Capabilities_ are **CAPABILITY_AUTO_EXPAND**, **CAPABILITY_IAM**, and + **CAPABILITY_NAMED_IAM**. + + * `{.Tags}`: + Optional. Tags to use when deploying the Stack. + + The _Tags_ key can be specified in either of 2 forms: + + # Natural (recommended) + Tags: + Foo: Bar + Baz: Bat + + # CloudFormation / CloudGenesis + Parameters: + - Key: Foo + Value: Bar + - Key: Baz + Value: Bat + +## EXAMPLE + +The following example shares a single Template between two deployments in two +regions of a single account. + + stacks/ + 111111111111.prod/ + us-east-1/ + my-app.yaml + | Template: web.yaml + | Parameters: + | ... + + us-west-2/ + my-app.yaml + | Template: web.yaml + | Parameters: + | ... + + templates/ + web.yaml + | Parameters: + | ... + | Resources: + | ... + +## DEPLOYMENT + +Once we have a specification, deployment is _conceptually_ simple: + + aws configure # for {account-id} + + aws --region {region} cloudformation deploy \ + --stack-name {stack-name} \ + --template-file templates/{.Template} \ + --parameter-overrides {.Parameters} \ + --capabilities {.Capabilities} \ + --tags {.Tags} + +In reality, we create changesets, optionally present them for review, execute +them, wait, stream events, and finally clean up. + +See stackctl-changes(1) and stackctl-deploy(1). + +## ENVIRONMENT + +* `STACKCTL_DIRECTORY`: + Environment-based alternative for `--directory`. + +* `STACKCTL_FILTER`: + Environment-based alternative for `--filter`. + +* `STACKCTL_AUTO_SSO`: + Environment-based alternative for `--auto-sso`. + +* `LOG_*`: + Variables such as *LOG_COLOR* or *LOG_LEVEL* will be respected by the + underlying logging framework (Blammo). Please see [its documentation][blammo] + for complete details. + + [blammo]: https://github.com/freckle/blammo#configuration + +* `AWS_PROFILE`: + 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 + +## SEE ALSO + +stackctl-cat(1), stackctl-capture(1), stackctl-changes(1), stackctl-deploy(1), +stackctl-ls(1), stackctl-version(1). + +## ACKNOWLEDGEMENTS + +The specification format and semantics is a minor extension of that used by the +CloudGenesis project, capturing more of a CloudFormation Stack's deployed state +statically is terraform-inspired, and GitOps as an approach was pioneered for +Kubernetes by Flux CD. 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 587cf0d..865ec01 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: stackctl -version: 1.3.0.0 +version: 1.7.2.0 github: freckle/stackctl license: MIT author: Freckle Engineering @@ -16,12 +16,15 @@ dependencies: - base >= 4 && < 5 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 @@ -57,17 +60,20 @@ default-extensions: library: source-dirs: src dependencies: - - Blammo >= 1.1.1.1 # pushLoggerLn, getLoggerShouldColor + - Blammo >= 1.1.2.3 # flushLogger bugfix - Glob + - QuickCheck - aeson - aeson-casing - aeson-pretty - - amazonka - - amazonka-cloudformation - - amazonka-core - - amazonka-ec2 - - amazonka-lambda - - amazonka-sts + - amazonka >= 2.0 + - amazonka-cloudformation >= 2.0 + - amazonka-core >= 2.0 + - amazonka-ec2 >= 2.0 + - amazonka-lambda >= 2.0 + - amazonka-mtl + - amazonka-sso >= 2.0 + - amazonka-sts >= 2.0 - bytestring - cfn-flip >= 0.1.0.3 # bugfix for Condition - conduit @@ -82,13 +88,17 @@ library: - monad-logger - mtl - optparse-applicative + - prettyprinter - resourcet - rio - semigroups - text + - text-metrics - time - - unliftio - - unliftio-core + - transformers + - typed-process + - unix + - unliftio >= 0.2.25.0 # UnliftIO.Exception.Lens - unordered-containers - uuid - yaml @@ -109,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 29dbc5f..bb2e2c7 100644 --- a/src/Stackctl/AWS/CloudFormation.hs +++ b/src/Stackctl/AWS/CloudFormation.hs @@ -1,22 +1,26 @@ +{-# LANGUAGE DuplicateRecordFields #-} + module Stackctl.AWS.CloudFormation - ( Stack(..) + ( Stack (..) , stack_stackName + , stack_stackStatus , stackDescription - , stackIsRollbackComplete - , StackId(..) - , StackName(..) - , StackDescription(..) - , StackEvent(..) - , ResourceStatus(..) + , stackStatusRequiresDeletion + , StackId (..) + , StackName (..) + , StackDescription (..) + , StackStatus (..) + , StackEvent (..) + , ResourceStatus (..) , stackEvent_eventId , stackEvent_logicalResourceId , stackEvent_resourceStatus , stackEvent_resourceStatusReason , stackEvent_timestamp - , StackTemplate(..) - , StackDeployResult(..) + , StackTemplate (..) + , StackDeployResult (..) , prettyStackDeployResult - , StackDeleteResult(..) + , StackDeleteResult (..) , prettyStackDeleteResult , Parameter , parameter_parameterKey @@ -24,7 +28,7 @@ module Stackctl.AWS.CloudFormation , newParameter , makeParameter , readParameter - , Capability(..) + , Capability (..) , Tag , newTag , tag_key @@ -39,23 +43,26 @@ module Stackctl.AWS.CloudFormation , awsCloudFormationGetStackNamesMatching , awsCloudFormationGetMostRecentStackEventId , awsCloudFormationDeleteStack + , awsCloudFormationCancelUpdateStack , awsCloudFormationWait , awsCloudFormationGetTemplate - -- * ChangeSets - , ChangeSet(..) + -- * ChangeSets + , ChangeSet (..) + , changeSetFromResponse , changeSetJSON - , ChangeSetId(..) - , ChangeSetName(..) - , Change(..) - , ResourceChange(..) - , Replacement(..) - , ChangeAction(..) - , ResourceAttribute(..) - , ResourceChangeDetail(..) - , ChangeSource(..) - , ResourceTargetDefinition(..) - , RequiresRecreation(..) + , ChangeSetId (..) + , ChangeSetName (..) + , ChangeSetType (..) + , Change (..) + , ResourceChange (..) + , Replacement (..) + , ChangeAction (..) + , ResourceAttribute (..) + , ResourceChangeDetail (..) + , ChangeSource (..) + , ResourceTargetDefinition (..) + , RequiresRecreation (..) , awsCloudFormationCreateChangeSet , awsCloudFormationExecuteChangeSet , awsCloudFormationDeleteAllChangeSets @@ -63,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 @@ -79,13 +87,12 @@ import Amazonka.CloudFormation.Waiters import Amazonka.Core ( AsError , ServiceError + , hasStatus , _MatchServiceError , _ServiceError - , hasStatus - , serviceCode - , serviceMessage ) -import Amazonka.Waiter (Accept(..)) +import qualified Amazonka.Env as Amazonka +import Amazonka.Waiter (Accept (..), Wait) import Conduit import Control.Lens ((?~)) import Data.Aeson @@ -96,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 @@ -126,14 +133,14 @@ data StackDeployResult | StackCreateFailure Bool | StackUpdateSuccess | StackUpdateFailure Bool - deriving stock Show + deriving stock (Show) prettyStackDeployResult :: StackDeployResult -> Text prettyStackDeployResult = \case StackCreateSuccess -> "Created Stack successfully" - StackCreateFailure{} -> "Failed to create Stack" + StackCreateFailure {} -> "Failed to create Stack" StackUpdateSuccess -> "Updated Stack successfully" - StackUpdateFailure{} -> "Failed to update Stack" + StackUpdateFailure {} -> "Failed to update Stack" stackCreateResult :: Accept -> StackDeployResult stackCreateResult = \case @@ -154,7 +161,7 @@ data StackDeleteResult prettyStackDeleteResult :: StackDeleteResult -> Text prettyStackDeleteResult = \case StackDeleteSuccess -> "Deleted Stack successfully" - StackDeleteFailure{} -> "Failed to delete Stack" + StackDeleteFailure {} -> "Failed to delete Stack" stackDeleteResult :: Accept -> StackDeleteResult stackDeleteResult = \case @@ -171,27 +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 + 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 @@ -199,42 +207,42 @@ 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 + -> Maybe Text + -- ^ Last-seen Id -> m [StackEvent] awsCloudFormationDescribeStackEvents stackName mLastId = do - let - req = - newDescribeStackEvents - & describeStackEvents_stackName - ?~ unStackName stackName + let req = + newDescribeStackEvents + & describeStackEvents_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 @@ -242,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 @@ -250,55 +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 + either stackCreateResult stackUpdateResult + <$> race + (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) @@ -326,6 +345,7 @@ data ChangeSet = ChangeSet { csCreationTime :: UTCTime , csChanges :: Maybe [Change] , csChangeSetName :: ChangeSetName + , csChangeSetType :: ChangeSetType , csExecutionStatus :: ExecutionStatus , csChangeSetId :: ChangeSetId , csParameters :: Maybe [Parameter] @@ -338,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 @@ -346,10 +385,8 @@ changeSetFailed = (== ChangeSetStatus_FAILED) . csStatus awsCloudFormationCreateChangeSet :: ( MonadUnliftIO m - , MonadResource m , MonadLogger m - , MonadReader env m - , HasAwsEnv env + , MonadAWS m ) => StackName -> Maybe StackDescription @@ -358,67 +395,55 @@ awsCloudFormationCreateChangeSet -> [Capability] -> [Tag] -> m (Either Text (Maybe ChangeSet)) -awsCloudFormationCreateChangeSet stackName mStackDescription stackTemplate parameters capabilities tags - = fmap (first formatServiceError) +awsCloudFormationCreateChangeSet stackName mStackDescription stackTemplate parameters capabilities tags = + fmap (first formatServiceError) $ trying (_ServiceError . hasStatus 400) + $ awsSilently $ do - name <- newChangeSetName + name <- newChangeSetName - logDebug $ "Reading Template" :# ["path" .= stackTemplate] - templateBody <- addStackDescription mStackDescription + logDebug $ "Reading Template" :# ["path" .= stackTemplate] + templateBody <- + addStackDescription mStackDescription <$> readFileUtf8 (unStackTemplate stackTemplate) - mStack <- awsCloudFormationDescribeStackMaybe stackName + mStack <- awsCloudFormationDescribeStackMaybe stackName - let - changeSetType = fromMaybe ChangeSetType_CREATE $ do + let changeSetType = fromMaybe ChangeSetType_CREATE $ do stack <- mStack - pure $ if stackIsAbandonedCreate stack - then ChangeSetType_CREATE - else ChangeSetType_UPDATE + pure + $ if stackIsAbandonedCreate stack + then ChangeSetType_CREATE + else ChangeSetType_UPDATE - let - req = + 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..." + logInfo + $ "Creating changeset..." :# ["name" .= name, "type" .= changeSetType] - csId <- awsSimple "CreateChangeSet" req (^. createChangeSetResponse_id) + csId <- AWS.simple req (^. createChangeSetResponse_id) - logDebug "Awaiting CREATE_COMPLETE" - void $ awsAwait newChangeSetCreateComplete $ newDescribeChangeSet csId + logDebug "Awaiting CREATE_COMPLETE" + void $ AWS.await newChangeSetCreateComplete $ newDescribeChangeSet csId - logInfo "Retrieving changeset..." - cs <- awsCloudFormationDescribeChangeSet $ ChangeSetId csId - pure $ cs <$ guard (not $ changeSetFailed cs) + logInfo "Retrieving changeset..." + 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 @@ -438,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? -- @@ -473,15 +496,24 @@ awsCloudFormationDeleteAllChangeSets stackName = do -- Our hueristic for finding these is under review but with no previous -- updates (no lastUpdatedTime), presumably meaning it's still in its /first/ -- review. --- stackIsAbandonedCreate :: Stack -> Bool stackIsAbandonedCreate stack = - stack ^. stack_stackStatus == StackStatus_REVIEW_IN_PROGRESS && isNothing - (stack ^. stack_lastUpdatedTime) + stack + ^. stack_stackStatus + == StackStatus_REVIEW_IN_PROGRESS + && isNothing + (stack ^. stack_lastUpdatedTime) + +stackStatusRequiresDeletion :: Stack -> Maybe StackStatus +stackStatusRequiresDeletion stack = + status + <$ guard (status `elem` requiresDeletionStatuses) + where + status = stack ^. stack_stackStatus -stackIsRollbackComplete :: Stack -> Bool -stackIsRollbackComplete stack = - stack ^. stack_stackStatus == StackStatus_ROLLBACK_COMPLETE +requiresDeletionStatuses :: [StackStatus] +requiresDeletionStatuses = + [StackStatus_ROLLBACK_COMPLETE, StackStatus_ROLLBACK_FAILED] runningStatuses :: [StackStatus] runningStatuses = @@ -494,8 +526,7 @@ _ValidationError :: AsError a => Getting (First ServiceError) a ServiceError _ValidationError = _MatchServiceError defaultService "ValidationError" . hasStatus 400 -formatServiceError :: ServiceError -> Text -formatServiceError e = mconcat - [ toText $ e ^. serviceCode - , maybe "" ((": " <>) . toText) $ e ^. serviceMessage - ] +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 e80282c..1cd8657 100644 --- a/src/Stackctl/AWS/Core.hs +++ b/src/Stackctl/AWS/Core.hs @@ -1,112 +1,192 @@ module Stackctl.AWS.Core - ( AwsEnv - , HasAwsEnv(..) - , awsEnvDiscover - , awsSimple - , awsSend - , awsPaginate - , awsAwait - - -- * Modifiers on 'AwsEnv' - , awsWithin - - -- * 'Amazonka' extensions - , AccountId(..) - - -- * 'Amazonka'/'ResourceT' re-exports - , Region(..) - , FromText(..) - , ToText(..) - , MonadResource + ( MonadAWS + , send + , paginate + , await + , withAuth + , localEnv + + -- * "Control.Monad.AWS" extensions + , simple + , discover + , withAssumedRole + + -- * Error-handling + , handlingAuthError + , handlingServiceError + , formatServiceError + + -- * "Amazonka" extensions + , AccountId (..) + + -- * "Amazonka" re-exports + , Region (..) + , FromText (..) + , ToText (..) + + -- * Deprecated + , assumeRole ) where import Stackctl.Prelude -import Amazonka hiding (LogLevel(..)) -import qualified Amazonka as AWS -import Conduit (ConduitM) +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 qualified Amazonka.Env as Amazonka +import Amazonka.STS.AssumeRole +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 - pure $ env - { AWS.envLogger = \level msg -> do - loggerIO - defaultLoc -- TODO: there may be a way to get a CallStack/Loc - "Amazonka" - (case level of - AWS.Info -> LevelInfo - AWS.Error -> LevelError - AWS.Debug -> LevelDebug - AWS.Trace -> LevelOther "trace" - ) - (toLogStr msg) - } - -class HasAwsEnv env where - awsEnvL :: Lens' env AwsEnv - -instance HasAwsEnv AwsEnv where - awsEnvL = id - -awsSimple - :: (MonadResource m, MonadReader env m, HasAwsEnv env, AWSRequest a) - => Text - -> a + + let logger level = do + loggerIO + defaultLoc + "Amazonka" + ( case level of + Amazonka.Info -> LevelInfo + Amazonka.Error -> LevelError + Amazonka.Debug -> LevelDebug + Amazonka.Trace -> LevelOther "trace" + ) + . toLogStr + pure $ env & Amazonka.env_logger .~ logger + +simple + :: forall a m b + . ( HasCallStack + , MonadIO m + , MonadAWS m + , AWSRequest a + ) + => a -> (AWSResponse a -> Maybe b) -> m b -awsSimple name req post = do - resp <- awsSend req - maybe (throwString err) pure $ post resp - where err = unpack name <> " successful, but processing the response failed" +simple req post = do + resp <- send req -awsSend - :: (MonadResource m, MonadReader env m, HasAwsEnv env, AWSRequest a) - => a - -> m (AWSResponse a) -awsSend req = do - AwsEnv env <- view awsEnvL - send env req + let + name = show $ typeRep $ Proxy @a + err = name <> " successful, but processing the response failed" -awsPaginate - :: (MonadResource m, MonadReader env m, HasAwsEnv env, AWSPager 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) - => Wait a - -> a - -> m Accept -awsAwait w req = do - AwsEnv env <- view awsEnvL - await env w req - -awsWithin :: (MonadReader env m, HasAwsEnv env) => Region -> m a -> m a -awsWithin r = local $ over (awsEnvL . unL) (within r) + maybe (throwString err) pure $ post resp + +-- | 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 + -- ^ Session name + -> m a + -- ^ Action to run as the assumed role + -> m a +assumeRole role sessionName f = do + let req = newAssumeRole role sessionName + + assumeEnv <- simple req $ \resp -> do + let creds = resp ^. assumeRoleResponse_credentials + token <- creds ^. Amazonka.authEnv_sessionToken + + let + accessKeyId = creds ^. Amazonka.authEnv_accessKeyId + secretAccessKey = creds ^. Amazonka.authEnv_secretAccessKey . _Sensitive + sessionToken = token ^. _Sensitive + + pure $ fromSession accessKeyId secretAccessKey sessionToken + + 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 + + fetchAuthInBackground getCredentials + + 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 = + 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) + ] + exitFailure + +formatServiceError :: ServiceError -> Text +formatServiceError e = + mconcat + [ toText $ e ^. serviceError_code + , maybe "" ((": " <>) . toText) $ e ^. serviceError_message + , maybe "" (("\nRequest Id: " <>) . toText) $ e ^. serviceError_requestId + ] diff --git a/src/Stackctl/AWS/EC2.hs b/src/Stackctl/AWS/EC2.hs index b89d8ba..a41402b 100644 --- a/src/Stackctl/AWS/EC2.hs +++ b/src/Stackctl/AWS/EC2.hs @@ -5,14 +5,14 @@ module Stackctl.AWS.EC2 import Stackctl.Prelude import Amazonka.EC2.DescribeAvailabilityZones -import Amazonka.EC2.Types (AvailabilityZone(..)) -import Stackctl.AWS.Core +import Amazonka.EC2.Types (AvailabilityZone (..)) +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 7747db0..c0c9fba 100644 --- a/src/Stackctl/AWS/Lambda.hs +++ b/src/Stackctl/AWS/Lambda.hs @@ -1,8 +1,8 @@ {-# LANGUAGE MultiWayIf #-} module Stackctl.AWS.Lambda - ( LambdaInvokeResult(..) - , LambdaError(..) + ( LambdaInvokeResult (..) + , LambdaError (..) , logLambdaInvocationResult , isLambdaInvocationSuccess , awsLambdaInvoke @@ -10,68 +10,80 @@ 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 | LambdaInvokeError LambdaError (Maybe Text) | LambdaInvokeFailure Int (Maybe Text) - deriving stock Show + deriving stock (Show) logLambdaInvocationResult :: MonadLogger m => LambdaInvokeResult -> m () logLambdaInvocationResult = \case LambdaInvokeSuccess bs -> do - let - meta = case decode @Value $ BSL.fromStrict bs of - Nothing -> ["response" .= decodeUtf8 bs] - Just response -> ["response" .= response] + let meta = case decode @Value $ BSL.fromStrict bs of + Nothing -> ["response" .= decodeUtf8 bs] + Just response -> ["response" .= response] logInfo $ "LambdaInvokeSuccess" :# meta LambdaInvokeError LambdaError {..} mFunctionError -> - logError $ (:# []) $ mconcat - [ "LambdaInvokeError" - , "\n errorType: " <> errorType - , "\n errorMessage: " <> errorMessage - , "\n trace: " - , mconcat $ map ("\n " <>) trace - , "\n FunctionError: " <> fromMaybe "none" mFunctionError - ] - LambdaInvokeFailure status mFunctionError -> logError $ (:# []) $ mconcat - [ "LambdaInvokeFailure" - , "\n StatusCode: " <> pack (show status) - , "\n FunctionError: " <> fromMaybe "none" mFunctionError - ] + logError + $ (:# []) + $ mconcat + [ "LambdaInvokeError" + , "\n errorType: " <> errorType + , "\n errorMessage: " <> errorMessage + , "\n trace: " + , mconcat $ map ("\n " <>) trace + , "\n FunctionError: " <> fromMaybe "none" mFunctionError + ] + LambdaInvokeFailure status mFunctionError -> + logError + $ (:# []) + $ mconcat + [ "LambdaInvokeFailure" + , "\n StatusCode: " <> pack (show status) + , "\n FunctionError: " <> fromMaybe "none" mFunctionError + ] isLambdaInvocationSuccess :: LambdaInvokeResult -> Bool isLambdaInvocationSuccess = \case - LambdaInvokeSuccess{} -> True - LambdaInvokeError{} -> False - LambdaInvokeFailure{} -> False + LambdaInvokeSuccess {} -> True + LambdaInvokeError {} -> False + LambdaInvokeFailure {} -> False data LambdaError = LambdaError { errorType :: Text , 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 - -> a -- ^ Payload + -> a + -- ^ Payload -> m LambdaInvokeResult awsLambdaInvoke name payload = do logDebug $ "Invoking function" :# ["name" .= name] - resp <- awsSend $ newInvoke name $ BSL.toStrict $ encode payload + -- Match Lambda's own limit (15 minutes) and add some buffer + resp <- + AWS.localEnv (globalTimeout 905) + $ AWS.send + $ newInvoke name + $ BSL.toStrict + $ encode + payload let status = resp ^. invokeResponse_statusCode @@ -81,16 +93,17 @@ 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 - | statusIsUnsuccessful status -> LambdaInvokeFailure status mFunctionError - | Just e <- mError -> LambdaInvokeError e mFunctionError - | otherwise -> LambdaInvokeSuccess response + pure + $ if + | statusIsUnsuccessful status -> LambdaInvokeFailure status mFunctionError + | Just e <- mError -> LambdaInvokeError e mFunctionError + | otherwise -> LambdaInvokeSuccess response statusIsUnsuccessful :: Int -> Bool statusIsUnsuccessful s = s < 200 || s >= 300 diff --git a/src/Stackctl/AWS/Orphans.hs b/src/Stackctl/AWS/Orphans.hs index e67ec8a..615cbfe 100644 --- a/src/Stackctl/AWS/Orphans.hs +++ b/src/Stackctl/AWS/Orphans.hs @@ -5,9 +5,7 @@ -- -- Orphans so we can get @'ToJSON' 'ChangeSet'@ without hand-writing a massive, -- incomplete, and doomed-to-drift instance ourselves. --- -module Stackctl.AWS.Orphans - () where +module Stackctl.AWS.Orphans () where import Stackctl.Prelude @@ -16,33 +14,49 @@ 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 } +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) , GToJSON' Encoding Zero (Rep a) - ) => ToJSON (Generically a) where + ) + => ToJSON (Generically a) + where 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 49ae049..0b56e99 100644 --- a/src/Stackctl/AWS/Scope.hs +++ b/src/Stackctl/AWS/Scope.hs @@ -1,13 +1,18 @@ module Stackctl.AWS.Scope - ( AwsScope(..) - , HasAwsScope(..) + ( AwsScope (..) + , awsScopeSpecPatterns + , awsScopeSpecStackName + , HasAwsScope (..) , fetchAwsScope ) where import Stackctl.Prelude +import qualified Data.Text as T import Stackctl.AWS import System.Environment (lookupEnv) +import System.FilePath (joinPath, splitPath) +import System.FilePath.Glob (Pattern, compile, match) data AwsScope = AwsScope { awsAccountId :: AccountId @@ -15,7 +20,39 @@ data AwsScope = AwsScope , awsRegion :: Region } deriving stock (Eq, Show, Generic) - deriving anyclass ToJSON + deriving anyclass (ToJSON) + +awsScopeSpecPatterns :: AwsScope -> [Pattern] +awsScopeSpecPatterns AwsScope {..} = + [ compile + $ "stacks" + unpack (unAccountId awsAccountId) <> ".*" + unpack (fromRegion awsRegion) + "**" + "*" <.> "yaml" + , compile + $ "stacks" + "*." <> unpack (unAccountId awsAccountId) + unpack (fromRegion awsRegion) + "**" + "*" <.> "yaml" + ] + +awsScopeSpecStackName :: AwsScope -> FilePath -> Maybe StackName +awsScopeSpecStackName scope path = do + guard $ any (`match` path) $ awsScopeSpecPatterns scope + + -- once we've guarded that the path matches our scope patterns, we can play it + -- 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 class HasAwsScope env where awsScopeL :: Lens' env AwsScope @@ -23,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 9d94494..ed05c0a 100644 --- a/src/Stackctl/Action.hs +++ b/src/Stackctl/Action.hs @@ -11,31 +11,35 @@ -- run: -- InvokeLambdaByStackOutput: OnDeployFunction -- @ --- module Stackctl.Action ( Action , newAction - , ActionOn(..) - , ActionRun(..) + , ActionOn (..) + , ActionRun (..) , runActions ) where 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) @@ -55,29 +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] - toEncoding = pairs . \case - InvokeLambdaByStackOutput name -> "InvokeLambdaByStackOutput" .= name - InvokeLambdaByName name -> "InvokeLambdaByName" .= name + 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 - deriving stock Show - deriving anyclass Exception + | 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] @@ -86,30 +106,37 @@ runActions stackName on = traverse_ (runAction stackName) . filter (`shouldRunOn` on) shouldRunOn :: Action -> ActionOn -> Bool -shouldRunOn Action { on } on' = on == on' +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 +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 @@ -121,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 new file mode 100644 index 0000000..6d03a26 --- /dev/null +++ b/src/Stackctl/AutoSSO.hs @@ -0,0 +1,84 @@ +module Stackctl.AutoSSO + ( AutoSSOOption + , defaultAutoSSOOption + , HasAutoSSOOption (..) + , autoSSOOption + , envAutoSSOOption + , handleAutoSSO + ) where + +import Stackctl.Prelude + +import Amazonka.SSO (_UnauthorizedException) +import Data.Semigroup (Last (..)) +import qualified Env +import Options.Applicative +import Stackctl.AWS.Core as AWS (formatServiceError) +import Stackctl.Prompt +import System.Process.Typed +import UnliftIO.Exception.Lens (catching) + +data AutoSSOOption + = AutoSSOAlways + | AutoSSOAsk + | AutoSSONever + deriving (Semigroup) via Last AutoSSOOption + +defaultAutoSSOOption :: AutoSSOOption +defaultAutoSSOOption = AutoSSOAsk + +readAutoSSO :: String -> Either String AutoSSOOption +readAutoSSO = \case + "always" -> Right AutoSSOAlways + "ask" -> Right AutoSSOAsk + "never" -> Right AutoSSONever + x -> + Left $ "Invalid choice for auto-sso: " <> x <> ", must be always|ask|never" + +class HasAutoSSOOption env where + autoSSOOptionL :: Lens' env AutoSSOOption + +autoSSOOption :: Parser AutoSSOOption +autoSSOOption = + option (eitherReader readAutoSSO) + $ mconcat [long "auto-sso", help autoSSOHelp, metavar "WHEN"] + +envAutoSSOOption :: Env.Parser Env.Error AutoSSOOption +envAutoSSOOption = + Env.var (first Env.UnreadError . readAutoSSO) "AUTO_SSO" + $ Env.help autoSSOHelp + +autoSSOHelp :: IsString a => a +autoSSOHelp = "Automatically run aws-sso-login if necessary?" + +handleAutoSSO + :: ( MonadUnliftIO m + , MonadReader env m + , MonadLogger m + , HasLogger env + , HasAutoSSOOption options + ) + => options + -> m a + -> m a +handleAutoSSO options f = do + catching _UnauthorizedException f $ \ex -> do + case options ^. autoSSOOptionL of + AutoSSOAlways -> do + logWarn $ ssoErrorMessage ex + logInfo "Running `aws sso login' automatically" + AutoSSOAsk -> do + logWarn $ ssoErrorMessage ex + promptOrExit "Run `aws sso login'" + AutoSSONever -> do + logError $ ssoErrorMessage ex + exitFailure + + runProcess_ $ proc "aws" ["sso", "login"] + f + where + ssoErrorMessage ex = + "AWS SSO authorization error" + :# [ "message" .= formatServiceError ex + , "hint" .= ("Run `aws sso login' and try again" :: Text) + ] diff --git a/src/Stackctl/CLI.hs b/src/Stackctl/CLI.hs index e7dcb54..e12671e 100644 --- a/src/Stackctl/CLI.hs +++ b/src/Stackctl/CLI.hs @@ -7,11 +7,16 @@ 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 import Stackctl.Config import Stackctl.DirectoryOption @@ -23,23 +28,23 @@ data App options = App , appConfig :: Config , appOptions :: options , appAwsScope :: AwsScope - , appAwsEnv :: AwsEnv + , appAwsEnv :: AWS.Env } optionsL :: Lens' (App options) options -optionsL = lens appOptions $ \x y -> x { appOptions = y } +optionsL = lens appOptions $ \x y -> x {appOptions = y} instance HasLogger (App options) where - loggerL = lens appLogger $ \x y -> x { appLogger = y } + loggerL = lens appLogger $ \x y -> x {appLogger = y} instance HasConfig (App options) where - configL = lens appConfig $ \x y -> x { appConfig = y } + configL = lens appConfig $ \x y -> x {appConfig = y} instance HasAwsScope (App options) where - awsScopeL = lens appAwsScope $ \x y -> x { appAwsScope = y } + 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 @@ -53,8 +58,11 @@ instance HasColorOption options => HasColorOption (App options) where instance HasVerboseOption options => HasVerboseOption (App options) where verboseOptionL = optionsL . verboseOptionL +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 @@ -64,17 +72,20 @@ 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 , MonadUnliftIO m , HasColorOption options , HasVerboseOption options + , HasAutoSSOOption options ) => options -> AppT (App options) m a @@ -82,38 +93,44 @@ runAppT runAppT options f = do envLogSettings <- liftIO - . LoggingEnv.parseWith - . setLogSettingsConcurrency (Just 1) - $ defaultLogSettings - - logger <- newLogger $ adjustLogSettings - (options ^. colorOptionL . to unColorOption) - (options ^. verboseOptionL) - envLogSettings - - app <- runResourceT $ runLoggerLoggingT logger $ do - aws <- awsEnvDiscover - - 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 - -adjustLogSettings :: LogColor -> Verbosity -> LogSettings -> LogSettings -adjustLogSettings lc v = setLogSettingsColor lc . verbositySetLogLevels v + . LoggingEnv.parseWith + . setLogSettingsConcurrency (Just 1) + $ defaultLogSettings + + 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 +adjustLogSettings mco v = + maybe id (setLogSettingsColor . unColorOption) mco . verbositySetLogLevels v 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/ColorOption.hs b/src/Stackctl/ColorOption.hs index dd154e0..8ac98af 100644 --- a/src/Stackctl/ColorOption.hs +++ b/src/Stackctl/ColorOption.hs @@ -1,46 +1,25 @@ module Stackctl.ColorOption - ( ColorOption(..) - , defaultColorOption - , HasColorOption(..) + ( ColorOption (..) + , HasColorOption (..) , colorOption - , colorHandle ) where import Stackctl.Prelude import Blammo.Logging.LogSettings -import Data.Semigroup (Last(..)) +import Data.Semigroup (Last (..)) import Options.Applicative newtype ColorOption = ColorOption { unColorOption :: LogColor } - deriving Semigroup via Last ColorOption - -defaultColorOption :: ColorOption -defaultColorOption = ColorOption LogColorAuto + deriving (Semigroup) via Last ColorOption class HasColorOption env where - colorOptionL :: Lens' env ColorOption - -instance HasColorOption ColorOption where - colorOptionL = id + colorOptionL :: Lens' env (Maybe ColorOption) colorOption :: Parser ColorOption -colorOption = option (eitherReader $ fmap ColorOption . readLogColor) $ mconcat - [ long "color" - , help "When to colorize output" - , metavar "auto|always|never" - , value defaultColorOption - , showDefaultWith showColorOption - ] - -showColorOption :: ColorOption -> String -showColorOption co = case unColorOption co of - LogColorAuto -> "auto" - LogColorAlways -> "always" - LogColorNever -> "never" - -colorHandle :: MonadIO m => Handle -> ColorOption -> m Bool -colorHandle h co = shouldColorHandle settings h - where settings = setLogSettingsColor (unColorOption co) defaultLogSettings +colorOption = + option (eitherReader $ fmap ColorOption . readLogColor) + $ mconcat + [long "color", help "When to colorize output", metavar "auto|always|never"] diff --git a/src/Stackctl/Colors.hs b/src/Stackctl/Colors.hs index 3123e44..d51d781 100644 --- a/src/Stackctl/Colors.hs +++ b/src/Stackctl/Colors.hs @@ -1,34 +1,5 @@ --- | Facilities for colorizing output module Stackctl.Colors - ( Colors(..) - , HasColorOption - , getColorsStdout - , getColorsLogger - , noColors + ( module Blammo.Logging.Colors ) where -import Stackctl.Prelude - import Blammo.Logging.Colors -import Blammo.Logging.Logger -import Stackctl.ColorOption (HasColorOption(..), colorHandle) - --- | Return 'Colors' based on options and 'stdout' -getColorsStdout - :: (MonadIO m, MonadReader env m, HasColorOption env) => m Colors -getColorsStdout = getColorsHandle stdout - --- | Return 'Colors' based on options given 'Handle' -getColorsHandle - :: (MonadIO m, MonadReader env m, HasColorOption env) => Handle -> m Colors -getColorsHandle h = do - colorOption <- view colorOptionL - c <- colorHandle h colorOption - pure $ getColors c - --- | Return 'Colors' consistent with the ambient 'Logger' -getColorsLogger :: (MonadReader env m, HasLogger env) => m Colors -getColorsLogger = view $ loggerL . to (getColors . getLoggerShouldColor) - -noColors :: Colors -noColors = getColors False diff --git a/src/Stackctl/Commands.hs b/src/Stackctl/Commands.hs index ed3afd1..bad0cb3 100644 --- a/src/Stackctl/Commands.hs +++ b/src/Stackctl/Commands.hs @@ -1,20 +1,18 @@ module Stackctl.Commands - ( cat - , capture - , changes - , deploy - , version + ( module Stackctl.Commands ) where import Stackctl.Prelude -import Stackctl.Colors +import Stackctl.AutoSSO +import Stackctl.ColorOption import Stackctl.DirectoryOption import Stackctl.FilterOption import Stackctl.Spec.Capture import Stackctl.Spec.Cat import Stackctl.Spec.Changes import Stackctl.Spec.Deploy +import Stackctl.Spec.List import Stackctl.Subcommand import Stackctl.VerboseOption import Stackctl.Version @@ -24,60 +22,85 @@ cat , HasVerboseOption options , HasDirectoryOption options , HasFilterOption options + , HasAutoSSOOption options ) => Subcommand options CatOptions -cat = Subcommand - { name = "cat" - , description = "Pretty-print specifications" - , parse = parseCatOptions - , run = runAppSubcommand runCat - } +cat = + Subcommand + { name = "cat" + , description = "Pretty-print specifications" + , parse = parseCatOptions + , run = runAppSubcommand runCat + } capture :: ( HasColorOption options , HasVerboseOption options , HasDirectoryOption options + , HasAutoSSOOption options ) => Subcommand options CaptureOptions -capture = Subcommand - { name = "capture" - , description = "Capture deployed Stacks as specifications" - , parse = parseCaptureOptions - , run = runAppSubcommand runCapture - } +capture = + Subcommand + { name = "capture" + , description = "Capture deployed Stacks as specifications" + , parse = parseCaptureOptions + , run = runAppSubcommand runCapture + } changes :: ( HasColorOption options , HasVerboseOption options , HasDirectoryOption options , HasFilterOption options + , HasAutoSSOOption options ) => Subcommand options ChangesOptions -changes = Subcommand - { name = "changes" - , description = "Review changes between specification and deployed state" - , parse = parseChangesOptions - , run = runAppSubcommand runChanges - } +changes = + Subcommand + { name = "changes" + , description = "Review changes between specification and deployed state" + , parse = parseChangesOptions + , run = runAppSubcommand runChanges + } deploy :: ( HasColorOption options , HasVerboseOption options , HasDirectoryOption options , HasFilterOption options + , HasAutoSSOOption options ) => Subcommand options DeployOptions -deploy = Subcommand - { name = "deploy" - , description = "Deploy specifications" - , parse = parseDeployOptions - , run = runAppSubcommand runDeploy - } +deploy = + Subcommand + { name = "deploy" + , description = "Deploy specifications" + , parse = parseDeployOptions + , run = runAppSubcommand runDeploy + } + +list + :: ( HasColorOption options + , HasVerboseOption options + , HasDirectoryOption options + , HasFilterOption options + , HasAutoSSOOption options + ) + => Subcommand options ListOptions +list = + Subcommand + { name = "ls" + , description = "List specifications" + , parse = parseListOptions + , run = runAppSubcommand runList + } version :: Subcommand options () -version = Subcommand - { name = "version" - , description = "Output the version" - , parse = pure () - , run = \() _ -> logVersion - } +version = + Subcommand + { name = "version" + , description = "Output the version" + , parse = pure () + , run = \() _ -> logVersion + } diff --git a/src/Stackctl/Config.hs b/src/Stackctl/Config.hs index aedac47..2092179 100644 --- a/src/Stackctl/Config.hs +++ b/src/Stackctl/Config.hs @@ -1,10 +1,10 @@ module Stackctl.Config - ( Config(..) + ( Config (..) , configParameters , configTags , emptyConfig - , HasConfig(..) - , ConfigError(..) + , HasConfig (..) + , ConfigError (..) , loadConfigOrExit , loadConfigFromBytes , applyConfig @@ -25,8 +25,8 @@ data Config = Config { required_version :: Maybe RequiredVersion , defaults :: Maybe Defaults } - deriving stock Generic - deriving anyclass FromJSON + deriving stock (Generic) + deriving anyclass (FromJSON) configParameters :: Config -> Maybe ParametersYaml configParameters = parameters <=< defaults @@ -41,8 +41,8 @@ data Defaults = Defaults { parameters :: Maybe ParametersYaml , tags :: Maybe TagsYaml } - deriving stock Generic - deriving anyclass FromJSON + deriving stock (Generic) + deriving anyclass (FromJSON) class HasConfig env where configL :: Lens' env Config @@ -54,7 +54,7 @@ data ConfigError = ConfigInvalidYaml Yaml.ParseException | ConfigInvalid (NonEmpty Text) | ConfigVersionNotSatisfied RequiredVersion Version - deriving stock Show + deriving stock (Show) configErrorMessage :: ConfigError -> Message configErrorMessage = \case @@ -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 @@ -73,9 +74,11 @@ loadConfigOrExit = either die pure =<< loadConfig exitFailure loadConfig :: MonadIO m => m (Either ConfigError Config) -loadConfig = runExceptT $ getConfigFile >>= \case - Nothing -> pure emptyConfig - Just cf -> loadConfigFrom cf +loadConfig = + 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) @@ -91,16 +94,19 @@ loadConfigFromBytes bs = do $ ConfigVersionNotSatisfied rv Paths.version applyConfig :: Config -> StackSpecYaml -> StackSpecYaml -applyConfig config ss@StackSpecYaml {..} = ss - { ssyParameters = configParameters config <> ssyParameters - , ssyTags = configTags config <> ssyTags - } +applyConfig config ss@StackSpecYaml {..} = + ss + { ssyParameters = configParameters config <> ssyParameters + , ssyTags = configTags config <> ssyTags + } getConfigFile :: MonadIO m => m (Maybe FilePath) -getConfigFile = listToMaybe <$> filterM - doesFileExist - [ ".stackctl" "config" <.> "yaml" - , ".stackctl" "config" <.> "yml" - , ".stackctl" <.> "yaml" - , ".stackctl" <.> "yml" - ] +getConfigFile = + listToMaybe + <$> filterM + doesFileExist + [ ".stackctl" "config" <.> "yaml" + , ".stackctl" "config" <.> "yml" + , ".stackctl" <.> "yaml" + , ".stackctl" <.> "yml" + ] diff --git a/src/Stackctl/Config/RequiredVersion.hs b/src/Stackctl/Config/RequiredVersion.hs index 8aae43e..842f2f3 100644 --- a/src/Stackctl/Config/RequiredVersion.hs +++ b/src/Stackctl/Config/RequiredVersion.hs @@ -1,9 +1,11 @@ module Stackctl.Config.RequiredVersion - ( RequiredVersion(..) + ( RequiredVersion (..) + , RequiredVersionOp (..) + , requiredVersionToText , requiredVersionFromText , isRequiredVersionSatisfied - -- * Exported for testing + -- * Exported for testing , (=~) ) where @@ -15,22 +17,33 @@ import qualified Data.List.NonEmpty as NE import qualified Data.Text as T import Data.Version hiding (parseVersion) import qualified Data.Version as Version +import Test.QuickCheck import Text.ParserCombinators.ReadP (readP_to_S) data RequiredVersion = RequiredVersion - { requiredVersionOp :: Text - , requiredVersionCompare :: Version -> Version -> Bool + { requiredVersionOp :: RequiredVersionOp , requiredVersionCompareWith :: Version } + deriving stock (Eq, Ord, Show) -instance Show RequiredVersion where - show RequiredVersion {..} = - unpack requiredVersionOp <> " " <> showVersion requiredVersionCompareWith +instance Arbitrary RequiredVersion where + arbitrary = RequiredVersion <$> arbitrary <*> arbitrary instance FromJSON RequiredVersion where parseJSON = withText "RequiredVersion" $ either fail pure . requiredVersionFromText +instance ToJSON RequiredVersion where + toJSON = toJSON . requiredVersionToText + toEncoding = toEncoding . requiredVersionToText + +requiredVersionToText :: RequiredVersion -> Text +requiredVersionToText RequiredVersion {..} = + requiredVersionOpToText requiredVersionOp + <> " " + <> pack + (showVersion requiredVersionCompareWith) + requiredVersionFromText :: Text -> Either String RequiredVersion requiredVersionFromText = fromWords . T.words where @@ -41,22 +54,23 @@ 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 = do - v <- parseVersion w - - case op of - "=" -> Right $ RequiredVersion op (==) v - "<" -> Right $ RequiredVersion op (<) v - "<=" -> Right $ RequiredVersion op (<=) v - ">" -> Right $ RequiredVersion op (>) v - ">=" -> Right $ RequiredVersion op (>=) v - "=~" -> Right $ RequiredVersion op (=~) v - _ -> - Left - $ "Invalid comparison operator (" + parseRequiredVersion op w = RequiredVersion <$> parseOp op <*> parseVersion w + + parseOp :: Text -> Either String RequiredVersionOp + parseOp = \case + "=" -> Right RequiredVersionEQ + "==" -> Right RequiredVersionEQ + "<" -> Right RequiredVersionLT + "<=" -> Right RequiredVersionLTE + ">" -> Right RequiredVersionGT + ">=" -> Right RequiredVersionGTE + "=~" -> Right RequiredVersionIsh + op -> + Left + $ "Invalid comparison operator (" <> unpack op <> "), may only be =, <, <=, >, >=, or =~" @@ -66,7 +80,44 @@ requiredVersionFromText = fromWords . T.words $ note ("Failed to parse as a version " <> s) $ NE.nonEmpty $ readP_to_S Version.parseVersion s - where s = unpack t + where + s = unpack t + +isRequiredVersionSatisfied :: RequiredVersion -> Version -> Bool +isRequiredVersionSatisfied RequiredVersion {..} = + (`requiredVersionCompare` requiredVersionCompareWith) + where + requiredVersionCompare = requiredVersionOpCompare requiredVersionOp + +data RequiredVersionOp + = RequiredVersionEQ + | RequiredVersionLT + | RequiredVersionLTE + | RequiredVersionGT + | RequiredVersionGTE + | RequiredVersionIsh + deriving stock (Eq, Ord, Show, Bounded, Enum) + +instance Arbitrary RequiredVersionOp where + arbitrary = arbitraryBoundedEnum + +requiredVersionOpToText :: RequiredVersionOp -> Text +requiredVersionOpToText = \case + RequiredVersionEQ -> "==" + RequiredVersionLT -> "<" + RequiredVersionLTE -> "<=" + RequiredVersionGT -> ">" + RequiredVersionGTE -> ">=" + RequiredVersionIsh -> "=~" + +requiredVersionOpCompare :: RequiredVersionOp -> Version -> Version -> Bool +requiredVersionOpCompare = \case + RequiredVersionEQ -> (==) + RequiredVersionLT -> (<) + RequiredVersionLTE -> (<=) + RequiredVersionGT -> (>) + RequiredVersionGTE -> (>=) + RequiredVersionIsh -> (=~) (=~) :: Version -> Version -> Bool a =~ b = a >= b && a < incrementVersion b @@ -75,7 +126,3 @@ a =~ b = a >= b && a < incrementVersion b onVersion f = makeVersion . f . versionBranch backwards f = reverse . f . reverse onHead f as = maybe as (uncurry (:) . first f) $ uncons as - -isRequiredVersionSatisfied :: RequiredVersion -> Version -> Bool -isRequiredVersionSatisfied RequiredVersion {..} = - (`requiredVersionCompare` requiredVersionCompareWith) diff --git a/src/Stackctl/DirectoryOption.hs b/src/Stackctl/DirectoryOption.hs index b9d5e3d..9acb7d9 100644 --- a/src/Stackctl/DirectoryOption.hs +++ b/src/Stackctl/DirectoryOption.hs @@ -1,22 +1,22 @@ module Stackctl.DirectoryOption - ( DirectoryOption(..) + ( DirectoryOption (..) , defaultDirectoryOption - , HasDirectoryOption(..) + , HasDirectoryOption (..) , envDirectoryOption , directoryOption ) where import Stackctl.Prelude -import Data.Semigroup (Last(..)) +import Data.Semigroup (Last (..)) import qualified Env import Options.Applicative newtype DirectoryOption = DirectoryOption { unDirectoryOption :: FilePath } - deriving newtype IsString - deriving Semigroup via Last DirectoryOption + deriving newtype (IsString) + deriving (Semigroup) via Last DirectoryOption defaultDirectoryOption :: DirectoryOption defaultDirectoryOption = "." @@ -28,14 +28,21 @@ instance HasDirectoryOption DirectoryOption where directoryOptionL = id envDirectoryOption :: Env.Parser Env.Error DirectoryOption -envDirectoryOption = Env.var (Env.str <=< Env.nonempty) "DIRECTORY" - $ Env.help "Operate on specifications in this directory" +envDirectoryOption = + Env.var (Env.str <=< Env.nonempty) "DIRECTORY" + $ Env.help directoryHelp directoryOption :: Parser DirectoryOption -directoryOption = option str $ mconcat - [ short 'd' - , long "directory" - , metavar "PATH" - , help "Operate on specifications in PATH" - , action "directory" - ] +directoryOption = + option str + $ mconcat + [ short 'd' + , long "directory" + , metavar "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 81c5984..90ae154 100644 --- a/src/Stackctl/FilterOption.hs +++ b/src/Stackctl/FilterOption.hs @@ -1,22 +1,23 @@ module Stackctl.FilterOption ( FilterOption , defaultFilterOption - , HasFilterOption(..) + , HasFilterOption (..) , envFilterOption , filterOption , filterOptionFromPaths , filterOptionFromText + , filterOptionToPaths , filterStackSpecs ) where import Stackctl.Prelude import qualified Data.List.NonEmpty as NE -import Data.Semigroup (Last(..)) +import Data.Semigroup (Last (..)) import qualified Data.Text as T import qualified Env import Options.Applicative -import Stackctl.AWS.CloudFormation (StackName(..)) +import Stackctl.AWS.CloudFormation (StackName (..)) import Stackctl.StackSpec import System.FilePath (hasExtension) import System.FilePath.Glob @@ -24,7 +25,7 @@ import System.FilePath.Glob newtype FilterOption = FilterOption { unFilterOption :: NonEmpty Pattern } - deriving Semigroup via Last FilterOption + deriving (Semigroup) via Last FilterOption instance ToJSON FilterOption where toJSON = toJSON . showFilterOption @@ -37,19 +38,23 @@ instance HasFilterOption FilterOption where filterOptionL = id envFilterOption :: String -> Env.Parser Env.Error FilterOption -envFilterOption items = - Env.var (first Env.UnreadError . readFilterOption) "FILTERS" - $ Env.help - $ "Filter " - <> items - <> " by patterns" +envFilterOption items = var "FILTERS" <|> var "FILTER" + where + var name = + Env.var (first Env.UnreadError . readFilterOption) name + $ Env.help + $ "Filter " + <> items + <> " by patterns" filterOption :: String -> Parser FilterOption -filterOption items = option (eitherReader readFilterOption) $ mconcat - [ long "filter" - , metavar "PATTERN[,PATTERN]" - , help $ "Filter " <> items <> " to match PATTERN(s)" - ] +filterOption items = + option (eitherReader readFilterOption) + $ mconcat + [ long "filter" + , metavar "PATTERN[,PATTERN]" + , help $ "Filter " <> items <> " to match PATTERN(s)" + ] filterOptionFromPaths :: NonEmpty FilePath -> FilterOption filterOptionFromPaths = FilterOption . fmap compile @@ -72,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"] @@ -80,7 +85,8 @@ expandPatterns t = map compile $ s : expanded readFilterOption :: String -> Either String FilterOption readFilterOption = note err . filterOptionFromText . pack - where err = "Must be non-empty, comma-separated list of non-empty patterns" + where + err = "Must be non-empty, comma-separated list of non-empty patterns" showFilterOption :: FilterOption -> String showFilterOption = @@ -93,13 +99,17 @@ showFilterOption = defaultFilterOption :: FilterOption defaultFilterOption = filterOptionFromPaths $ pure "**/*" +filterOptionToPaths :: FilterOption -> [FilePath] +filterOptionToPaths = map decompile . NE.toList . unFilterOption + filterStackSpecs :: FilterOption -> [StackSpec] -> [StackSpec] filterStackSpecs fo = filter $ \spec -> any (`matchStackSpec` spec) $ unFilterOption fo matchStackSpec :: Pattern -> StackSpec -> Bool -matchStackSpec p spec = or - [ match p $ unpack $ unStackName $ stackSpecStackName spec - , match p $ stackSpecStackFile spec - , match p $ stackSpecTemplateFile spec - ] +matchStackSpec p spec = + or + [ match p $ unpack $ unStackName $ stackSpecStackName spec + , match p $ stackSpecStackFile spec + , match p $ stackSpecTemplateFile spec + ] 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 fcd82be..6f472bc 100644 --- a/src/Stackctl/Options.hs +++ b/src/Stackctl/Options.hs @@ -9,6 +9,7 @@ import Stackctl.Prelude import Data.Semigroup.Generic import qualified Env import Options.Applicative +import Stackctl.AutoSSO import Stackctl.ColorOption import Stackctl.DirectoryOption import Stackctl.FilterOption @@ -19,18 +20,19 @@ data Options = Options , oFilter :: Maybe FilterOption , oColor :: Maybe ColorOption , oVerbose :: Verbosity + , oAutoSSO :: Maybe AutoSSOOption } - deriving stock Generic - deriving Semigroup via GenericSemigroupMonoid Options + deriving stock (Generic) + deriving (Semigroup) via GenericSemigroupMonoid Options directoryL :: Lens' Options (Maybe DirectoryOption) -directoryL = lens oDirectory $ \x y -> x { oDirectory = y } +directoryL = lens oDirectory $ \x y -> x {oDirectory = y} filterL :: Lens' Options (Maybe FilterOption) -filterL = lens oFilter $ \x y -> x { oFilter = y } +filterL = lens oFilter $ \x y -> x {oFilter = y} -colorL :: Lens' Options (Maybe ColorOption) -colorL = lens oColor $ \x y -> x { oColor = y } +autoSSOL :: Lens' Options (Maybe AutoSSOOption) +autoSSOL = lens oAutoSSO $ \x y -> x {oAutoSSO = y} instance HasDirectoryOption Options where directoryOptionL = directoryL . maybeLens defaultDirectoryOption @@ -39,25 +41,33 @@ instance HasFilterOption Options where filterOptionL = filterL . maybeLens defaultFilterOption instance HasColorOption Options where - colorOptionL = colorL . maybeLens defaultColorOption + colorOptionL = lens oColor $ \x y -> x {oColor = y} instance HasVerboseOption Options where - verboseOptionL = lens oVerbose $ \x y -> x { oVerbose = y } + verboseOptionL = lens oVerbose $ \x y -> x {oVerbose = y} + +instance HasAutoSSOOption Options where + autoSSOOptionL = autoSSOL . maybeLens defaultAutoSSOOption -- brittany-disable-next-binding 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 +envParser = + Env.prefixed "STACKCTL_" + $ Options + <$> optional envDirectoryOption + <*> optional (envFilterOption "specifications") + <*> pure mempty -- use LOG_COLOR + <*> pure mempty -- use LOG_LEVEL + <*> optional envAutoSSOOption -- brittany-disable-next-binding optionsParser :: Parser Options -optionsParser = Options - <$> optional directoryOption - <*> optional (filterOption "specifications") - <*> (Just <$> colorOption) - <*> verboseOption +optionsParser = + Options + <$> optional directoryOption + <*> optional (filterOption "specifications") + <*> optional colorOption + <*> verboseOption + <*> optional autoSSOOption diff --git a/src/Stackctl/ParameterOption.hs b/src/Stackctl/ParameterOption.hs index e579c9b..ea53730 100644 --- a/src/Stackctl/ParameterOption.hs +++ b/src/Stackctl/ParameterOption.hs @@ -9,12 +9,14 @@ import Options.Applicative import Stackctl.AWS.CloudFormation (Parameter, makeParameter) parameterOption :: Parser Parameter -parameterOption = option (eitherReader readParameter) $ mconcat - [ short 'p' - , long "parameter" - , metavar "KEY=[VALUE]" - , help "Override the given Parameter for this operation" - ] +parameterOption = + option (eitherReader readParameter) + $ mconcat + [ short 'p' + , long "parameter" + , metavar "KEY=[VALUE]" + , help "Override the given Parameter for this operation" + ] readParameter :: String -> Either String Parameter readParameter s = case T.breakOn "=" t of @@ -22,4 +24,5 @@ readParameter s = case T.breakOn "=" t of (k, _) | T.null k -> Left $ "Empty key (" <> s <> ")" (k, "=") -> Right $ makeParameter k $ Just "" (k, v) -> Right $ makeParameter k $ Just $ T.drop 1 v - where t = pack s + where + t = pack s diff --git a/src/Stackctl/Prelude.hs b/src/Stackctl/Prelude.hs index 307b834..370271a 100644 --- a/src/Stackctl/Prelude.hs +++ b/src/Stackctl/Prelude.hs @@ -5,7 +5,7 @@ module Stackctl.Prelude ) where import RIO as X hiding - ( LogLevel(..) + ( LogLevel (..) , LogSource , logDebug , logDebugS @@ -20,11 +20,18 @@ 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.Aeson as X (ToJSON (..), object) import Data.Text as X (pack, unpack) import System.FilePath as X - (dropExtension, takeBaseName, takeDirectory, (<.>), ()) + ( dropExtension + , takeBaseName + , takeDirectory + , (<.>) + , () + ) import UnliftIO.Directory as X (withCurrentDirectory) {-# ANN module ("HLint: ignore Avoid restricted alias" :: String) #-} diff --git a/src/Stackctl/Prompt.hs b/src/Stackctl/Prompt.hs index d006bf5..ad3825e 100644 --- a/src/Stackctl/Prompt.hs +++ b/src/Stackctl/Prompt.hs @@ -1,6 +1,7 @@ module Stackctl.Prompt ( prompt , promptContinue + , promptOrExit ) where import Stackctl.Prelude @@ -34,7 +35,13 @@ prompt message parse dispatch = do promptContinue :: (MonadIO m, MonadLogger m, MonadReader env m, HasLogger env) => m () -promptContinue = prompt "Continue (y/n)" parse dispatch +promptContinue = promptOrExit "Continue" + +promptOrExit + :: (MonadIO m, MonadLogger m, MonadReader env m, HasLogger env) + => Text + -> m () +promptOrExit msg = prompt (msg <> " (y/n)") parse dispatch where parse x | x `elem` ["y", "Y"] = Right True diff --git a/src/Stackctl/RemovedStack.hs b/src/Stackctl/RemovedStack.hs new file mode 100644 index 0000000..12c2ace --- /dev/null +++ b/src/Stackctl/RemovedStack.hs @@ -0,0 +1,47 @@ +module Stackctl.RemovedStack + ( inferRemovedStacks + ) where + +import Stackctl.Prelude + +import Control.Error.Util (hoistMaybe) +import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) +import Stackctl.AWS.CloudFormation +import Stackctl.AWS.Core as AWS +import Stackctl.AWS.Scope +import Stackctl.DirectoryOption +import Stackctl.FilterOption +import UnliftIO.Directory (doesFileExist) + +inferRemovedStacks + :: ( MonadUnliftIO m + , MonadAWS m + , MonadReader env m + , HasAwsScope env + , HasDirectoryOption env + , HasFilterOption env + ) + => m [Stack] +inferRemovedStacks = do + scope <- view awsScopeL + paths <- view $ filterOptionL . to filterOptionToPaths + dir <- view $ directoryOptionL . to unDirectoryOption + catMaybes <$> traverse (findRemovedStack scope dir) paths + +findRemovedStack + :: (MonadUnliftIO m, MonadAWS m) + => AwsScope + -> FilePath + -- ^ Root directory + -> FilePath + -> m (Maybe Stack) +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 (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 b528fe7..d71d790 100644 --- a/src/Stackctl/Spec/Capture.hs +++ b/src/Stackctl/Spec/Capture.hs @@ -1,5 +1,5 @@ module Stackctl.Spec.Capture - ( CaptureOptions(..) + ( CaptureOptions (..) , parseCaptureOptions , runCapture ) where @@ -10,9 +10,16 @@ import Options.Applicative import Stackctl.AWS import Stackctl.AWS.Scope import Stackctl.Config (HasConfig) -import Stackctl.DirectoryOption (HasDirectoryOption(..), unDirectoryOption) +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 @@ -27,85 +34,107 @@ data CaptureOptions = CaptureOptions -- brittany-disable-next-binding parseCaptureOptions :: Parser CaptureOptions -parseCaptureOptions = CaptureOptions - <$> optional (strOption - ( short 'n' - <> long "account-name" - <> metavar "NAME" - <> help "Account name to use in generated files" - )) - <*> optional (strOption - ( short 't' - <> long "template-path" - <> metavar "PATH" - <> help "Write Template to PATH. Default is based on STACK" - )) - <*> optional (strOption - ( short 'p' - <> long "path" - <> metavar "PATH" - <> help "Write specification to PATH. Default is based on STACK" - )) - <*> optional (some (StackName <$> strOption - ( long "depend" - <> metavar "STACK" - <> help "Add a dependency on STACK" - ))) - <*> flag TemplateFormatYaml TemplateFormatJson - ( long "no-flip" - <> help "Don't flip JSON templates to Yaml" +parseCaptureOptions = + CaptureOptions + <$> optional + ( strOption + ( short 'n' + <> long "account-name" + <> metavar "NAME" + <> help "Account name to use in generated files" + ) + ) + <*> optional + ( strOption + ( short 't' + <> long "template-path" + <> metavar "PATH" + <> help "Write Template to PATH. Default is based on STACK" + ) + ) + <*> optional + ( strOption + ( short 'p' + <> long "path" + <> metavar "PATH" + <> help "Write specification to PATH. Default is based on STACK" + ) + ) + <*> optional + ( some + ( StackName + <$> strOption + ( long "depend" + <> metavar "STACK" + <> help "Add a dependency on STACK" + ) + ) + ) + <*> flag + TemplateFormatYaml + TemplateFormatJson + ( long "no-flip" + <> help "Don't flip JSON templates to Yaml" ) <*> strArgument - ( metavar "STACK" - <> help "Name of deployed Stack to capture" + ( metavar "STACK" + <> help "Name of deployed Stack to capture" ) runCapture :: ( MonadMask m , MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasAwsScope env - , HasAwsEnv env , HasConfig env , HasDirectoryOption env ) => CaptureOptions -> m () runCapture CaptureOptions {..} = do - dir <- unDirectoryOption <$> view directoryOptionL - let setScopeName scope = - maybe scope (\name -> scope { awsAccountName = name }) scoAccountName + maybe scope (\name -> scope {awsAccountName = name}) scoAccountName + + generate' stack template mPath mTemplatePath = do + let + stackName = StackName $ stack ^. stack_stackName + templateBody = templateBodyFromValue template - generate' stack template path templatePath = do - void $ local (awsScopeL %~ setScopeName) $ generate Generate - { gOutputDirectory = dir - , gTemplatePath = templatePath - , gTemplateFormat = scoTemplateFormat - , gStackPath = path - , gStackName = StackName $ stack ^. stack_stackName - , gDescription = stackDescription stack - , gDepends = scoDepends - , gActions = Nothing - , gParameters = parameters stack - , gCapabilities = capabilities stack - , gTags = tags stack - , gTemplateBody = templateBodyFromValue template - } + void + $ local (awsScopeL %~ setScopeName) + $ generate + 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 template <- awsCloudFormationGetTemplate stackName diff --git a/src/Stackctl/Spec/Cat.hs b/src/Stackctl/Spec/Cat.hs index c72c4c2..3ef1ffa 100644 --- a/src/Stackctl/Spec/Cat.hs +++ b/src/Stackctl/Spec/Cat.hs @@ -1,5 +1,5 @@ module Stackctl.Spec.Cat - ( CatOptions(..) + ( CatOptions (..) , parseCatOptions , runCat ) where @@ -21,7 +21,7 @@ import Stackctl.AWS import Stackctl.AWS.Scope import Stackctl.Colors import Stackctl.Config (HasConfig) -import Stackctl.DirectoryOption (HasDirectoryOption(..), unDirectoryOption) +import Stackctl.DirectoryOption (HasDirectoryOption (..), unDirectoryOption) import Stackctl.FilterOption (HasFilterOption) import Stackctl.Spec.Discover import Stackctl.StackSpec @@ -37,24 +37,25 @@ data CatOptions = CatOptions -- brittany-disable-next-binding parseCatOptions :: Parser CatOptions -parseCatOptions = CatOptions - <$> switch - ( long "no-stacks" - <> help "Only show templates/" - ) - <*> switch - ( long "no-templates" - <> help "Only show stacks/" - ) - <*> switch - ( short 'b' - <> long "brief" - <> help "Don't show file contents, only paths" - ) +parseCatOptions = + CatOptions + <$> switch + ( long "no-stacks" + <> help "Only show templates/" + ) + <*> switch + ( long "no-templates" + <> help "Only show stacks/" + ) + <*> switch + ( short 'b' + <> long "brief" + <> help "Don't show file contents, only paths" + ) runCat - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m , MonadLogger m , MonadReader env m , HasLogger env @@ -62,7 +63,6 @@ runCat , HasConfig env , HasDirectoryOption env , HasFilterOption env - , HasColorOption env ) => CatOptions -> m () @@ -115,32 +115,34 @@ specTree = map (second groupRegion) . groupAccount groupRegion = groupTo (stackSpecPathRegion . stackSpecSpecPath) groupAccount :: [StackSpec] -> [((AccountId, Text), [StackSpec])] - groupAccount = groupTo - ((stackSpecPathAccountId &&& stackSpecPathAccountName) . stackSpecSpecPath) + groupAccount = + groupTo + ((stackSpecPathAccountId &&& stackSpecPathAccountName) . stackSpecSpecPath) groupTo :: Ord b => (a -> b) -> [a] -> [(b, [a])] groupTo f = map (f . NE.head &&& NE.toList) . NE.groupAllWith f prettyPrintStackSpecYaml :: Colors -> StackName -> StackSpecYaml -> [Text] -prettyPrintStackSpecYaml Colors {..} name StackSpecYaml {..} = concat - [ [cyan "Name" <> ": " <> green (unStackName name)] - , maybe [] ppDescription ssyDescription - , [cyan "Template" <> ": " <> green (pack ssyTemplate)] - , ppObject "Parameters" parametersYamlKVs ssyParameters - , ppList "Capabilities" ppCapabilities ssyCapabilities - , ppObject "Tags" tagsYamlKVs ssyTags - ] +prettyPrintStackSpecYaml Colors {..} name StackSpecYaml {..} = + concat + [ [cyan "Name" <> ": " <> green (unStackName name)] + , maybe [] ppDescription ssyDescription + , [cyan "Template" <> ": " <> green (pack ssyTemplate)] + , ppObject "Parameters" parametersYamlKVs ssyParameters + , ppList "Capabilities" ppCapabilities ssyCapabilities + , ppObject "Tags" tagsYamlKVs ssyTags + ] where ppObject :: Text -> (a -> [(Text, Maybe Text)]) -> Maybe a -> [Text] ppObject label f mA = fromMaybe [] $ do 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) @@ -153,9 +155,13 @@ parametersYamlKVs :: ParametersYaml -> [(Text, Maybe Text)] parametersYamlKVs = mapMaybe parameterYamlKV . unParametersYaml parameterYamlKV :: ParameterYaml -> Maybe (Text, Maybe Text) -parameterYamlKV py = (,) <$> (p ^. parameter_parameterKey) <*> pure - (p ^. parameter_parameterValue) - where p = unParameterYaml py +parameterYamlKV py = + (,) + <$> (p ^. parameter_parameterKey) + <*> pure + (p ^. parameter_parameterValue) + where + p = unParameterYaml py tagsYamlKVs :: TagsYaml -> [(Text, Maybe Text)] tagsYamlKVs = map (tagKV . unTagYaml) . unTagsYaml @@ -164,12 +170,13 @@ tagKV :: Tag -> (Text, Maybe Text) tagKV tg = (tg ^. tag_key, tg ^. tag_value . to Just) prettyPrintTemplate :: Colors -> Value -> [Text] -prettyPrintTemplate Colors {..} val = concat - [ displayTextProperty "Description" - , displayObjectProperty "Parameters" - , displayObjectProperty "Resources" - , displayObjectProperty "Outputs" - ] +prettyPrintTemplate Colors {..} val = + concat + [ displayTextProperty "Description" + , displayObjectProperty "Parameters" + , displayObjectProperty "Resources" + , displayObjectProperty "Outputs" + ] where displayTextProperty :: Text -> [Text] displayTextProperty = displayPropertyWith @@ -179,13 +186,14 @@ prettyPrintTemplate Colors {..} val = concat displayObjectProperty = displayPropertyWith @(HashMap Text Value) $ map ((" - " <>) . green) - . sort - . HashMap.keys + . sort + . HashMap.keys displayPropertyWith :: (FromJSON a, ToJSON a) => (a -> [Text]) -> Text -> [Text] displayPropertyWith f k = cyan k <> ": " : fromMaybe [] displayValue - where displayValue = val ^? key (Key.fromText k) . _JSON . to f + where + displayValue = val ^? key (Key.fromText k) . _JSON . to f putBoxed :: MonadIO m => Int -> [Text] -> m () putBoxed n xs = do @@ -195,4 +203,5 @@ putBoxed n xs = do put :: MonadIO m => Int -> Text -> m () put n = liftIO . T.putStrLn . (indent <>) - where indent = mconcat $ replicate n " " + where + indent = mconcat $ replicate n " " diff --git a/src/Stackctl/Spec/Changes.hs b/src/Stackctl/Spec/Changes.hs index 612adb4..662f3f8 100644 --- a/src/Stackctl/Spec/Changes.hs +++ b/src/Stackctl/Spec/Changes.hs @@ -1,5 +1,5 @@ module Stackctl.Spec.Changes - ( ChangesOptions(..) + ( ChangesOptions (..) , parseChangesOptions , runChanges ) where @@ -16,6 +16,7 @@ import Stackctl.Config (HasConfig) import Stackctl.DirectoryOption (HasDirectoryOption) import Stackctl.FilterOption (HasFilterOption) import Stackctl.ParameterOption +import Stackctl.RemovedStack import Stackctl.Spec.Changes.Format import Stackctl.Spec.Discover import Stackctl.StackSpec @@ -24,6 +25,7 @@ import Stackctl.TagOption data ChangesOptions = ChangesOptions { scoFormat :: Format + , scoOmitFull :: OmitFull , scoParameters :: [Parameter] , scoTags :: [Tag] , scoOutput :: Maybe FilePath @@ -32,25 +34,29 @@ data ChangesOptions = ChangesOptions -- brittany-disable-next-binding parseChangesOptions :: Parser ChangesOptions -parseChangesOptions = ChangesOptions - <$> formatOption - <*> many parameterOption - <*> many tagOption - <*> optional (argument str - ( metavar "PATH" - <> help "Write changes summary to PATH" - <> action "file" - )) +parseChangesOptions = + ChangesOptions + <$> formatOption + <*> omitFullOption + <*> many parameterOption + <*> many tagOption + <*> optional + ( argument + str + ( metavar "PATH" + <> help "Write changes summary to PATH" + <> action "file" + ) + ) 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 @@ -61,9 +67,15 @@ runChanges ChangesOptions {..} = do -- Clear file before starting, as we have to use append for each spec liftIO $ traverse_ (`T.writeFile` "") scoOutput - specs <- discoverSpecs + colors <- case scoOutput of + Nothing -> getColorsLogger + Just {} -> pure noColors - for_ specs $ \spec -> do + let write formatted = case scoOutput of + Nothing -> pushLoggerLn formatted + Just p -> liftIO $ T.appendFile p $ formatted <> "\n" + + forEachSpec_ $ \spec -> do withThreadContext ["stackName" .= stackSpecStackName spec] $ do emChangeSet <- createChangeSet spec scoParameters scoTags @@ -72,14 +84,8 @@ runChanges ChangesOptions {..} = do logError $ "Error creating ChangeSet" :# ["error" .= err] exitFailure Right mChangeSet -> do - colors <- case scoOutput of - Nothing -> getColorsLogger - Just{} -> pure noColors - - let - name = pack $ stackSpecPathFilePath $ stackSpecSpecPath spec - formatted = formatChangeSet colors name scoFormat mChangeSet + let name = pack $ stackSpecPathFilePath $ stackSpecSpecPath spec + write $ formatChangeSet colors scoOmitFull name scoFormat mChangeSet - case scoOutput of - Nothing -> pushLoggerLn formatted - Just p -> liftIO $ T.appendFile p $ formatted <> "\n" + removed <- inferRemovedStacks + traverse_ (write . formatRemovedStack colors scoFormat) removed diff --git a/src/Stackctl/Spec/Changes/Format.hs b/src/Stackctl/Spec/Changes/Format.hs index 9f09135..359a27c 100644 --- a/src/Stackctl/Spec/Changes/Format.hs +++ b/src/Stackctl/Spec/Changes/Format.hs @@ -1,7 +1,10 @@ module Stackctl.Spec.Changes.Format - ( Format(..) + ( Format (..) , formatOption + , OmitFull (..) + , omitFullOption , formatChangeSet + , formatRemovedStack , formatTTY ) where @@ -16,15 +19,22 @@ import Stackctl.Colors data Format = FormatTTY | FormatPullRequest + deriving stock (Bounded, Enum, Show) + +data OmitFull + = OmitFull + | IncludeFull formatOption :: Parser Format -formatOption = option (eitherReader readFormat) $ mconcat - [ short 'f' - , long "format" - , help "Format to output changes in" - , value FormatTTY - , showDefaultWith showFormat - ] +formatOption = + option (eitherReader readFormat) + $ mconcat + [ short 'f' + , long "format" + , help "Format to output changes in" + , value FormatTTY + , showDefaultWith showFormat + ] readFormat :: String -> Either String Format readFormat = \case @@ -37,19 +47,40 @@ showFormat = \case FormatTTY -> "tty" FormatPullRequest -> "pr" -formatChangeSet :: Colors -> Text -> Format -> Maybe ChangeSet -> Text -formatChangeSet colors name = \case +-- brittany-disable-next-binding + +omitFullOption :: Parser OmitFull +omitFullOption = + flag + IncludeFull + OmitFull + ( long "no-include-full" + <> help "Don't include full ChangeSet JSON details" + ) + +formatChangeSet + :: Colors -> OmitFull -> Text -> Format -> Maybe ChangeSet -> Text +formatChangeSet colors omitFull name = \case FormatTTY -> formatTTY colors name - FormatPullRequest -> formatPullRequest name + FormatPullRequest -> formatPullRequest omitFull name + +formatRemovedStack :: Colors -> Format -> Stack -> Text +formatRemovedStack Colors {..} format stack = case format of + FormatTTY -> red "DELETE" <> " stack " <> cyan name + FormatPullRequest -> ":x: This PR will **delete** the stack `" <> name <> "`" + where + name = stack ^. stack_stackName formatTTY :: Colors -> Text -> Maybe ChangeSet -> Text 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 @@ -83,15 +114,15 @@ formatTTY colors@Colors {..} name mChangeSet = case (mChangeSet, rChanges) of x@Replacement_Conditional -> yellow (toText x) Replacement' x -> x -formatPullRequest :: Text -> Maybe ChangeSet -> Text -formatPullRequest name mChangeSet = +formatPullRequest :: OmitFull -> Text -> Maybe ChangeSet -> Text +formatPullRequest omitFull name mChangeSet = emoji <> " This PR generates " <> description <> " for `" <> name <> "`." - <> fromMaybe "" (commentBody <$> mChangeSet <*> rChanges) + <> fromMaybe "" (commentBody omitFull <$> mChangeSet <*> rChanges) <> "\n" where emoji = case (mChangeSet, nChanges) of @@ -112,37 +143,41 @@ formatPullRequest name mChangeSet = changes <- csChanges cs NE.nonEmpty $ mapMaybe resourceChange changes -commentBody :: ChangeSet -> NonEmpty ResourceChange -> Text -commentBody cs rcs = +commentBody :: OmitFull -> ChangeSet -> NonEmpty ResourceChange -> Text +commentBody omitFull cs rcs = mconcat $ [ "\n" , "\n| Action | Logical Id | Physical Id | Type | Replacement | Scope | Details |" , "\n| --- | --- | --- | --- | --- | --- | --- |" ] - <> map commentTableRow (NE.toList rcs) - <> [ "\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' {..} = mconcat - [ "\n" - , "| " <> maybe "" toText action <> " " - , "| " <> maybe "" toText logicalResourceId <> " " - , "| " <> maybe "" toText physicalResourceId <> " " - , "| " <> maybe "" toText resourceType <> " " - , "| " <> maybe "" toText replacement <> " " - , "| " <> maybe "" (T.intercalate ", " . map toText) scope <> " " - , "| " <> maybe "" (mdList . mapMaybe (formatDetail noColors)) details <> " " - , "|" - ] +commentTableRow ResourceChange' {..} = + mconcat + [ "\n" + , "| " <> maybe "" toText action <> " " + , "| " <> maybe "" toText logicalResourceId <> " " + , "| " <> maybe "" toText physicalResourceId <> " " + , "| " <> maybe "" toText resourceType <> " " + , "| " <> maybe "" toText replacement <> " " + , "| " <> maybe "" (T.intercalate ", " . map toText) scope <> " " + , "| " <> maybe "" (mdList . mapMaybe (formatDetail noColors)) details <> " " + , "|" + ] mdList :: [Text] -> Text mdList xs = @@ -160,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 4857e4a..d8c2913 100644 --- a/src/Stackctl/Spec/Deploy.hs +++ b/src/Stackctl/Spec/Deploy.hs @@ -1,6 +1,6 @@ module Stackctl.Spec.Deploy - ( DeployOptions(..) - , DeployConfirmation(..) + ( DeployOptions (..) + , DeployConfirmation (..) , parseDeployOptions , runDeploy ) where @@ -11,15 +11,17 @@ import Blammo.Logging.Logger (pushLoggerLn) import qualified Data.Text as T import Data.Time (defaultTimeLocale, formatTime, utcToLocalZonedTime) import Options.Applicative -import Stackctl.Action 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) import Stackctl.FilterOption (HasFilterOption) import Stackctl.ParameterOption import Stackctl.Prompt +import Stackctl.RemovedStack import Stackctl.Spec.Changes.Format import Stackctl.Spec.Discover import Stackctl.StackSpec @@ -31,39 +33,50 @@ data DeployOptions = DeployOptions , sdoTags :: [Tag] , sdoSaveChangeSets :: Maybe FilePath , sdoDeployConfirmation :: DeployConfirmation + , sdoRemovals :: Bool , sdoClean :: Bool } -- brittany-disable-next-binding parseDeployOptions :: Parser DeployOptions -parseDeployOptions = DeployOptions - <$> many parameterOption - <*> many tagOption - <*> optional (strOption - ( long "save-change-sets" - <> metavar "DIRECTORY" - <> help "Save executed changesets to DIRECTORY" - <> action "directory" - )) - <*> flag DeployWithConfirmation DeployWithoutConfirmation - ( long "no-confirm" - <> help "Don't confirm changes before executing" - ) - <*> switch - ( long "clean" - <> help "Remove all changesets from Stack after deploy" - ) +parseDeployOptions = + DeployOptions + <$> many parameterOption + <*> many tagOption + <*> optional + ( strOption + ( long "save-change-sets" + <> metavar "DIRECTORY" + <> help "Save executed changesets to DIRECTORY" + <> action "directory" + ) + ) + <*> flag + DeployWithConfirmation + DeployWithoutConfirmation + ( long "no-confirm" + <> help "Don't confirm changes before executing" + ) + <*> ( not + <$> switch + ( long "no-remove" + <> help "Don't delete removed Stacks" + ) + ) + <*> switch + ( long "clean" + <> help "Remove all changesets from Stack after deploy" + ) 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 @@ -71,11 +84,14 @@ runDeploy => DeployOptions -> m () runDeploy DeployOptions {..} = do - specs <- discoverSpecs + when sdoRemovals $ do + removed <- inferRemovedStacks + traverse_ (deleteRemovedStack sdoDeployConfirmation) removed - for_ specs $ \spec -> do + forEachSpec_ $ \spec -> do withThreadContext ["stackName" .= stackSpecStackName spec] $ do - handleRollbackComplete sdoDeployConfirmation $ stackSpecStackName spec + checkIfStackRequiresDeletion sdoDeployConfirmation + $ stackSpecStackName spec emChangeSet <- createChangeSet spec sdoParameters sdoTags @@ -97,29 +113,55 @@ runDeploy DeployOptions {..} = do runActions stackName PostDeploy $ stackSpecActions spec when sdoClean $ awsCloudFormationDeleteAllChangeSets stackName +deleteRemovedStack + :: ( MonadIO m + , MonadMask m + , MonadAWS m + , MonadLogger m + , MonadReader env m + , HasLogger env + ) + => DeployConfirmation + -> Stack + -> m () +deleteRemovedStack confirmation stack = do + withThreadContext ["stack" .= stackName] $ do + colors <- getColorsLogger + pushLoggerLn $ formatRemovedStack colors FormatTTY stack + + case confirmation of + DeployWithConfirmation -> do + promptContinue + logInfo "Deleting Stack" + DeployWithoutConfirmation -> pure () + + deleteStack stackName + where + stackName = StackName $ stack ^. stack_stackName + data DeployConfirmation = DeployWithConfirmation | DeployWithoutConfirmation - deriving stock Eq + deriving stock (Eq) -handleRollbackComplete +checkIfStackRequiresDeletion :: ( MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => DeployConfirmation -> StackName -> m () -handleRollbackComplete confirmation stackName = do +checkIfStackRequiresDeletion confirmation stackName = do mStack <- awsCloudFormationDescribeStackMaybe stackName - when (maybe False stackIsRollbackComplete mStack) $ do - logWarn - $ "Stack is in ROLLBACK_COMPLETE state and must be deleted before proceeding" - :# ["stackName" .= stackName] + for_ (stackStatusRequiresDeletion =<< mStack) $ \status -> do + logWarn $ "Stack must be deleted before proceeding" :# ["status" .= status] + when (status == StackStatus_ROLLBACK_FAILED) + $ logWarn + "Stack is in ROLLBACK_FAILED. This may require elevated permissions for the delete to succeed" case confirmation of DeployWithConfirmation -> promptContinue @@ -127,19 +169,26 @@ handleRollbackComplete confirmation stackName = do logError "Refusing to delete without confirmation" exitFailure - result <- awsCloudFormationDeleteStack stackName + logInfo "Deleting Stack" + deleteStack stackName - case result of - StackDeleteSuccess -> logInfo $ prettyStackDeleteResult result :# [] - StackDeleteFailure{} -> logWarn $ prettyStackDeleteResult result :# [] +deleteStack + :: (MonadIO m, MonadAWS m, MonadLogger m) + => StackName + -> m () +deleteStack stackName = do + result <- awsCloudFormationDeleteStack stackName + + case result of + StackDeleteSuccess -> logInfo $ prettyStackDeleteResult result :# [] + StackDeleteFailure {} -> logWarn $ prettyStackDeleteResult result :# [] deployChangeSet :: ( MonadUnliftIO m - , MonadResource m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => DeployConfirmation -> ChangeSet @@ -158,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 @@ -173,22 +230,23 @@ deployChangeSet confirmation changeSet = do case result of StackCreateSuccess -> onSuccess - StackCreateFailure{} -> onFailure + StackCreateFailure {} -> onFailure StackUpdateSuccess -> onSuccess - StackUpdateFailure{} -> onFailure + StackUpdateFailure {} -> onFailure where stackName = csStackName changeSet changeSetId = csChangeSetId changeSet tailStackEventsSince - :: ( MonadResource m + :: ( MonadIO m + , MonadAWS m , MonadLogger m , MonadReader env m , HasLogger env - , HasAwsEnv env ) => StackName - -> Maybe Text -- ^ StackEventId + -> Maybe Text + -- ^ StackEventId -> m a tailStackEventsSince stackName mLastId = do colors <- getColorsLogger @@ -206,18 +264,19 @@ tailStackEventsSince stackName mLastId = do formatStackEvent :: MonadIO m => Colors -> StackEvent -> m Text formatStackEvent Colors {..} e = do timestamp <- - liftIO $ formatTime defaultTimeLocale "%F %T %Z" <$> utcToLocalZonedTime - (e ^. stackEvent_timestamp) - - pure $ mconcat - [ fromString timestamp - , " | " - , maybe "" colorStatus $ e ^. stackEvent_resourceStatus - , maybe "" (magenta . (" " <>)) $ e ^. stackEvent_logicalResourceId - , maybe "" ((\x -> " (" <> x <> ")") . T.strip) - $ e - ^. stackEvent_resourceStatusReason - ] + liftIO + $ formatTime defaultTimeLocale "%F %T %Z" + <$> utcToLocalZonedTime (e ^. stackEvent_timestamp) + + pure + $ mconcat + [ fromString timestamp + , " | " + , maybe "" colorStatus $ e ^. stackEvent_resourceStatus + , maybe "" (magenta . (" " <>)) $ e ^. stackEvent_logicalResourceId + , maybe "" ((\x -> " (" <> x <> ")") . T.strip) + $ e ^. stackEvent_resourceStatusReason + ] where colorStatus = \case ResourceStatus' x diff --git a/src/Stackctl/Spec/Discover.hs b/src/Stackctl/Spec/Discover.hs index f2e27c9..bcec218 100644 --- a/src/Stackctl/Spec/Discover.hs +++ b/src/Stackctl/Spec/Discover.hs @@ -1,5 +1,6 @@ module Stackctl.Spec.Discover - ( discoverSpecs + ( forEachSpec_ + , discoverSpecs , buildSpecPath ) where @@ -7,19 +8,35 @@ 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) -import Stackctl.DirectoryOption (HasDirectoryOption(..), unDirectoryOption) -import Stackctl.FilterOption (HasFilterOption(..), filterStackSpecs) +import Stackctl.DirectoryOption (HasDirectoryOption (..), unDirectoryOption) +import Stackctl.FilterOption (HasFilterOption (..), filterStackSpecs) import Stackctl.StackSpec import Stackctl.StackSpecPath import System.FilePath (isPathSeparator) import System.FilePath.Glob +forEachSpec_ + :: ( MonadIO m + , MonadMask m + , MonadLogger m + , MonadReader env m + , HasAwsScope env + , HasConfig env + , HasDirectoryOption env + , HasFilterOption env + ) + => (StackSpec -> m ()) + -> m () +forEachSpec_ f = traverse_ f =<< discoverSpecs + discoverSpecs - :: ( MonadMask m - , MonadResource m + :: ( MonadIO m + , MonadMask m , MonadLogger m , MonadReader env m , HasAwsScope env @@ -30,27 +47,8 @@ discoverSpecs => m [StackSpec] discoverSpecs = do dir <- unDirectoryOption <$> view directoryOptionL - scope@AwsScope {..} <- view awsScopeL - paths <- globRelativeTo - dir - [ compile - $ "stacks" - unpack (unAccountId awsAccountId) - <> ".*" - unpack (fromRegion awsRegion) - <> "**" - "*" - <.> "yaml" - , compile - $ "stacks" - "*." - <> unpack (unAccountId awsAccountId) - unpack (fromRegion awsRegion) - <> "**" - "*" - <.> "yaml" - ] - + scope <- view awsScopeL + paths <- globRelativeTo dir $ awsScopeSpecPatterns scope filterOption <- view filterOptionL let @@ -68,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 () @@ -92,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 09e5563..b6a68e5 100644 --- a/src/Stackctl/Spec/Generate.hs +++ b/src/Stackctl/Spec/Generate.hs @@ -1,37 +1,34 @@ module Stackctl.Spec.Generate - ( Generate(..) + ( GenerateSpec (..) + , GenerateTemplate (..) , generate - , TemplateFormat(..) + , TemplateFormat (..) ) where import Stackctl.Prelude -import Stackctl.Action import Stackctl.AWS import Stackctl.AWS.Scope import Stackctl.Config (HasConfig) +import Stackctl.DirectoryOption import Stackctl.Spec.Discover (buildSpecPath) import Stackctl.StackSpec import Stackctl.StackSpecPath import Stackctl.StackSpecYaml -data Generate = Generate - { gOutputDirectory :: FilePath - , gTemplatePath :: Maybe FilePath - -- ^ If not given, will use @{stack-name}.(yaml|json)@ - , gTemplateFormat :: TemplateFormat - -- ^ Ignored if 'gTemplatePath' is given - , gStackPath :: Maybe FilePath - -- ^ If not given, will use @{stack-name}.yaml@ - , gStackName :: StackName - , gDescription :: Maybe StackDescription - , gDepends :: Maybe [StackName] - , gActions :: Maybe [Action] - , gParameters :: Maybe [Parameter] - , gCapabilities :: Maybe [Capability] - , gTags :: Maybe [Tag] - , gTemplateBody :: TemplateBody - } +data GenerateSpec + = -- | Generate at an inferred name + GenerateSpec StackName + | -- | Generate to a given path + GenerateSpecTo StackName FilePath + +data GenerateTemplate + = -- | Generate at an inferred name + GenerateTemplate TemplateBody TemplateFormat + | -- | Generate to the given path + GenerateTemplateTo TemplateBody FilePath + | -- | Assume template exists + UseExistingTemplate FilePath data TemplateFormat = TemplateFormatYaml @@ -44,36 +41,35 @@ generate , MonadReader env m , HasConfig env , HasAwsScope env + , HasDirectoryOption env ) - => Generate + => Bool + -> GenerateSpec + -> GenerateTemplate + -> (FilePath -> StackSpecYaml) -> m FilePath -generate Generate {..} = do +generate overwrite spec template toStackSpecYaml = do let - defaultStackPath = unpack (unStackName gStackName) <.> "yaml" - defaultTemplatePath = - unpack (unStackName gStackName) <.> case gTemplateFormat of - TemplateFormatYaml -> "yaml" - TemplateFormatJson -> "json" - - stackPath = fromMaybe defaultStackPath gStackPath + (stackName, stackPath) = case spec of + GenerateSpec name -> (name, unpack (unStackName name) <> ".yaml") + GenerateSpecTo name path -> (name, path) - specPath <- buildSpecPath gStackName stackPath + (mTemplateBody, templatePath) = case template of + GenerateTemplate body format -> + ( Just body + , case format of + TemplateFormatYaml -> unpack (unStackName stackName) <> ".yaml" + TemplateFormatJson -> unpack (unStackName stackName) <> ".json" + ) + GenerateTemplateTo body path -> (Just body, path) + UseExistingTemplate path -> (Nothing, path) - let - templatePath = fromMaybe defaultTemplatePath gTemplatePath - 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 - stackSpec <- buildStackSpec gOutputDirectory specPath specYaml + dir <- view $ directoryOptionL . to unDirectoryOption + specPath <- buildSpecPath stackName stackPath + stackSpec <- buildStackSpec dir specPath specYaml withThreadContext ["stackName" .= stackSpecStackName stackSpec] $ do - logInfo "Generating specification" - writeStackSpec stackSpec gTemplateBody + writeStackSpec overwrite stackSpec mTemplateBody pure $ stackSpecPathFilePath specPath diff --git a/src/Stackctl/Spec/List.hs b/src/Stackctl/Spec/List.hs new file mode 100644 index 0000000..097717a --- /dev/null +++ b/src/Stackctl/Spec/List.hs @@ -0,0 +1,121 @@ +module Stackctl.Spec.List + ( ListOptions (..) + , parseListOptions + , runList + ) where + +import Stackctl.Prelude + +import Blammo.Logging.Logger (pushLoggerLn) +import qualified Data.Text as T +import Options.Applicative +import Stackctl.AWS +import Stackctl.AWS.Scope +import Stackctl.Colors +import Stackctl.Config (HasConfig) +import Stackctl.DirectoryOption (HasDirectoryOption (..)) +import Stackctl.FilterOption (HasFilterOption) +import Stackctl.Spec.Discover +import Stackctl.StackSpec + +newtype ListOptions = ListOptions + { loLegend :: Bool + } + +parseListOptions :: Parser ListOptions +parseListOptions = + ListOptions + <$> ( not + <$> switch + ( mconcat + [ long "no-legend" + , help "Don't print indicators legend at the end" + ] + ) + ) + +runList + :: ( MonadUnliftIO m + , MonadMask m + , MonadAWS m + , MonadLogger m + , MonadReader env m + , HasAwsScope env + , HasLogger env + , HasConfig env + , HasDirectoryOption env + , HasFilterOption env + ) + => ListOptions + -> m () +runList ListOptions {..} = do + colors@Colors {..} <- getColorsLogger + + forEachSpec_ $ \spec -> do + let + path = stackSpecFilePath spec + name = stackSpecStackName spec + + mStackStatus <- + fmap (^. stack_stackStatus) + <$> awsCloudFormationDescribeStackMaybe name + + let + indicator = maybe NotDeployed statusIndicator mStackStatus + + formatted :: Text + formatted = + " " + <> 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/StackDescription.hs b/src/Stackctl/StackDescription.hs index fc95edc..34920e2 100644 --- a/src/Stackctl/StackDescription.hs +++ b/src/Stackctl/StackDescription.hs @@ -1,12 +1,12 @@ module Stackctl.StackDescription - ( StackDescription(..) + ( StackDescription (..) , addStackDescription ) where import Stackctl.Prelude import Control.Lens ((?~)) -import Data.Aeson (FromJSON, Value(..)) +import Data.Aeson (FromJSON, Value (..)) import qualified Data.Aeson as JSON import Data.Aeson.Lens import Data.ByteString.Char8 as BS8 @@ -28,13 +28,15 @@ addStackDescription mStackDescription body = fromMaybe body $ do decodeUtf8 <$> case bc of BodyContentJSON v -> updateJSON d bs <$ guard (not $ hasDescription v) BodyContentYaml v -> updateYaml d bs <$ guard (not $ hasDescription v) - where bs = encodeUtf8 body + where + bs = encodeUtf8 body getBodyContent :: ByteString -> Maybe BodyContent -getBodyContent body = asum - [ BodyContentJSON . Object <$> JSON.decodeStrict body - , hush $ BodyContentYaml . Object <$> Yaml.decodeEither' body - ] +getBodyContent body = + asum + [ BodyContentJSON . Object <$> JSON.decodeStrict body + , hush $ BodyContentYaml . Object <$> Yaml.decodeEither' body + ] -- Inserting a key is easy to do in Yaml without the parsing round-trip that -- would strip formatting and comments. But updating a key is hard. To avoid diff --git a/src/Stackctl/StackSpec.hs b/src/Stackctl/StackSpec.hs index f118107..e2e0300 100644 --- a/src/Stackctl/StackSpec.hs +++ b/src/Stackctl/StackSpec.hs @@ -1,9 +1,11 @@ module Stackctl.StackSpec ( StackSpec + , stackSpecFilePath , stackSpecSpecPath , stackSpecSpecBody , stackSpecStackName , stackSpecStackDescription + , stackSpecDepends , stackSpecActions , stackSpecParameters , stackSpecCapabilities @@ -26,15 +28,15 @@ import Data.Aeson import qualified Data.ByteString.Lazy as BSL import Data.List.Extra (nubOrdOn) import qualified Data.Yaml as Yaml -import Stackctl.Action import Stackctl.AWS -import Stackctl.Config (HasConfig(..), applyConfig) +import Stackctl.Action +import Stackctl.Config (HasConfig (..), applyConfig) import Stackctl.Sort import Stackctl.StackSpecPath import Stackctl.StackSpecYaml -import qualified System.FilePath as FilePath import System.FilePath (takeExtension) -import UnliftIO.Directory (createDirectoryIfMissing) +import qualified System.FilePath as FilePath +import UnliftIO.Directory (createDirectoryIfMissing, doesFileExist) data StackSpec = StackSpec { ssSpecRoot :: FilePath @@ -45,6 +47,10 @@ data StackSpec = StackSpec stackSpecSpecRoot :: StackSpec -> FilePath stackSpecSpecRoot = ssSpecRoot +stackSpecFilePath :: StackSpec -> FilePath +stackSpecFilePath spec = + FilePath.normalise $ stackSpecSpecRoot spec stackSpecStackFile spec + stackSpecSpecPath :: StackSpec -> StackSpecPath stackSpecSpecPath = ssSpecPath @@ -75,8 +81,7 @@ stackSpecTemplate :: StackSpec -> StackTemplate stackSpecTemplate spec = StackTemplate $ FilePath.normalise - $ ssSpecRoot spec - stackSpecTemplateFile spec + $ ssSpecRoot spec stackSpecTemplateFile spec stackSpecParameters :: StackSpec -> [Parameter] stackSpecParameters = @@ -96,11 +101,12 @@ buildStackSpec -> m StackSpec buildStackSpec dir specPath specBody = do config <- view configL - pure StackSpec - { ssSpecRoot = dir - , ssSpecPath = specPath - , ssSpecBody = applyConfig config specBody - } + pure + StackSpec + { ssSpecRoot = dir + , ssSpecPath = specPath + , ssSpecBody = applyConfig config specBody + } data TemplateBody = TemplateText Text @@ -109,7 +115,7 @@ data TemplateBody newtype UnexpectedTemplateJson = UnexpectedTemplateJson { _unexpectedTemplateJsonExtension :: String } - deriving stock Show + deriving stock (Show) instance Exception UnexpectedTemplateJson where displayException (UnexpectedTemplateJson ext) = @@ -135,17 +141,32 @@ writeTemplateBody path body = do dir = takeDirectory path ext = takeExtension path -writeStackSpec :: MonadUnliftIO m => StackSpec -> TemplateBody -> m () -writeStackSpec stackSpec templateBody = do - writeTemplateBody templatePath templateBody - createDirectoryIfMissing True $ takeDirectory specPath - liftIO $ Yaml.encodeFile specPath $ stackSpecSpecBody stackSpec +writeStackSpec + :: (MonadUnliftIO m, MonadLogger m) + => Bool + -> StackSpec + -> Maybe TemplateBody + -> m () +writeStackSpec overwrite stackSpec mTemplateBody = do + for_ mTemplateBody $ \templateBody -> do + logInfo $ "Writing template" :# ["path" .= templatePath] + writeTemplateBody templatePath templateBody + + exists <- doesFileExist specPath + + if exists && not overwrite + then do + let + reason :: Text + reason = "file exists and overwrite not set" + logInfo $ "Skipping" :# ["path" .= specPath, "reason" .= reason] + else do + logInfo $ "Writing specification" :# ["path" .= specPath] + createDirectoryIfMissing True $ takeDirectory specPath + liftIO $ Yaml.encodeFile specPath $ stackSpecSpecBody stackSpec where templatePath = unStackTemplate $ stackSpecTemplate stackSpec - specPath = - FilePath.normalise - $ stackSpecSpecRoot stackSpec - stackSpecStackFile stackSpec + specPath = stackSpecFilePath stackSpec readStackSpec :: (MonadIO m, MonadReader env m, HasConfig env) @@ -163,23 +184,21 @@ 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] -> [Tag] -> m (Either Text (Maybe ChangeSet)) -createChangeSet spec parameters tags = awsCloudFormationCreateChangeSet - (stackSpecStackName spec) - (stackSpecStackDescription spec) - (stackSpecTemplate spec) - (nubOrdOn (^. parameter_parameterKey) $ parameters <> stackSpecParameters spec - ) - (stackSpecCapabilities spec) - (nubOrdOn (^. tag_key) $ tags <> stackSpecTags spec) +createChangeSet spec parameters tags = + awsCloudFormationCreateChangeSet + (stackSpecStackName spec) + (stackSpecStackDescription spec) + (stackSpecTemplate spec) + (nubOrdOn (^. parameter_parameterKey) $ parameters <> stackSpecParameters spec) + (stackSpecCapabilities spec) + (nubOrdOn (^. tag_key) $ tags <> stackSpecTags spec) sortStackSpecs :: [StackSpec] -> [StackSpec] sortStackSpecs = sortByDependencies stackSpecStackName stackSpecDepends diff --git a/src/Stackctl/StackSpecPath.hs b/src/Stackctl/StackSpecPath.hs index bc1c970..8500b61 100644 --- a/src/Stackctl/StackSpecPath.hs +++ b/src/Stackctl/StackSpecPath.hs @@ -3,7 +3,7 @@ module Stackctl.StackSpecPath ( StackSpecPath - -- * Fields + -- * Fields , stackSpecPathAccountId , stackSpecPathAccountName , stackSpecPathRegion @@ -11,7 +11,7 @@ module Stackctl.StackSpecPath , stackSpecPathBasePath , stackSpecPathFilePath - -- * Construction + -- * Construction , stackSpecPath , stackSpecPathFromFilePath ) where @@ -33,12 +33,13 @@ data StackSpecPath = StackSpecPath deriving stock (Eq, Show) stackSpecPath :: AwsScope -> StackName -> FilePath -> StackSpecPath -stackSpecPath sspAwsScope@AwsScope {..} sspStackName sspPath = StackSpecPath - { sspAwsScope - , sspAccountPathPart - , sspStackName - , sspPath - } +stackSpecPath sspAwsScope@AwsScope {..} sspStackName sspPath = + StackSpecPath + { sspAwsScope + , sspAccountPathPart + , sspStackName + , sspPath + } where sspAccountPathPart = unpack $ unAccountId awsAccountId <> "." <> awsAccountName @@ -71,7 +72,8 @@ stackSpecPathFilePath path = stackSpecPathFromFilePath :: AwsScope - -> FilePath -- ^ Must be relative, @stacks/@ + -> FilePath + -- ^ Must be relative, @stacks/@ -> Either String StackSpecPath stackSpecPathFromFilePath awsScope@AwsScope {..} path = case splitDirectories path of @@ -81,30 +83,30 @@ 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) - $ T.stripSuffix ".yaml" - $ T.intercalate "-" - $ map pack rest - - Right $ StackSpecPath - { sspAwsScope = awsScope { awsAccountName = accountName } - , sspAccountPathPart = pathAccount - , sspStackName = stackName - , sspPath = joinPath rest - } - + $ T.stripSuffix ".yaml" + $ T.intercalate "-" + $ map pack rest + + Right + $ StackSpecPath + { sspAwsScope = awsScope {awsAccountName = accountName} + , sspAccountPathPart = pathAccount + , sspStackName = stackName + , sspPath = joinPath rest + } _ -> Left $ "Path is not stacks/././.: " <> path -- | Handle @{account-name}.{account-id}@ or @{account-id}.{account-name}@ @@ -115,5 +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 - where isAccountId x = T.length x == 12 && T.all isDigit x + <> 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 ea10960..aebc651 100644 --- a/src/Stackctl/StackSpecYaml.hs +++ b/src/Stackctl/StackSpecYaml.hs @@ -4,32 +4,41 @@ -- Template: -- -- Depends: --- - +-- - -- -- Parameters: --- - ParameterKey: --- ParameterValue: +-- - ParameterKey: +-- ParameterValue: +-- +-- # Or +-- : -- -- Capabilities: --- - +-- - -- -- Tags: --- - Key: --- Value: --- @ +-- - Key: +-- Value: -- +-- # Or +-- : +-- @ module Stackctl.StackSpecYaml - ( StackSpecYaml(..) + ( StackSpecYaml (..) , ParametersYaml , parametersYaml , unParametersYaml , ParameterYaml , parameterYaml + , mkParameterYaml , unParameterYaml + , ParameterValue + , parameterValueFromText + , parameterValueTemplate , TagsYaml , tagsYaml , unTagsYaml - , TagYaml(..) + , TagYaml (..) ) where import Stackctl.Prelude @@ -40,10 +49,11 @@ 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.Monoid (Last(..)) +import Data.List.Extra (dropSuffix) +import Data.Monoid (Last (..)) import qualified Data.Text as T -import Stackctl.Action import Stackctl.AWS +import Stackctl.Action data StackSpecYaml = StackSpecYaml { ssyDescription :: Maybe StackDescription @@ -75,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 @@ -86,7 +95,7 @@ instance FromJSON ParametersYaml where -- error messages will include "Parameters.{k}". See specs for an example. let parseKey k = ParameterYaml k <$> o .: k ParametersYaml <$> traverse parseKey (KeyMap.keys o) - v@Array{} -> ParametersYaml <$> parseJSON v + v@Array {} -> ParametersYaml <$> parseJSON v v -> typeMismatch err v where err = @@ -98,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 @@ -115,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] @@ -155,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) @@ -164,20 +222,20 @@ instance Semigroup TagsYaml where instance FromJSON TagsYaml where parseJSON = \case Object o -> do - let - parseKey k = do - t <- newTag (Key.toText k) <$> o .: k - pure $ TagYaml t + let parseKey k = do + t <- newTag (Key.toText k) <$> o .: k + pure $ TagYaml t TagsYaml <$> traverse parseKey (KeyMap.keys o) - v@Array{} -> TagsYaml <$> parseJSON v + v@Array {} -> TagsYaml <$> parseJSON v v -> typeMismatch err v - where err = "Object or list of {Key, Value} Objects" + where + err = "Object or list of {Key, Value} Objects" 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 @@ -193,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 928c9e4..ec7d33c 100644 --- a/src/Stackctl/Subcommand.hs +++ b/src/Stackctl/Subcommand.hs @@ -1,5 +1,5 @@ module Stackctl.Subcommand - ( Subcommand(..) + ( Subcommand (..) , subcommand , runSubcommand , runSubcommand' @@ -10,6 +10,10 @@ 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 import Stackctl.ColorOption import Stackctl.Options @@ -41,12 +45,16 @@ runSubcommand' -> Mod CommandFields (options -> IO a) -> IO a runSubcommand' title parseEnv parseCLI sp = do - (options, act) <- applyEnv - <$> Env.parse (Env.header $ unpack title) parseEnv - <*> execParser (withInfo title $ (,) <$> parseCLI <*> subparser sp) + (options, act) <- + applyEnv + <$> Env.parse (Env.header $ unpack title) parseEnv + <*> customExecParser + (prefs helpShowGlobals) + (withInfo title $ (,) <$> parseCLI <*> subparser sp) act options - where applyEnv env = first (env <>) + where + applyEnv env = first (env <>) -- | Use this in the 'run' member of a 'Subcommand' that wants 'AppT' -- @@ -59,14 +67,30 @@ runSubcommand' title parseEnv parseCLI sp = do -- runFoo :: (MonadReader env m, HasAws env) => FooOptions -> m () -- runFoo = undefined -- @ --- runAppSubcommand - :: (HasColorOption options, HasVerboseOption options) + :: ( HasColorOption options + , HasVerboseOption options + , HasAutoSSOOption options + ) => (subOptions -> AppT (App options) IO a) -> subOptions -> options -> IO a -runAppSubcommand f subOptions options = runAppT options $ f subOptions +runAppSubcommand f subOptions options = + runAppT options + $ handlingServiceError + $ 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/TagOption.hs b/src/Stackctl/TagOption.hs index 5988137..0efb80e 100644 --- a/src/Stackctl/TagOption.hs +++ b/src/Stackctl/TagOption.hs @@ -9,12 +9,14 @@ import Options.Applicative import Stackctl.AWS.CloudFormation (Tag, newTag) tagOption :: Parser Tag -tagOption = option (eitherReader readTag) $ mconcat - [ short 't' - , long "tag" - , metavar "KEY=[VALUE]" - , help "Override the given Tag for this operation" - ] +tagOption = + option (eitherReader readTag) + $ mconcat + [ short 't' + , long "tag" + , metavar "KEY=[VALUE]" + , help "Override the given Tag for this operation" + ] readTag :: String -> Either String Tag readTag s = case T.breakOn "=" t of @@ -22,4 +24,5 @@ readTag s = case T.breakOn "=" t of (k, _) | T.null k -> Left $ "Empty key (" <> s <> ")" (k, "=") -> Right $ newTag k "" (k, v) -> Right $ newTag k $ T.drop 1 v - where t = pack s + where + t = pack s diff --git a/src/Stackctl/VerboseOption.hs b/src/Stackctl/VerboseOption.hs index 56443b5..1a6decf 100644 --- a/src/Stackctl/VerboseOption.hs +++ b/src/Stackctl/VerboseOption.hs @@ -1,12 +1,13 @@ module Stackctl.VerboseOption ( Verbosity , verbositySetLogLevels - , HasVerboseOption(..) + , HasVerboseOption (..) , verboseOption ) where import Stackctl.Prelude +import Blammo.Logging.LogSettings import Blammo.Logging.LogSettings.LogLevels import Options.Applicative @@ -31,8 +32,12 @@ instance HasVerboseOption Verbosity where verboseOptionL = id verboseOption :: Parser Verbosity -verboseOption = fmap Verbosity $ many $ flag' () $ mconcat - [ short 'v' - , long "verbose" - , help "Increase verbosity (can be passed multiple times)" - ] +verboseOption = + fmap Verbosity + $ many + $ flag' () + $ mconcat + [ short 'v' + , long "verbose" + , help "Increase verbosity (can be passed multiple times)" + ] 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/src/UnliftIO/Exception/Lens.hs b/src/UnliftIO/Exception/Lens.hs deleted file mode 100644 index da10b83..0000000 --- a/src/UnliftIO/Exception/Lens.hs +++ /dev/null @@ -1,33 +0,0 @@ --- | A copy of "Control.Exception.Lens" on 'MonadUnliftIO' --- --- And only the parts we use in this code-base --- -module UnliftIO.Exception.Lens - ( handling_ - , trying - ) where - -import Prelude - -import Control.Lens (Getting, preview) -import Control.Monad.IO.Unlift (MonadUnliftIO) -import Data.Monoid (First) -import UnliftIO.Exception (SomeException, catchJust, tryJust) - -catching_ - :: MonadUnliftIO m => Getting (First a) SomeException a -> m r -> m r -> m r -catching_ l a b = catchJust (preview l) a (const b) -{-# INLINE catching_ #-} - -handling_ - :: MonadUnliftIO m => Getting (First a) SomeException a -> m r -> m r -> m r -handling_ l = flip (catching_ l) -{-# INLINE handling_ #-} - -trying - :: MonadUnliftIO m - => Getting (First a) SomeException a - -> m r - -> m (Either a r) -trying l = tryJust (preview l) -{-# INLINE trying #-} diff --git a/stack.yaml b/stack.yaml index ccd5b42..7597741 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,18 +1,17 @@ -resolver: lts-20.4 +resolver: lts-23.7 extra-deps: - - Blammo-1.1.1.1 - - cfn-flip-0.1.0.3 - - github: brendanhay/amazonka - commit: f73a957d05f64863e867cf39d0db260718f0fadd # main, as of SSO support + commit: cf174ae30fa914439f4d1fa1c3dbd9b69b935141 # main + #1029 subdirs: - lib/amazonka - lib/amazonka-core - - lib/services/amazonka-certificatemanager - lib/services/amazonka-cloudformation - lib/services/amazonka-ec2 - - lib/services/amazonka-ecr - lib/services/amazonka-lambda - lib/services/amazonka-sso - lib/services/amazonka-sts + + - 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 5085e31..60debb7 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -1,143 +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.1.1@sha256:2a40212b058e49f0449cd81a786a216a97ec1e4139870e571560312b50532430,4045 - pantry-tree: - sha256: 2dc64fe1800fbb344ae8345762dc814e6014147671fe0749581b2fe1c6ed9a92 - size: 1490 - original: - hackage: Blammo-1.1.1.1 -- completed: - hackage: cfn-flip-0.1.0.3@sha256:8737882d818d74b29d3b1791a4df4dc89995870312374989c47c29352ea503ec,5615 - pantry-tree: - sha256: 715102dfcca7053390eda5be0504485fb93b8b84226fe373a6e62d297090d49b - size: 3139 - original: - hackage: cfn-flip-0.1.0.3 - completed: name: amazonka pantry-tree: - sha256: 0257a27c3332e400abc0f4a38f7a875c4a2a04b03ac342d7481e19d9d5665040 - size: 1257 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: 6a4df9d7ef86e2ecffb44ef528844a97b2339e6a6703bd304a605341c6db9842 + size: 1529 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/amazonka - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/amazonka - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-core pantry-tree: - sha256: 2eadbad33f65f20781409c4de9faee04e7e4baa92906db696b78689f53de0a83 - size: 3117 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: fbd62e7df53cf2f5b944a99d0ef024c77a10e3bde2e519fb95bcb262aed29fc4 + size: 3222 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/amazonka-core - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/amazonka-core - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz -- completed: - name: amazonka-certificatemanager - pantry-tree: - sha256: 86c39ebac8e40030c05048385c1153deb124193e49bb2651c38bd1232bbd3fff - size: 7063 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 - subdir: lib/services/amazonka-certificatemanager - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz - version: '2.0' - original: - subdir: lib/services/amazonka-certificatemanager - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-cloudformation pantry-tree: - sha256: a9f557fdf3f3d5f960a28921465609e338ce702e1ecdf6e559e01efdccb364a6 - size: 25784 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: 0cacf4a7cae64a63855bf1cce2b947084e4353f46756f36e65dd351087a7f63e + size: 27257 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/services/amazonka-cloudformation - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/services/amazonka-cloudformation - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-ec2 pantry-tree: - sha256: 29c4666aa6cd81a371cdef208199f45a99d9fdef31f3ff9450c1762a64dd60d0 - size: 190150 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: dc171159485af8773de82731ee1cf1df56acdf1e6c6fe76864dcf24d5d6b7e85 + size: 234434 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/services/amazonka-ec2 - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/services/amazonka-ec2 - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz -- completed: - name: amazonka-ecr - pantry-tree: - sha256: 8c8b2a242ac973b0205916de20d8066cf3820acd47d98d6ec6df9182f6b31966 - size: 12161 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 - subdir: lib/services/amazonka-ecr - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz - version: '2.0' - original: - subdir: lib/services/amazonka-ecr - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-lambda pantry-tree: - sha256: 7e6feb0f8af0a9f6ce20db04d69a0e7b92838f27d17f65f0cd1a3c87b6a6331e - size: 19117 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: 249b7557046e64a2fae70acd3e7d7e20422ef7b3db49bf01d56c619e1d0a4470 + size: 21343 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/services/amazonka-lambda - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/services/amazonka-lambda - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-sso pantry-tree: - sha256: f11babeeaf0481ae68134ced86e9d1d9396d1beb7bd70e0a1e6b77bc4148a192 - size: 1869 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: c4575f7b7cf61c3de65e43d0d77a14dfa14c47ebff5f1a3dcd2f6e1313aaaf0a + size: 1817 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/services/amazonka-sso - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/services/amazonka-sso - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz - completed: name: amazonka-sts pantry-tree: - sha256: 64ed22eaaea868b32cf56f162d1bd7332b048d8f2ea073c4e9827ed08e71cc70 - size: 2932 - sha256: 14aeaa9f748f7ac03683e8a8126760ed16aa82152404a96c0333b582444cd381 - size: 27775608 + sha256: e0cb89013938230d257a2e546a78170dfdb6d507f37c6cb763a6cdf6290edb66 + size: 2880 + sha256: bd186dab03b64bc3f4e61adafaa8b66df7c8aaff789bfe98172dedddad59e6dc + size: 34855496 subdir: lib/services/amazonka-sts - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz version: '2.0' original: subdir: lib/services/amazonka-sts - url: https://github.com/brendanhay/amazonka/archive/f73a957d05f64863e867cf39d0db260718f0fadd.tar.gz + url: https://github.com/brendanhay/amazonka/archive/cf174ae30fa914439f4d1fa1c3dbd9b69b935141.tar.gz +- completed: + hackage: amazonka-mtl-0.1.1.0@sha256:90b45a950c0e398b0e48d1447766f331c2ac3d5a72e15be2bf0be3b3c56159c3,6572 + pantry-tree: + sha256: c85849d4d5caa36a3597323185d7593cb624cadee6e4f05219d3ccd498a7b270 + size: 965 + original: + hackage: amazonka-mtl-0.1.1.0 +- completed: + hackage: cfn-flip-0.1.0.3@sha256:40f33714827c35a9fd3cebde06002f54448c3efa34252efbe5e48445065f2620,5934 + pantry-tree: + sha256: 4d5fc2c97d269deb4a34432db02a725850392542d1852a11f10c76832611e2c8 + size: 3139 + original: + hackage: cfn-flip-0.1.0.3 +- completed: + hackage: microlens-pro-0.2.0.2@sha256:2fd14b7f87d6aa76700dabf65fcdda835aa329a4fdd8a44eebdf399e798af7ab,3377 + pantry-tree: + sha256: be8ac1093c45ec46d640c56c06d8826d364ad1243d601e731e37581e8579e9c3 + size: 430 + original: + 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 1ca1336..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.1. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack name: stackctl -version: 1.3.0.0 +version: 1.7.2.0 description: Please see homepage: https://github.com/freckle/stackctl#readme bug-reports: https://github.com/freckle/stackctl/issues @@ -26,6 +26,7 @@ source-repository head library exposed-modules: Stackctl.Action + Stackctl.AutoSSO Stackctl.AWS Stackctl.AWS.CloudFormation Stackctl.AWS.Core @@ -34,6 +35,7 @@ library Stackctl.AWS.Orphans Stackctl.AWS.Scope Stackctl.AWS.STS + Stackctl.CancelHandler Stackctl.CLI Stackctl.ColorOption Stackctl.Colors @@ -42,10 +44,12 @@ library Stackctl.Config.RequiredVersion Stackctl.DirectoryOption Stackctl.FilterOption + Stackctl.OneOrListOf Stackctl.Options Stackctl.ParameterOption Stackctl.Prelude Stackctl.Prompt + Stackctl.RemovedStack Stackctl.Sort Stackctl.Spec.Capture Stackctl.Spec.Cat @@ -54,6 +58,7 @@ library Stackctl.Spec.Deploy Stackctl.Spec.Discover Stackctl.Spec.Generate + Stackctl.Spec.List Stackctl.StackDescription Stackctl.StackSpec Stackctl.StackSpecPath @@ -62,7 +67,6 @@ library Stackctl.TagOption Stackctl.VerboseOption Stackctl.Version - UnliftIO.Exception.Lens other-modules: Paths_stackctl hs-source-dirs: @@ -93,19 +97,22 @@ library StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -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.1.1 + Blammo >=1.1.2.3 , Glob + , QuickCheck , aeson , aeson-casing , aeson-pretty - , amazonka - , amazonka-cloudformation - , amazonka-core - , amazonka-ec2 - , amazonka-lambda - , amazonka-sts + , amazonka >=2.0 + , amazonka-cloudformation >=2.0 + , amazonka-core >=2.0 + , amazonka-ec2 >=2.0 + , amazonka-lambda >=2.0 + , amazonka-mtl + , amazonka-sso >=2.0 + , amazonka-sts >=2.0 , base ==4.* , bytestring , cfn-flip >=0.1.0.3 @@ -121,13 +128,17 @@ library , monad-logger , mtl , optparse-applicative + , prettyprinter , resourcet , rio , semigroups , text + , text-metrics , time - , unliftio - , unliftio-core + , transformers + , typed-process + , unix + , unliftio >=0.2.25.0 , unordered-containers , uuid , yaml @@ -165,7 +176,7 @@ executable stackctl StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -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 @@ -176,12 +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 @@ -211,14 +230,29 @@ test-suite spec StandaloneDeriving TypeApplications TypeFamilies - ghc-options: -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/Spec.hs b/test/Spec.hs index 545b063..c5e6791 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -1,2 +1,2 @@ -{-# OPTIONS_GHC -fno-warn-missing-export-lists #-} {-# OPTIONS_GHC -F -pgmF hspec-discover #-} +{-# OPTIONS_GHC -fno-warn-missing-export-lists #-} 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/AWS/ScopeSpec.hs b/test/Stackctl/AWS/ScopeSpec.hs new file mode 100644 index 0000000..8dba6f6 --- /dev/null +++ b/test/Stackctl/AWS/ScopeSpec.hs @@ -0,0 +1,56 @@ +module Stackctl.AWS.ScopeSpec + ( spec + ) where + +import Stackctl.Prelude + +import Stackctl.AWS.CloudFormation +import Stackctl.AWS.Core +import Stackctl.AWS.Scope +import Test.Hspec + +spec :: Spec +spec = do + describe "awsScopeSpecStackName" $ do + let scope = + AwsScope + { awsAccountId = AccountId "123" + , awsAccountName = "testing" + , awsRegion = "us-east-1" + } + + it "parses full paths to stacks in the current scope" $ do + awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo.yaml" + `shouldBe` Just (StackName "foo") + + it "parses name.account style too" $ do + awsScopeSpecStackName scope "stacks/testing.123/us-east-1/foo.yaml" + `shouldBe` Just (StackName "foo") + + it "handles sub-directories" $ do + awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo/bar.yaml" + `shouldBe` Just (StackName "foo-bar") + + it "handles mismatched name" $ do + awsScopeSpecStackName scope "stacks/123.x/us-east-1/foo.yaml" + `shouldBe` Just (StackName "foo") + + it "handles mismatched name in name.account style" $ do + awsScopeSpecStackName scope "stacks/x.123/us-east-1/foo.yaml" + `shouldBe` Just (StackName "foo") + + it "avoids wrong region" $ do + awsScopeSpecStackName scope "stacks/123.testing/us-east-2/foo.yaml" + `shouldBe` Nothing + + it "avoids arong account id" $ do + awsScopeSpecStackName scope "stacks/124.testing/us-east-1/foo.yaml" + `shouldBe` Nothing + + it "requires a stacks/ prefix" $ do + awsScopeSpecStackName scope "123.testing/us-east-1/foo.yaml" + `shouldBe` Nothing + + it "requires a .yaml suffix" $ do + awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo.yml" + `shouldBe` Nothing 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/Config/RequiredVersionSpec.hs b/test/Stackctl/Config/RequiredVersionSpec.hs index 1df5ffb..7cbfc85 100644 --- a/test/Stackctl/Config/RequiredVersionSpec.hs +++ b/test/Stackctl/Config/RequiredVersionSpec.hs @@ -4,6 +4,7 @@ module Stackctl.Config.RequiredVersionSpec import Stackctl.Prelude +import Data.Aeson (decode, encode) import Data.Version import Stackctl.Config.RequiredVersion import Test.Hspec @@ -11,6 +12,10 @@ import Test.QuickCheck spec :: Spec spec = do + describe "JSON" $ do + it "round-trips" $ property $ \rv -> do + decode (encode @RequiredVersion rv) `shouldBe` Just rv + describe "requiredVersionFromText" $ do it "parses with or without operator" $ do requiredVersionFromText "1.2.3-rc1" `shouldSatisfy` isRight @@ -27,13 +32,13 @@ spec = do it "compares exactly" $ prop (==) Nothing it "compares with = " $ prop (==) $ Just "=" + it "compares with ==" $ prop (==) $ Just "==" it "compares with < " $ prop (<) $ Just "<" it "compares with <=" $ prop (<=) $ Just "<=" it "compares with > " $ prop (>) $ Just ">" it "compares with >=" $ prop (>=) $ Just ">=" it "compares with =~" $ prop (=~) $ Just "=~" - describe "=~" $ do it "treats equal versions as satisfying" $ do makeVersion [1, 2, 3] =~ makeVersion [1, 2, 3] `shouldBe` True @@ -77,4 +82,5 @@ runRequiredVersion -> Either String Bool runRequiredVersion mOperator required current = (`isRequiredVersionSatisfied` current) <$> requiredVersionFromText rvText - where rvText = maybe "" (<> " ") mOperator <> pack (showVersion required) + where + rvText = maybe "" (<> " ") mOperator <> pack (showVersion required) diff --git a/test/Stackctl/ConfigSpec.hs b/test/Stackctl/ConfigSpec.hs index 3950f2e..90fd683 100644 --- a/test/Stackctl/ConfigSpec.hs +++ b/test/Stackctl/ConfigSpec.hs @@ -19,21 +19,20 @@ spec :: Spec spec = do describe "loadConfigFromBytes" $ do it "loads a valid config" $ do - let - result = loadConfigFromLines - [ "required_version: " <> BS8.pack (showVersion Paths.version) - , "defaults:" - , " parameters:" - , " Some: Parameter" - , " tags:" - , " Some: Tag" - ] + let result = + loadConfigFromLines + [ "required_version: " <> BS8.pack (showVersion Paths.version) + , "defaults:" + , " parameters:" + , " Some: Parameter" + , " tags:" + , " Some: Tag" + ] 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")]) @@ -42,27 +41,29 @@ spec = do describe "applyConfig" $ do it "defaults missing Tags" $ do let - specYaml = StackSpecYaml - { ssyDescription = Nothing - , ssyTemplate = "" - , ssyDepends = Nothing - , ssyActions = Nothing - , ssyParameters = Nothing - , ssyCapabilities = Nothing - , ssyTags = Just $ toTagsYaml [("Hi", "There"), ("Keep", "Me")] - } + specYaml = + StackSpecYaml + { ssyDescription = Nothing + , ssyTemplate = "" + , ssyDepends = Nothing + , ssyActions = Nothing + , ssyParameters = Nothing + , ssyCapabilities = Nothing + , ssyTags = Just $ toTagsYaml [("Hi", "There"), ("Keep", "Me")] + } 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")] + tags + `shouldBe` toTagsYaml + [("Hi", "There"), ("From", "Defaults"), ("Keep", "Me")] loadConfigFromLines :: MonadError ConfigError m => [ByteString] -> m Config loadConfigFromLines = loadConfigFromBytes . mconcat . map (<> "\n") diff --git a/test/Stackctl/FilterOptionSpec.hs b/test/Stackctl/FilterOptionSpec.hs index e905c5d..ed88652 100644 --- a/test/Stackctl/FilterOptionSpec.hs +++ b/test/Stackctl/FilterOptionSpec.hs @@ -91,8 +91,9 @@ spec = do describe "filterOptionFromPaths" $ do it "finds full paths (e.g. as output by generate)" $ do let - option = filterOptionFromPaths - $ pure "stacks/1234567890.test-account/us-east-1/stack.yaml" + option = + filterOptionFromPaths + $ pure "stacks/1234567890.test-account/us-east-1/stack.yaml" specs = [ toSpec "some-name" "stack.yaml" Nothing , toSpec "other-path" "other-stack.yaml" $ Just "x" @@ -102,26 +103,29 @@ spec = do `shouldMatchList` ["some-name"] toSpec :: Text -> FilePath -> Maybe FilePath -> StackSpec -toSpec name path mTemplate = flip runReader emptyConfig - $ buildStackSpec ".platform/specs" specPath specBody +toSpec name path mTemplate = + flip runReader emptyConfig + $ buildStackSpec ".platform/specs" specPath specBody where stackName = StackName name specPath = stackSpecPath scope stackName path - specBody = StackSpecYaml - { ssyDescription = Nothing - , ssyDepends = Nothing - , ssyActions = Nothing - , ssyTemplate = fromMaybe path mTemplate - , ssyParameters = Nothing - , ssyCapabilities = Nothing - , ssyTags = Nothing - } - - scope = AwsScope - { awsAccountId = AccountId "1234567890" - , awsAccountName = "test-account" - , awsRegion = Region' "us-east-1" - } + specBody = + StackSpecYaml + { ssyDescription = Nothing + , ssyDepends = Nothing + , ssyActions = Nothing + , ssyTemplate = fromMaybe path mTemplate + , ssyParameters = Nothing + , ssyCapabilities = Nothing + , ssyTags = Nothing + } + + scope = + AwsScope + { awsAccountId = AccountId "1234567890" + , awsAccountName = "test-account" + , awsRegion = Region' "us-east-1" + } specName :: StackSpec -> Text specName = unStackName . stackSpecStackName 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/StackDescriptionSpec.hs b/test/Stackctl/StackDescriptionSpec.hs index fa6d383..6f99346 100644 --- a/test/Stackctl/StackDescriptionSpec.hs +++ b/test/Stackctl/StackDescriptionSpec.hs @@ -26,8 +26,8 @@ spec = do it "does not clobber or duplicate an existing Description" $ do addStackDescription - aDescription - "Resources: []\nDescription: Existing description\n" + aDescription + "Resources: []\nDescription: Existing description\n" `shouldBe` "Resources: []\nDescription: Existing description\n" context "JSON" $ do @@ -37,6 +37,6 @@ spec = do it "does not clobber or duplicate an existing Description" $ do addStackDescription - aDescription - "{\"Resources\":[],\"Description\":\"Existing description\"}" + aDescription + "{\"Resources\":[],\"Description\":\"Existing description\"}" `shouldBe` "{\"Resources\":[],\"Description\":\"Existing description\"}" diff --git a/test/Stackctl/StackSpecSpec.hs b/test/Stackctl/StackSpecSpec.hs index a16b3cb..30105d5 100644 --- a/test/Stackctl/StackSpecSpec.hs +++ b/test/Stackctl/StackSpecSpec.hs @@ -16,38 +16,40 @@ spec :: Spec spec = do describe "sortStackSpecs" $ do it "orders dependencies before dependents" $ do - let - specs = - [ toSpec "app" ["roles", "iam", "networking"] - , toSpec "roles" ["iam"] - , toSpec "iam" [] - , toSpec "networking" [] - ] + let specs = + [ toSpec "app" ["roles", "iam", "networking"] + , toSpec "roles" ["iam"] + , toSpec "iam" [] + , toSpec "networking" [] + ] map specName (sortStackSpecs specs) `shouldBe` ["iam", "roles", "networking", "app"] toSpec :: Text -> [Text] -> StackSpec -toSpec name depends = flip runReader emptyConfig - $ buildStackSpec "." specPath specBody +toSpec name depends = + flip runReader emptyConfig + $ buildStackSpec "." specPath specBody where stackName = StackName name specPath = stackSpecPath scope stackName "a/b.yaml" - specBody = StackSpecYaml - { ssyDescription = Nothing - , ssyDepends = Just $ map StackName depends - , ssyActions = Nothing - , ssyTemplate = "" - , ssyParameters = Nothing - , ssyCapabilities = Nothing - , ssyTags = Nothing - } + specBody = + StackSpecYaml + { ssyDescription = Nothing + , ssyDepends = Just $ map StackName depends + , ssyActions = Nothing + , ssyTemplate = "" + , ssyParameters = Nothing + , ssyCapabilities = Nothing + , ssyTags = Nothing + } - scope = AwsScope - { awsAccountId = AccountId "" - , awsAccountName = "" - , awsRegion = Region' "" - } + scope = + AwsScope + { awsAccountId = AccountId "" + , awsAccountName = "" + , awsRegion = Region' "" + } specName :: StackSpec -> Text specName = unStackName . stackSpecStackName diff --git a/test/Stackctl/StackSpecYamlSpec.hs b/test/Stackctl/StackSpecYamlSpec.hs index 53a4e7b..1dd9f09 100644 --- a/test/Stackctl/StackSpecYamlSpec.hs +++ b/test/Stackctl/StackSpecYamlSpec.hs @@ -8,8 +8,8 @@ import Stackctl.Prelude import Data.Aeson import qualified Data.Yaml as Yaml -import Stackctl.Action import Stackctl.AWS +import Stackctl.Action import Stackctl.StackSpecYaml import Test.Hspec @@ -17,123 +17,113 @@ spec :: Spec spec = do describe "From/ToJSON" $ do it "round trips" $ do - let - yaml = StackSpecYaml - { ssyDescription = Just $ StackDescription "Testing Stack" - , ssyTemplate = "path/to/template.yaml" - , ssyDepends = Just [StackName "a-stack", StackName "another-stack"] - , ssyActions = Just - [newAction PostDeploy $ InvokeLambdaByName "a-lambda"] - , ssyParameters = Just $ parametersYaml $ mapMaybe - parameterYaml - [makeParameter "PKey" $ Just "PValue"] - , ssyCapabilities = Just [Capability_CAPABILITY_IAM] - , ssyTags = Just $ tagsYaml [TagYaml $ newTag "TKey" "TValue"] - } + let yaml = + StackSpecYaml + { ssyDescription = Just $ StackDescription "Testing Stack" + , ssyTemplate = "path/to/template.yaml" + , ssyDepends = Just [StackName "a-stack", StackName "another-stack"] + , ssyActions = + Just + [newAction PostDeploy [InvokeLambdaByName "a-lambda"]] + , ssyParameters = + Just + $ parametersYaml + $ mapMaybe + parameterYaml + [makeParameter "PKey" $ Just "PValue"] + , ssyCapabilities = Just [Capability_CAPABILITY_IAM] + , ssyTags = Just $ tagsYaml [TagYaml $ newTag "TKey" "TValue"] + } eitherDecode (encode yaml) `shouldBe` Right yaml describe "decoding Yaml" $ do it "reads String parameters" $ do - StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - ParameterKey: Foo\n" - , " ParameterValue: Bar\n" - ] - - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + [ "Template: foo.yaml\n" + , "Parameters:\n" + , " - ParameterKey: Foo\n" + , " ParameterValue: Bar\n" + ] + + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Foo" param ^. parameter_parameterValue `shouldBe` Just "Bar" it "reads Number parameters without decimals" $ do - StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - ParameterKey: Port\n" - , " ParameterValue: 80\n" - ] - - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + [ "Template: foo.yaml\n" + , "Parameters:\n" + , " - ParameterKey: Port\n" + , " ParameterValue: 80\n" + ] + + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Port" param ^. parameter_parameterValue `shouldBe` Just "80" it "reads Number parameters with decimals" $ do - StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - ParameterKey: Pie\n" - , " ParameterValue: 3.14\n" - ] - - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + [ "Template: foo.yaml\n" + , "Parameters:\n" + , " - ParameterKey: Pie\n" + , " ParameterValue: 3.14\n" + ] + + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters 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 $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - ParameterKey: Foo\n" - , " ParameterValue: null\n" - ] - - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + [ "Template: foo.yaml\n" + , "Parameters:\n" + , " - ParameterKey: Foo\n" + , " ParameterValue: null\n" + ] + + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Foo" param ^. parameter_parameterValue `shouldBe` Nothing it "handles missing Value" $ do - StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat - ["Template: foo.yaml\n", "Parameters:\n", " - ParameterKey: Foo\n"] + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + ["Template: foo.yaml\n", "Parameters:\n", " - ParameterKey: Foo\n"] - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Foo" param ^. parameter_parameterValue `shouldBe` Nothing it "also accepts CloudGenesis formatted values" $ do - StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat - [ "Template: foo.yaml\n" - , "Parameters:\n" - , " - Name: Foo\n" - , " Value: Bar\n" - ] - - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat + [ "Template: foo.yaml\n" + , "Parameters:\n" + , " - Name: Foo\n" + , " Value: Bar\n" + ] + + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Foo" param ^. parameter_parameterValue `shouldBe` Just "Bar" it "also accepts objects" $ do - StackSpecYaml {..} <- Yaml.decodeThrow - $ mconcat ["Template: foo.yaml\n", "Parameters:\n", " Foo: Bar\n"] + StackSpecYaml {..} <- + Yaml.decodeThrow + $ mconcat ["Template: foo.yaml\n", "Parameters:\n", " Foo: Bar\n"] - let - Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters + let Just [param] = map unParameterYaml . unParametersYaml <$> ssyParameters param ^. parameter_parameterKey `shouldBe` Just "Foo" param ^. parameter_parameterValue `shouldBe` Just "Bar" @@ -141,18 +131,20 @@ spec = do it "has overriding Semigroup semantics" $ do let a = parametersYaml [] - b = parametersYaml - $ catMaybes [parameterYaml $ makeParameter "Key" (Just "B")] - c = parametersYaml - $ catMaybes [parameterYaml $ makeParameter "Key" (Just "C")] - d = parametersYaml - $ catMaybes [parameterYaml $ makeParameter "Key" Nothing] + b = + parametersYaml + $ catMaybes [parameterYaml $ makeParameter "Key" (Just "B")] + c = + parametersYaml + $ catMaybes [parameterYaml $ makeParameter "Key" (Just "C")] + d = + parametersYaml + $ catMaybes [parameterYaml $ makeParameter "Key" Nothing] a <> b `shouldBe` b -- keeps keys in B b <> c `shouldBe` c -- C overrides B (Last) c <> d `shouldBe` c -- C overrides D (Just) d <> c `shouldBe` c -- C overrides D (Just) - describe "TagsYaml" $ do it "has overriding Semigroup semantics" $ do let 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