packages feed

stackctl (empty) → 1.1.0.0

raw patch · 42 files changed

+3309/−0 lines, 42 filesdep +Blammodep +Globdep +aeson

Dependencies added: Blammo, Glob, aeson, aeson-casing, aeson-pretty, amazonka, amazonka-cloudformation, amazonka-core, amazonka-ec2, amazonka-lambda, amazonka-sts, base, bytestring, cfn-flip, conduit, containers, errors, exceptions, extra, fast-logger, filepath, hspec, lens, lens-aeson, monad-logger, optparse-applicative, resourcet, rio, stackctl, text, time, unliftio, unliftio-core, unordered-containers, uuid, yaml

Files

+ CHANGELOG.md view
@@ -0,0 +1,48 @@+## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.1.0.0...main)++## [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
+ LICENSE view
@@ -0,0 +1,21 @@+The MIT License (MIT)++Copyright (c) 2022 Renaissance Learning Inc++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,88 @@+# Stackctl++Manage CloudFormation Stacks through specifications.++## About++`stackctl` is a command-line tool for working with [Stack Specifications][spec]. A Stack+Specification is a file-system format for describing deployed (or+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++This project also contains a Haskell library for doing the same.++## Install++### Binary install++Go to the [latest release][latest] and download the `.tar.gz` asset appropriate+for your OS. Navigate to the directory containing the downloaded file and run:++```console+tar xvf stackctl-*.tar.gz && cd stackctl+```++[latest]: https://github.com/freckle/stackctl/releases/latest++#### Global installation++Install into `/usr/local/`, with appropriate permissions:++```console+sudo make install+```++#### User installation++Set `PREFIX` to base the installation in a directory of your choosing:++```console+make install PREFIX=$HOME/.local+```++## Usage++Once installed, see:++- `stackctl --help`,+- `stackctl <command> --help`,+- `man 1 stackctl`, or+- `man 1 stackctl <command>`++The man pages are also available [in-repository](./doc), but contain+documentation as of `main`, and not your installed version.++## Relationship to CloudGenesis++[CloudGenesis][] is a project that also takes a directory of Stack+Specifications and deploys them when changed. Its on-disk format inspired ours+and, in fact, directories built for CloudGenesis can be managed by `stackctl`+(not necessarily the other way around).++[cloudgenesis]: https://github.com/LifeWay/CloudGenesis++The key differences are:++- CloudGenesis supplies AWS CodeBuild tooling for handling changes to your+  GitOps repository; Stackctl expects you to implement a GitHub Action that+  installs and executes `stackctl` commands as appropriate++  This makes Stackctl better if you need or want to also run the same tooling in+  a local context, but it makes CloudGenesis better if you need or want this+  activity to remain within the boundaries of your AWS VPC.++- CloudGenesis reacts to file-change events in S3, which only happens when you+  synchronize from `main`; Stackctl can run on any branch and easily be scoped+  to files changed in the PR or push.++  This enables Stackctl features like commenting with ChangeSet details on PRs,+  which are not possible in CloudGenesis as it's currently implemented.++- Stackctl adds the `Depends` key, for ordering multi-Stack processing++---++[CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE)
+ app/Main.hs view
@@ -0,0 +1,17 @@+module Main+  ( main+  ) where++import Stackctl.Prelude++import qualified Stackctl.Commands as Commands+import Stackctl.Subcommand++main :: IO ()+main =+  runSubcommand+    $ subcommand Commands.cat+    <> subcommand Commands.capture+    <> subcommand Commands.changes+    <> subcommand Commands.deploy+    <> subcommand Commands.version
+ src/Stackctl/AWS.hs view
@@ -0,0 +1,8 @@+module Stackctl.AWS+  ( module X+  ) where++import Stackctl.AWS.CloudFormation as X+import Stackctl.AWS.Core as X+import Stackctl.AWS.EC2 as X+import Stackctl.AWS.STS as X
+ src/Stackctl/AWS/CloudFormation.hs view
@@ -0,0 +1,445 @@+module Stackctl.AWS.CloudFormation+  (+  -- * Stacks+    Stack(..)+  , stackIsRollbackComplete+  , StackId(..)+  , StackName(..)+  , StackEvent(..)+  , ResourceStatus(..)+  , stackEvent_eventId+  , stackEvent_logicalResourceId+  , stackEvent_resourceStatus+  , stackEvent_resourceStatusReason+  , stackEvent_timestamp+  , StackTemplate(..)+  , StackDeployResult(..)+  , prettyStackDeployResult+  , StackDeleteResult(..)+  , prettyStackDeleteResult+  , Parameter+  , parameter_parameterKey+  , parameter_parameterValue+  , newParameter+  , makeParameter+  , readParameter+  , Capability(..)+  , Tag+  , newTag+  , tag_key+  , tag_value+  , Output+  , output_outputKey+  , output_outputValue+  , awsCloudFormationDescribeStack+  , awsCloudFormationDescribeStackMaybe+  , awsCloudFormationDescribeStackOutputs+  , awsCloudFormationDescribeStackEvents+  , awsCloudFormationGetMostRecentStackEventId+  , awsCloudFormationDeleteStack+  , awsCloudFormationWait+  , awsCloudFormationGetTemplate++  -- * ChangeSets+  , ChangeSet(..)+  , changeSetJSON+  , ChangeSetId(..)+  , ChangeSetName(..)+  , Change(..)+  , ResourceChange(..)+  , Replacement(..)+  , ChangeAction(..)+  , ResourceAttribute(..)+  , ResourceChangeDetail(..)+  , ChangeSource(..)+  , ResourceTargetDefinition(..)+  , RequiresRecreation(..)+  , awsCloudFormationCreateChangeSet+  , awsCloudFormationExecuteChangeSet+  , awsCloudFormationDeleteAllChangeSets+  ) where++import Stackctl.Prelude++import Amazonka.CloudFormation.CreateChangeSet hiding (id)+import Amazonka.CloudFormation.DeleteChangeSet+import Amazonka.CloudFormation.DeleteStack+import Amazonka.CloudFormation.DescribeChangeSet+import Amazonka.CloudFormation.DescribeStackEvents+import Amazonka.CloudFormation.DescribeStacks+import Amazonka.CloudFormation.ExecuteChangeSet+import Amazonka.CloudFormation.GetTemplate+import Amazonka.CloudFormation.ListChangeSets+import Amazonka.CloudFormation.Types+import qualified Amazonka.CloudFormation.Types.ChangeSetSummary as Summary+import Amazonka.CloudFormation.Waiters+import Amazonka.Core+  ( AsError+  , ServiceError+  , _MatchServiceError+  , _ServiceError+  , hasStatus+  , serviceCode+  , serviceMessage+  )+import Amazonka.Waiter (Accept(..))+import Conduit+import Control.Lens ((?~))+import Data.Aeson+import Data.Aeson.Encode.Pretty+import qualified Data.ByteString.Lazy as BSL+import Data.Monoid (First)+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 UnliftIO.Exception.Lens (handling_, trying)++newtype StackId = StackId+  { unStackId :: Text+  }+  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)++newtype StackName = StackName+  { unStackName :: Text+  }+  deriving newtype (Eq, Ord, Show, FromJSON, ToJSON)++newtype StackTemplate = StackTemplate+  { unStackTemplate :: FilePath+  }+  deriving stock (Eq, Show)+  deriving newtype FromJSON++data StackDeployResult+  = StackCreateSuccess+  | StackCreateFailure Bool+  | StackUpdateSuccess+  | StackUpdateFailure Bool+  deriving stock Show++prettyStackDeployResult :: StackDeployResult -> Text+prettyStackDeployResult = \case+  StackCreateSuccess -> "Created Stack successfully"+  StackCreateFailure{} -> "Failed to create Stack"+  StackUpdateSuccess -> "Updated Stack successfully"+  StackUpdateFailure{} -> "Failed to update Stack"++stackCreateResult :: Accept -> StackDeployResult+stackCreateResult = \case+  AcceptSuccess -> StackCreateSuccess+  AcceptFailure -> StackCreateFailure False+  AcceptRetry -> StackCreateFailure True++stackUpdateResult :: Accept -> StackDeployResult+stackUpdateResult = \case+  AcceptSuccess -> StackUpdateSuccess+  AcceptFailure -> StackUpdateFailure False+  AcceptRetry -> StackUpdateFailure True++data StackDeleteResult+  = StackDeleteSuccess+  | StackDeleteFailure Bool++prettyStackDeleteResult :: StackDeleteResult -> Text+prettyStackDeleteResult = \case+  StackDeleteSuccess -> "Deleted Stack successfully"+  StackDeleteFailure{} -> "Failed to delete Stack"++stackDeleteResult :: Accept -> StackDeleteResult+stackDeleteResult = \case+  AcceptSuccess -> StackDeleteSuccess+  AcceptFailure -> StackDeleteFailure False+  AcceptRetry -> StackDeleteFailure True++-- | @stackctl-{timestamp}-{uuid}@+newChangeSetName :: MonadIO m => m ChangeSetName+newChangeSetName = liftIO $ do+  timestamp <- formatTime defaultTimeLocale "%Y%m%d%H%M" <$> getCurrentTime+  uuid <- UUID.toString <$> UUID.nextRandom+  let parts = ["stackctl", timestamp, uuid]+  pure $ ChangeSetName $ T.intercalate "-" $ map pack parts++awsCloudFormationDescribeStack+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => StackName -> m Stack+awsCloudFormationDescribeStack stackName = do+  let+    req = newDescribeStacks & describeStacks_stackName ?~ unStackName stackName++  awsSimple "DescribeStack" req $ \resp -> do+    stacks <- resp ^. describeStacksResponse_stacks+    listToMaybe stacks++awsCloudFormationDescribeStackMaybe+  :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env)+  => 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++awsCloudFormationDescribeStackOutputs+  :: (MonadResource m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> m [Output]+awsCloudFormationDescribeStackOutputs stackName = do+  stack <- awsCloudFormationDescribeStack stackName+  pure $ fromMaybe [] $ outputs stack++awsCloudFormationDescribeStackEvents+  :: (MonadResource m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> Maybe Text -- ^ Last-seen Id+  -> m [StackEvent]+awsCloudFormationDescribeStackEvents stackName mLastId = do+  let+    req =+      newDescribeStackEvents+        & describeStackEvents_stackName+        ?~ unStackName stackName++  runConduit+    $ awsPaginate req+    .| mapC (fromMaybe [] . (^. describeStackEventsResponse_stackEvents))+    .| concatC+    .| takeWhileC (\e -> Just (e ^. stackEvent_eventId) /= mLastId)+    .| sinkList++awsCloudFormationGetMostRecentStackEventId+  :: (MonadResource m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> m (Maybe Text)+awsCloudFormationGetMostRecentStackEventId stackName = do+  let+    req =+      newDescribeStackEvents+        & describeStackEvents_stackName+        ?~ unStackName stackName++    -- Events are returned most-recent first, so "last" is "first" here+    getFirstEventId :: [StackEvent] -> Maybe Text+    getFirstEventId = \case+      [] -> Nothing+      (e : _) -> Just $ e ^. stackEvent_eventId++  awsSimple "DescribeStackEvents" req+    $ pure+    . getFirstEventId+    . fromMaybe []+    . (^. describeStackEventsResponse_stackEvents)++awsCloudFormationDeleteStack+  :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> m StackDeleteResult+awsCloudFormationDeleteStack stackName = do+  let+    deleteReq = newDeleteStack $ unStackName stackName+    describeReq =+      newDescribeStacks & describeStacks_stackName ?~ unStackName stackName++  awsSimple "DeleteStack" deleteReq $ const $ pure ()++  logDebug "Awaiting DeleteStack"+  stackDeleteResult <$> awsAwait newStackDeleteComplete describeReq++awsCloudFormationWait+  :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> m StackDeployResult+awsCloudFormationWait stackName = do+  either stackCreateResult stackUpdateResult <$> race+    (awsAwait newStackCreateComplete req)+    (awsAwait newStackUpdateComplete req)+ where+  req = newDescribeStacks & describeStacks_stackName ?~ unStackName stackName++awsCloudFormationGetTemplate+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => StackName -> m Value+awsCloudFormationGetTemplate stackName = do+  let+    req =+      newGetTemplate+        & (getTemplate_stackName ?~ unStackName stackName)+        . (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+    body <- resp ^. getTemplateResponse_templateBody+    pure $ decodeTemplateBody body++makeParameter :: Text -> Maybe Text -> Parameter+makeParameter k v =+  newParameter & (parameter_parameterKey ?~ k) . (parameter_parameterValue .~ v)++readParameter :: String -> Either String Parameter+readParameter x = case T.breakOn "=" $ pack x of+  (key, _) | T.null key -> invalid "empty KEY"+  (_, value) | T.null value -> invalid "empty VALUE"+  (_, "=") -> invalid "empty VALUE" -- Is supporting { k, Nothing } valuable?+  (pName, value) -> Right $ makeParameter pName $ Just $ T.drop 1 value+ where+  invalid msg = Left $ "Invalid format for parameter (KEY=VALUE): " <> msg++newtype ChangeSetId = ChangeSetId+  { unChangeSetId :: Text+  }+  deriving newtype (FromJSON, ToJSON)++newtype ChangeSetName = ChangeSetName+  { unChangeSetName :: Text+  }+  deriving newtype (FromJSON, ToJSON)++data ChangeSet = ChangeSet+  { csCreationTime :: UTCTime+  , csChanges :: Maybe [Change]+  , csChangeSetName :: ChangeSetName+  , csExecutionStatus :: ExecutionStatus+  , csChangeSetId :: ChangeSetId+  , csParameters :: Maybe [Parameter]+  , csStackId :: StackId+  , csCapabilities :: Maybe [Capability]+  , csTags :: Maybe [Tag]+  , csStackName :: StackName+  , csStatus :: ChangeSetStatus+  , csStatusReason :: Maybe Text+  , csResponse :: DescribeChangeSetResponse+  }++changeSetJSON :: ChangeSet -> Text+changeSetJSON = decodeUtf8 . BSL.toStrict . encodePretty . csResponse++changeSetFailed :: ChangeSet -> Bool+changeSetFailed = (== ChangeSetStatus_FAILED) . csStatus++awsCloudFormationCreateChangeSet+  :: ( MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsEnv env+     )+  => StackName+  -> StackTemplate+  -> [Parameter]+  -> [Capability]+  -> [Tag]+  -> m (Either Text (Maybe ChangeSet))+awsCloudFormationCreateChangeSet stackName stackTemplate parameters capabilities tags+  = fmap (first formatServiceError)+    $ trying (_ServiceError . hasStatus 400)+    $ do+        name <- newChangeSetName+        templateBody <- readFileUtf8 $ unStackTemplate stackTemplate++        mStack <- awsCloudFormationDescribeStackMaybe stackName+        let+          changeSetType = fromMaybe ChangeSetType_CREATE $ do+            stack <- mStack+            pure $ if stackIsAbandonedCreate stack+              then ChangeSetType_CREATE+              else ChangeSetType_UPDATE++        let+          req =+            newCreateChangeSet (unStackName stackName) (unChangeSetName name)+              & (createChangeSet_changeSetType ?~ changeSetType)+              . (createChangeSet_templateBody ?~ templateBody)+              . (createChangeSet_parameters ?~ parameters)+              . (createChangeSet_capabilities ?~ capabilities)+              . (createChangeSet_tags ?~ tags)++        logInfo "Creating changeset..."+        csId <- awsSimple "CreateChangeSet" req (^. createChangeSetResponse_id)++        logDebug "Awaiting CREATE_COMPLETE"+        void $ awsAwait newChangeSetCreateComplete $ newDescribeChangeSet csId++        logInfo "Retrieving changeset..."+        cs <- awsCloudFormationDescribeChangeSet $ ChangeSetId csId+        pure $ cs <$ guard (not $ changeSetFailed cs)++awsCloudFormationDescribeChangeSet+  :: (MonadResource m, MonadReader env m, HasAwsEnv env)+  => ChangeSetId+  -> m ChangeSet+awsCloudFormationDescribeChangeSet changeSetId = do+  let req = newDescribeChangeSet $ unChangeSetId changeSetId+  awsSimple "DescribeChangeSet" req $ \resp ->+    ChangeSet+      <$> (resp ^. describeChangeSetResponse_creationTime)+      <*> pure (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++awsCloudFormationExecuteChangeSet+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => ChangeSetId -> m ()+awsCloudFormationExecuteChangeSet changeSetId = do+  void $ awsSend $ newExecuteChangeSet $ unChangeSetId changeSetId++awsCloudFormationDeleteAllChangeSets+  :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env)+  => 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+         )++-- | Did we abandoned this Stack's first ever ChangeSet?+--+-- If you create a ChangeSet for a Stack that doesn't exist, but you don't+-- deploy it, then you attempt to create another ChangeSet, you need to use the+-- CREATE ChangeSetType still, or you'll get a "Stack does not exist" error.+--+-- This is despite the Stack very much existing, according to DescribeStacks,+-- with a creationTime, ARN, and everything.+--+-- 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)++stackIsRollbackComplete :: Stack -> Bool+stackIsRollbackComplete stack =+  stack ^. stack_stackStatus == StackStatus_ROLLBACK_COMPLETE++_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+  ]
+ src/Stackctl/AWS/Core.hs view
@@ -0,0 +1,114 @@+module Stackctl.AWS.Core+  (+  -- * AWS via 'MonadReader'+    AwsEnv+  , HasAwsEnv(..)+  , awsEnvDiscover+  , awsSimple+  , awsSend+  , awsPaginate+  , awsAwait++  -- * Modifiers on 'AwsEnv'+  , awsWithin++  -- * 'Amazonka' extensions+  , AccountId(..)++  -- * 'Amazonka'/'ResourceT' re-exports+  , Region(..)+  , FromText(..)+  , ToText(..)+  , MonadResource+  ) where++import Stackctl.Prelude++import Amazonka hiding (LogLevel(..))+import qualified Amazonka as AWS+import Conduit (ConduitM)+import Control.Monad.Logger (defaultLoc, toLogStr)+import Control.Monad.Trans.Resource (MonadResource)+import Stackctl.AWS.Orphans ()++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+  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+  -> (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"++awsSend+  :: (MonadResource m, MonadReader env m, HasAwsEnv env, AWSRequest a)+  => a+  -> m (AWSResponse a)+awsSend req = do+  AwsEnv env <- view awsEnvL+  send env req++awsPaginate+  :: (MonadResource m, MonadReader env m, HasAwsEnv env, AWSPager a)+  => 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)++newtype AccountId = AccountId+  { unAccountId :: Text+  }+  deriving newtype (Eq, Ord, ToJSON)
+ src/Stackctl/AWS/EC2.hs view
@@ -0,0 +1,19 @@+module Stackctl.AWS.EC2+  ( awsEc2DescribeFirstAvailabilityZoneRegionName+  ) where++import Stackctl.Prelude++import Amazonka.EC2.DescribeAvailabilityZones+import Amazonka.EC2.Types (AvailabilityZone(..))+import Stackctl.AWS.Core++awsEc2DescribeFirstAvailabilityZoneRegionName+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m Region+awsEc2DescribeFirstAvailabilityZoneRegionName = do+  let req = newDescribeAvailabilityZones+  awsSimple "DescribeAvailabilityZones" req $ \resp -> do+    azs <- resp ^. describeAvailabilityZonesResponse_availabilityZones+    az <- listToMaybe azs+    rn <- regionName az+    hush $ fromText rn
+ src/Stackctl/AWS/Lambda.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE MultiWayIf #-}++module Stackctl.AWS.Lambda+  ( LambdaInvokeResult(..)+  , LambdaError(..)+  , logLambdaInvocationResult+  , isLambdaInvocationSuccess+  , awsLambdaInvoke+  ) where++import Stackctl.Prelude hiding (trace)++import Amazonka.Lambda.Invoke+import Data.Aeson+import qualified Data.ByteString.Lazy as BSL+import Stackctl.AWS.Core++data LambdaInvokeResult+  = LambdaInvokeSuccess+  | LambdaInvokeError LambdaError (Maybe Text)+  | LambdaInvokeFailure Int (Maybe Text)+  deriving stock Show++logLambdaInvocationResult :: MonadLogger m => LambdaInvokeResult -> m ()+logLambdaInvocationResult = \case+  LambdaInvokeSuccess -> logInfo "LambdaInvokeSuccess"+  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+    ]++isLambdaInvocationSuccess :: LambdaInvokeResult -> Bool+isLambdaInvocationSuccess = \case+  LambdaInvokeSuccess -> True+  LambdaInvokeError{} -> False+  LambdaInvokeFailure{} -> False++data LambdaError = LambdaError+  { errorType :: Text+  , errorMessage :: Text+  , trace :: [Text]+  }+  deriving stock (Show, Generic)+  deriving anyclass (FromJSON, ToJSON)++awsLambdaInvoke+  :: ( MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsEnv env+     , ToJSON a+     )+  => Text+  -> a -- ^ Payload+  -> m LambdaInvokeResult+awsLambdaInvoke name payload = do+  logDebug $ "Invoking function" :# ["name" .= name]++  resp <- awsSend $ newInvoke name $ BSL.toStrict $ encode payload++  let+    status = resp ^. invokeResponse_statusCode+    mError = decode . BSL.fromStrict =<< resp ^. invokeResponse_payload+    mFunctionError = resp ^. invokeResponse_functionError++  logDebug+    $ "Function result"+    :# [ "name" .= name+       , "status" .= status+       , "error" .= mError+       , "functionError" .= mFunctionError+       ]++  pure $ if+    | statusIsUnsuccessful status -> LambdaInvokeFailure status mFunctionError+    | Just e <- mError            -> LambdaInvokeError e mFunctionError+    | otherwise                   -> LambdaInvokeSuccess++statusIsUnsuccessful :: Int -> Bool+statusIsUnsuccessful s = s < 200 || s >= 300
+ src/Stackctl/AWS/Orphans.hs view
@@ -0,0 +1,48 @@+{-# LANGUAGE UndecidableInstances #-}+{-# OPTIONS_GHC -Wno-orphans #-}++-- |+--+-- Orphans so we can get @'ToJSON' 'ChangeSet'@ without hand-writing a massive,+-- incomplete, and doomed-to-drift instance ourselves.+--+module Stackctl.AWS.Orphans+  () where++import Stackctl.Prelude++import Amazonka.CloudFormation.DescribeChangeSet+import Amazonka.CloudFormation.Types+import Data.Aeson+import GHC.Generics (Rep)++-- Makes it syntactally easier to do a bunch of these+newtype Generically a = Generically { unGenerically :: a }+instance+  ( Generic a+  , GToJSON' Value Zero (Rep a)+  , GToJSON' Encoding Zero (Rep a)+  ) => 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
+ src/Stackctl/AWS/STS.hs view
@@ -0,0 +1,14 @@+module Stackctl.AWS.STS+  ( awsGetCallerIdentityAccount+  ) where++import Stackctl.Prelude++import Amazonka.STS.GetCallerIdentity+import Stackctl.AWS.Core++awsGetCallerIdentityAccount+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m AccountId+awsGetCallerIdentityAccount = do+  awsSimple "GetCallerIdentity" newGetCallerIdentity $ \resp -> do+    AccountId <$> resp ^. getCallerIdentityResponse_account
+ src/Stackctl/AWS/Scope.hs view
@@ -0,0 +1,32 @@+module Stackctl.AWS.Scope+  ( AwsScope(..)+  , HasAwsScope(..)+  , fetchAwsScope+  ) where++import Stackctl.Prelude++import Stackctl.AWS+import System.Environment (lookupEnv)++data AwsScope = AwsScope+  { awsAccountId :: AccountId+  , awsAccountName :: Text+  , awsRegion :: Region+  }+  deriving stock Generic+  deriving anyclass ToJSON++class HasAwsScope env where+  awsScopeL :: Lens' env AwsScope++instance HasAwsScope AwsScope where+  awsScopeL = id++fetchAwsScope+  :: (MonadResource m, MonadReader env m, HasAwsEnv env) => m AwsScope+fetchAwsScope =+  AwsScope+    <$> awsGetCallerIdentityAccount+    <*> liftIO (maybe "unknown" pack <$> lookupEnv "AWS_PROFILE")+    <*> awsEc2DescribeFirstAvailabilityZoneRegionName
+ src/Stackctl/Action.hs view
@@ -0,0 +1,117 @@+{-# LANGUAGE NamedFieldPuns #-}++-- | Actions that can be performed on certain Stack management events+--+-- For example, to invoke a Lambda whose name is found in the deploying Stack's+-- outputs after it's been deployed:+--+-- @+-- Actions:+--   - on: PostDeploy+--     run:+--       InvokeLambdaByStackOutput: OnDeployFunction+-- @+--+module Stackctl.Action+  ( Action+  , newAction+  , ActionOn(..)+  , ActionRun(..)+  , runActions+  ) where++import Stackctl.Prelude hiding (on)++import Data.Aeson+import Data.List (find)+import Stackctl.AWS+import Stackctl.AWS.Lambda++data Action = Action+  { on :: ActionOn+  , run :: ActionRun+  }+  deriving stock Generic+  deriving anyclass (FromJSON, ToJSON)++newAction :: ActionOn -> ActionRun -> Action+newAction = Action++data ActionOn = PostDeploy+  deriving stock (Eq, Generic)++instance FromJSON ActionOn where+  parseJSON = withText "ActionOn" $ \case+    "PostDeploy" -> pure PostDeploy+    x ->+      fail $ "Invalid ActionOn: " <> show x <> ", must be one of [PostDeploy]"++instance ToJSON ActionOn where+  toJSON = \case+    PostDeploy -> toJSON @Text "PostDeploy"+  toEncoding = \case+    PostDeploy -> toEncoding @Text "PostDeploy"++data ActionRun+  = InvokeLambdaByStackOutput Text+  | InvokeLambdaByName Text++instance FromJSON ActionRun where+  parseJSON = withObject "ActionRun" $ \o -> asum+    [ InvokeLambdaByStackOutput <$> o .: "InvokeLambdaByStackOutput"+    , InvokeLambdaByStackOutput <$> o .: "InvokeLambdaByName"+    ]++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++data ActionFailure+  = NoSuchOutput StackName Text [Output]+  | InvokeLambdaFailure LambdaInvokeResult+  deriving stock Show+  deriving anyclass Exception++runActions+  :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> ActionOn+  -> [Action]+  -> m ()+runActions stackName on =+  traverse_ (runAction stackName) . filter (`shouldRunOn` on)++shouldRunOn :: Action -> ActionOn -> Bool+shouldRunOn Action { on } on' = on == on'++runAction+  :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env)+  => StackName+  -> Action+  -> m ()+runAction stackName Action { on, run } = do+  logInfo $ "Running action" :# ["on" .= on, "run" .= run]++  case run of+    InvokeLambdaByStackOutput outputName -> do+      outputs <- awsCloudFormationDescribeStackOutputs stackName+      case findOutputValue outputName outputs of+        Nothing -> throwIO $ NoSuchOutput stackName outputName outputs+        Just name -> invoke name+    InvokeLambdaByName name -> invoke name+ where+  invoke name = do+    result <- awsLambdaInvoke name payload+    logLambdaInvocationResult result+    unless (isLambdaInvocationSuccess result) $ throwIO $ InvokeLambdaFailure+      result++  payload = object ["stack" .= stackName, "event" .= on]++findOutputValue :: Text -> [Output] -> Maybe Text+findOutputValue name =+  view output_outputValue <=< find ((== Just name) . view output_outputKey)
+ src/Stackctl/CLI.hs view
@@ -0,0 +1,110 @@+module Stackctl.CLI+  ( App+  , optionsL+  , AppT+  , runAppT+  ) where++import Stackctl.Prelude++import qualified Blammo.Logging.LogSettings.Env as LoggingEnv+import Control.Monad.Catch (MonadCatch)+import Control.Monad.Trans.Resource (ResourceT, runResourceT)+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.ColorOption+import Stackctl.DirectoryOption+import Stackctl.FilterOption+import Stackctl.VerboseOption++data App options = App+  { appLogger :: Logger+  , appOptions :: options+  , appAwsScope :: AwsScope+  , appAwsEnv :: AwsEnv+  }++optionsL :: Lens' (App options) options+optionsL = lens appOptions $ \x y -> x { appOptions = y }++instance HasLogger (App options) where+  loggerL = lens appLogger $ \x y -> x { appLogger = y }++instance HasAwsScope (App options) where+  awsScopeL = lens appAwsScope $ \x y -> x { appAwsScope = y }++instance HasAwsEnv (App options) where+  awsEnvL = lens appAwsEnv $ \x y -> x { appAwsEnv = y }++instance HasDirectoryOption options => HasDirectoryOption (App options) where+  directoryOptionL = optionsL . directoryOptionL++instance HasFilterOption options => HasFilterOption (App options) where+  filterOptionL = optionsL . filterOptionL++instance HasColorOption options => HasColorOption (App options) where+  colorOptionL = optionsL . colorOptionL++instance HasVerboseOption options => HasVerboseOption (App options) where+  verboseOptionL = optionsL . verboseOptionL++newtype AppT app m a = AppT+  { unAppT :: ReaderT app (LoggingT (ResourceT m)) a+  }+  deriving newtype+    ( Functor+    , Applicative+    , Monad+    , MonadIO+    , MonadUnliftIO+    , MonadResource+    , MonadReader app+    , MonadLogger+    , MonadThrow+    , MonadCatch+    , MonadMask+    )++runAppT+  :: ( MonadMask m+     , MonadUnliftIO m+     , HasColorOption options+     , HasVerboseOption options+     )+  => options+  -> AppT (App options) m a+  -> m a+runAppT options f = do+  envLogSettings <- liftIO LoggingEnv.parse++  logger <- newLogger $ adjustLogSettings+    (options ^. colorOptionL)+    (options ^. verboseOptionL)+    envLogSettings++  aws <- runLoggerLoggingT logger awsEnvDiscover++  let+    runAws+      :: MonadUnliftIO m => ReaderT AwsEnv (LoggingT (ResourceT m)) a -> m a+    runAws = runResourceT . runLoggerLoggingT logger . flip runReaderT aws++  app <- App logger options <$> runAws fetchAwsScope <*> 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
+ src/Stackctl/ColorOption.hs view
@@ -0,0 +1,36 @@+module Stackctl.ColorOption+  ( LogColor(..)+  , HasColorOption(..)+  , colorOption+  , colorHandle+  ) where++import Stackctl.Prelude++import Blammo.Logging.LogSettings+import Options.Applicative++class HasColorOption env where+  colorOptionL :: Lens' env LogColor++instance HasColorOption LogColor where+  colorOptionL = id++colorOption :: Parser LogColor+colorOption = option (eitherReader readLogColor) $ mconcat+  [ long "color"+  , help "When to colorize output"+  , metavar "auto|always|never"+  , value LogColorAuto+  , showDefaultWith showLogColor+  ]++showLogColor :: LogColor -> String+showLogColor = \case+  LogColorAuto -> "auto"+  LogColorAlways -> "always"+  LogColorNever -> "never"++colorHandle :: MonadIO m => Handle -> LogColor -> m Bool+colorHandle h lc = shouldColorHandle settings h+  where settings = setLogSettingsColor lc defaultLogSettings
+ src/Stackctl/Colors.hs view
@@ -0,0 +1,28 @@+-- | Facilities for colorizing output+module Stackctl.Colors+  ( Colors(..)+  , HasColorOption+  , getColorsStdout+  , noColors+  ) where++import Stackctl.Prelude++import Blammo.Logging.Colors+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++noColors :: Colors+noColors = getColors False
+ src/Stackctl/Commands.hs view
@@ -0,0 +1,85 @@+module Stackctl.Commands+  ( cat+  , capture+  , changes+  , deploy+  , version+  ) where++import Stackctl.Prelude++import Stackctl.AWS+import Stackctl.AWS.Scope+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.Subcommand+import Stackctl.Version++cat+  :: ( HasLogger env+     , HasAwsScope env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => Subcommand CatOptions env+cat = Subcommand+  { name = "cat"+  , description = "Pretty-print specifications"+  , parse = runCatOptions+  , run = runCat+  }++capture+  :: (HasAwsScope env, HasAwsEnv env, HasDirectoryOption env)+  => Subcommand CaptureOptions env+capture = Subcommand+  { name = "capture"+  , description = "Capture deployed Stacks as specifications"+  , parse = runCaptureOptions+  , run = runCapture+  }++changes+  :: ( HasAwsScope env+     , HasAwsEnv env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => Subcommand ChangesOptions env+changes = Subcommand+  { name = "changes"+  , description = "Review changes between specification and deployed state"+  , parse = runChangesOptions+  , run = runChanges+  }++deploy+  :: ( HasLogger env+     , HasAwsScope env+     , HasAwsEnv env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => Subcommand DeployOptions env+deploy = Subcommand+  { name = "deploy"+  , description = "Deploy specifications"+  , parse = runDeployOptions+  , run = runDeploy+  }++version :: Subcommand () env+version = Subcommand+  { name = "version"+  , description = "Output the version"+  , parse = pure ()+  , run = \() -> logVersion+  }
+ src/Stackctl/DirectoryOption.hs view
@@ -0,0 +1,24 @@+module Stackctl.DirectoryOption+  ( HasDirectoryOption(..)+  , directoryOption+  ) where++import Stackctl.Prelude++import Options.Applicative++class HasDirectoryOption env where+  directoryOptionL :: Lens' env FilePath++instance HasDirectoryOption FilePath where+  directoryOptionL = id++directoryOption :: Parser FilePath+directoryOption = option str $ mconcat+  [ short 'd'+  , long "directory"+  , metavar "PATH"+  , help "Operate on specifications in PATH"+  , value "."+  , showDefault+  ]
+ src/Stackctl/FilterOption.hs view
@@ -0,0 +1,65 @@+module Stackctl.FilterOption+  ( FilterOption+  , HasFilterOption(..)+  , filterOption+  , filterOptionFromPaths+  , filterFilePaths+  ) where++import Stackctl.Prelude++import qualified Data.List.NonEmpty as NE+import qualified Data.Text as T+import Options.Applicative+import System.FilePath.Glob++newtype FilterOption = FilterOption+  { unFilterOption :: NonEmpty Pattern+  }++instance ToJSON FilterOption where+  toJSON = toJSON . showFilterOption+  toEncoding = toEncoding . showFilterOption++class HasFilterOption env where+  filterOptionL :: Lens' env FilterOption++instance HasFilterOption FilterOption where+  filterOptionL = id++filterOption :: String -> Parser FilterOption+filterOption items = option (eitherReader readFilterOption) $ mconcat+  [ long "filter"+  , metavar "PATTERN[,PATTERN]"+  , help $ "Filter " <> items <> " to match PATTERN(s)"+  , value defaultFilterOption+  , showDefaultWith showFilterOption+  ]++filterOptionFromPaths :: NonEmpty FilePath -> FilterOption+filterOptionFromPaths = FilterOption . fmap compile++readFilterOption :: String -> Either String FilterOption+readFilterOption =+  maybe (Left err) (Right . FilterOption)+    . NE.nonEmpty+    . map (compile . unpack)+    . filter (not . T.null)+    . map T.strip+    . T.splitOn ","+    . pack+  where err = "Must be non-empty, comma-separated list of non-empty patterns"++showFilterOption :: FilterOption -> String+showFilterOption =+  unpack+    . T.intercalate ","+    . map (pack . decompile)+    . NE.toList+    . unFilterOption++defaultFilterOption :: FilterOption+defaultFilterOption = filterOptionFromPaths $ pure "**/*"++filterFilePaths :: FilterOption -> [FilePath] -> [FilePath]+filterFilePaths fo = filter $ \path -> any (`match` path) $ unFilterOption fo
+ src/Stackctl/Options.hs view
@@ -0,0 +1,40 @@+module Stackctl.Options+  ( Options(..)+  , optionsParser+  ) where++import Stackctl.Prelude++import Options.Applicative+import Stackctl.ColorOption+import Stackctl.DirectoryOption+import Stackctl.FilterOption+import Stackctl.VerboseOption++data Options = Options+  { oDirectory :: FilePath+  , oFilterOption :: FilterOption+  , oColor :: LogColor+  , oVerbose :: Verbosity+  }++instance HasDirectoryOption Options where+  directoryOptionL = lens oDirectory $ \x y -> x { oDirectory = y }++instance HasColorOption Options where+  colorOptionL = lens oColor $ \x y -> x { oColor = y }++instance HasFilterOption Options where+  filterOptionL = lens oFilterOption $ \x y -> x { oFilterOption = y }++instance HasVerboseOption Options where+  verboseOptionL = lens oVerbose $ \x y -> x { oVerbose = y }++-- brittany-disable-next-binding++optionsParser :: Parser Options+optionsParser = Options+  <$> directoryOption+  <*> filterOption "specifications"+  <*> colorOption+  <*> verboseOption
+ src/Stackctl/Prelude.hs view
@@ -0,0 +1,32 @@+module Stackctl.Prelude+  ( module X+  , decodeUtf8+  ) where++import RIO as X hiding+  ( LogLevel(..)+  , LogSource+  , logDebug+  , logDebugS+  , logError+  , logErrorS+  , logInfo+  , logInfoS+  , logOther+  , logOtherS+  , logWarn+  , logWarnS+  )++import Blammo.Logging as X+import Control.Error.Util as X (hush, note)+import Data.Aeson as X (ToJSON(..), object)+import Data.Text as X (pack, unpack)+import System.FilePath as X+  (dropExtension, takeBaseName, takeDirectory, (<.>), (</>))+import UnliftIO.Directory as X (withCurrentDirectory)++{-# ANN module ("HLint: ignore Avoid restricted qualification" :: String) #-}++decodeUtf8 :: ByteString -> Text+decodeUtf8 = decodeUtf8With lenientDecode
+ src/Stackctl/Prompt.hs view
@@ -0,0 +1,44 @@+module Stackctl.Prompt+  ( prompt+  , promptContinue+  ) where++import Stackctl.Prelude++import Blammo.Logging.Logger (flushLogger)+import qualified Data.Text as T+import qualified Data.Text.IO as T++prompt+  :: (MonadIO m, MonadLogger m, MonadReader env m, HasLogger env)+  => Text+  -- ^ Message to present+  -> (Text -> Either Text a)+  -- ^ Parse user input (stripped)+  -> (a -> m r)+  -- ^ Action to take on result+  -> m r+prompt message parse dispatch = do+  flushLogger++  x <- liftIO $ do+    T.putStr $ message <> "? "+    hFlush stdout+    T.strip <$> T.getLine++  case parse x of+    Left err -> do+      logWarn $ "Invalid input" :# ["error" .= err]+      prompt message parse dispatch+    Right a -> dispatch a++promptContinue+  :: (MonadIO m, MonadLogger m, MonadReader env m, HasLogger env) => m ()+promptContinue = prompt "Continue (y/n)" parse dispatch+ where+  parse x+    | x `elem` ["y", "Y"] = Right True+    | x `elem` ["n", "N"] = Right False+    | otherwise = Left $ "Must be y, Y, n, or N (saw " <> x <> ")"++  dispatch b = if b then pure () else exitSuccess
+ src/Stackctl/Spec/Capture.hs view
@@ -0,0 +1,87 @@+module Stackctl.Spec.Capture+  ( CaptureOptions(..)+  , runCaptureOptions+  , runCapture+  ) where++import Stackctl.Prelude++import Options.Applicative+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.DirectoryOption (HasDirectoryOption(..))+import Stackctl.Spec.Generate++data CaptureOptions = CaptureOptions+  { scoAccountName :: Maybe Text+  , scoTemplatePath :: Maybe FilePath+  , scoStackPath :: Maybe FilePath+  , scoDepends :: Maybe [StackName]+  , scoStackName :: StackName+  }++-- brittany-disable-next-binding++runCaptureOptions :: Parser CaptureOptions+runCaptureOptions = 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"+      )))+    <*> (StackName <$> argument str+      (  metavar "STACK"+      <> help "Name of deployed Stack to capture"+      ))++runCapture+  :: ( MonadMask m+     , MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsScope env+     , HasAwsEnv env+     , HasDirectoryOption env+     )+  => CaptureOptions+  -> m ()+runCapture CaptureOptions {..} = do+  dir <- view directoryOptionL+  stack <- awsCloudFormationDescribeStack scoStackName+  template <- awsCloudFormationGetTemplate scoStackName++  let+    setScopeName scope =+      maybe scope (\name -> scope { awsAccountName = name }) scoAccountName++  void $ local (awsScopeL %~ setScopeName) $ generate Generate+    { gOutputDirectory = dir+    , gTemplatePath = scoTemplatePath+    , gStackPath = scoStackPath+    , gStackName = scoStackName+    , gDepends = scoDepends+    , gActions = Nothing+    , gParameters = parameters stack+    , gCapabilities = capabilities stack+    , gTags = tags stack+    , gTemplate = template+    }
+ src/Stackctl/Spec/Cat.hs view
@@ -0,0 +1,183 @@+module Stackctl.Spec.Cat+  ( CatOptions(..)+  , runCatOptions+  , runCat+  ) where++import Stackctl.Prelude++import Blammo.Logging.Logger (flushLogger)+import Data.Aeson+import Data.Aeson.Lens+import qualified Data.HashMap.Strict as HashMap+import Data.List (sort, sortOn)+import qualified Data.List.NonEmpty as NE+import qualified Data.Text as T+import qualified Data.Text.IO as T+import qualified Data.Yaml as Yaml+import Options.Applicative+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.Colors+import Stackctl.DirectoryOption (HasDirectoryOption(..))+import Stackctl.FilterOption (HasFilterOption)+import Stackctl.Spec.Discover+import Stackctl.StackSpec+import Stackctl.StackSpecPath+import Stackctl.StackSpecYaml++data CatOptions = CatOptions+  { sctoNoStacks :: Bool+  , sctoNoTemplates :: Bool+  , sctoBrief :: Bool+  }++-- brittany-disable-next-binding++runCatOptions :: Parser CatOptions+runCatOptions = 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+     , MonadLogger m+     , MonadReader env m+     , HasLogger env+     , HasAwsScope env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => CatOptions+  -> m ()+runCat CatOptions {..} = do+  dir <- view directoryOptionL+  colors@Colors {..} <- getColorsStdout+  tree <- specTree <$> discoverSpecs++  let+    putStack n x = if sctoNoStacks then pure () else put n x+    putStackBody n x =+      if sctoNoStacks || sctoBrief then pure () else putBoxed n x+    putTemplate n x = if sctoNoTemplates then pure () else put n x+    putTemplateBody n x =+      if sctoNoTemplates || sctoBrief then pure () else putBoxed n x++  flushLogger++  put 0 $ fromString dir <> "/"+  putStack 2 "stacks/"+  templates <- for tree $ \((accountId, accountName), regions) -> do+    putStack 4 $ magenta (unAccountId accountId) <> "." <> accountName <> "/"++    for regions $ \(region, specs) -> do+      putStack 6 $ magenta (toText region) <> "/"++      let sorted = sortOn (stackSpecPathBasePath . stackSpecSpecPath) specs+      for sorted $ \spec -> do+        let+          base = stackSpecPathBasePath $ stackSpecSpecPath spec+          body = stackSpecSpecBody spec+          name = stackSpecStackName spec+          yaml = prettyPrintStackSpecYaml colors name body++        putStack 8 $ magenta (fromString base)+        putStackBody 10 yaml+        pure $ ssyTemplate body++  putTemplate 2 "templates/"+  for_ (sort $ concat $ concat templates) $ \template -> do+    val <- Yaml.decodeFileThrow @_ @Value $ dir </> "templates" </> template++    putTemplate 4 $ green $ fromString template+    putTemplateBody 6 $ prettyPrintTemplate colors val++specTree :: [StackSpec] -> [((AccountId, Text), [(Region, [StackSpec])])]+specTree = map (second groupRegion) . groupAccount+ where+  groupRegion :: [StackSpec] -> [(Region, [StackSpec])]+  groupRegion = groupTo (stackSpecPathRegion . stackSpecSpecPath)++  groupAccount :: [StackSpec] -> [((AccountId, Text), [StackSpec])]+  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)]+  , [cyan "Template" <> ": " <> green (pack ssyTemplate)]+  , ppList "Parameters" (ppParameters . map unParameterYaml) ssyParameters+  , ppList "Capabilities" ppCapabilities ssyCapabilities+  , ppList "Tags" (ppTags . map unTagYaml) ssyTags+  ]+ where+  ppList :: Text -> (a -> [Text]) -> Maybe a -> [Text]+  ppList label f = maybe [] (((cyan label <> ":") :) . f)++  ppParameters = concatMap $ \p ->+    [ "  - " <> cyan "ParameterKey" <> ": " <> maybe+      ""+      green+      (p ^. parameter_parameterKey)+    , "    " <> cyan "ParameterValue" <> ": " <> maybe+      ""+      toText+      (p ^. parameter_parameterValue)+    ]++  ppCapabilities = map (("  - " <>) . green . toText)++  ppTags = concatMap $ \tg ->+    [ "  - " <> cyan "Key" <> ": " <> green (tg ^. tag_key)+    , "    " <> cyan "Value" <> ": " <> (tg ^. tag_value)+    ]++prettyPrintTemplate :: Colors -> Value -> [Text]+prettyPrintTemplate Colors {..} val = concat+  [ displayTextProperty "Description"+  , displayObjectProperty "Parameters"+  , displayObjectProperty "Resources"+  , displayObjectProperty "Outputs"+  ]+ where+  displayTextProperty :: Text -> [Text]+  displayTextProperty = displayPropertyWith+    $ \v -> let tp = T.dropWhileEnd (== '\n') $ pack v in ["  " <> green tp]++  displayObjectProperty :: Text -> [Text]+  displayObjectProperty =+    displayPropertyWith @(HashMap Text Value)+      $ map (("  - " <>) . green)+      . sort+      . HashMap.keys++  displayPropertyWith+    :: (FromJSON a, ToJSON a) => (a -> [Text]) -> Text -> [Text]+  displayPropertyWith f k = cyan k <> ": " : fromMaybe [] displayValue+    where displayValue = val ^? key k . _JSON . to f++putBoxed :: MonadIO m => Int -> [Text] -> m ()+putBoxed n xs = do+  traverse_ (put n . ("│ " <>)) xs+  put n "└──────────"+  put 0 ""++put :: MonadIO m => Int -> Text -> m ()+put n = liftIO . T.putStrLn . (indent <>)+  where indent = mconcat $ replicate n " "
+ src/Stackctl/Spec/Changes.hs view
@@ -0,0 +1,68 @@+module Stackctl.Spec.Changes+  ( ChangesOptions(..)+  , runChangesOptions+  , runChanges+  ) where++import Stackctl.Prelude++import qualified Data.Text.IO as T+import Options.Applicative+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.Colors+import Stackctl.DirectoryOption (HasDirectoryOption)+import Stackctl.FilterOption (HasFilterOption)+import Stackctl.Spec.Changes.Format+import Stackctl.Spec.Discover+import Stackctl.StackSpec+import Stackctl.StackSpecPath++data ChangesOptions = ChangesOptions+  { scoFormat :: Format+  , scoOutput :: FilePath+  }++-- brittany-disable-next-binding++runChangesOptions :: Parser ChangesOptions+runChangesOptions = ChangesOptions+  <$> formatOption+  <*> argument str+    (  metavar "PATH"+    <> help "Where to write the changes summary"+    )++runChanges+  :: ( MonadMask m+     , MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsScope env+     , HasAwsEnv env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => ChangesOptions+  -> m ()+runChanges ChangesOptions {..} = do+  specs <- discoverSpecs++  for_ specs $ \spec -> do+    withThreadContext ["stackName" .= stackSpecStackName spec] $ do+      emChangeSet <- createChangeSet spec++      case emChangeSet of+        Left err -> do+          logError $ "Error creating ChangeSet" :# ["error" .= err]+          exitFailure+        Right mChangeSet -> do+          colors <- getColorsStdout+          let name = pack $ stackSpecPathFilePath $ stackSpecSpecPath spec+          liftIO $ T.writeFile scoOutput $ formatChangeSet+            colors+            name+            scoFormat+            mChangeSet
+ src/Stackctl/Spec/Changes/Format.hs view
@@ -0,0 +1,172 @@+module Stackctl.Spec.Changes.Format+  ( Format(..)+  , formatOption+  , formatChangeSet+  , formatTTY+  ) where++import Stackctl.Prelude++import qualified Data.List.NonEmpty as NE+import qualified Data.Text as T+import Options.Applicative hiding (action)+import Stackctl.AWS+import Stackctl.Colors++data Format+  = FormatTTY+  | FormatPullRequest++formatOption :: Parser Format+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+  "tty" -> Right FormatTTY+  "pr" -> Right FormatPullRequest+  x -> Left $ "Invalid format: " <> x++showFormat :: Format -> String+showFormat = \case+  FormatTTY -> "tty"+  FormatPullRequest -> "pr"++formatChangeSet :: Colors -> Text -> Format -> Maybe ChangeSet -> Text+formatChangeSet colors name = \case+  FormatTTY -> formatTTY colors name+  FormatPullRequest -> formatPullRequest name++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)+ where+  rChanges = do+    cs <- mChangeSet+    changes <- csChanges cs+    NE.nonEmpty $ mapMaybe resourceChange changes++  formatResourceChange ResourceChange' {..} =+    maybe "" colorAction action+      <> " "+      <> maybe "" toText logicalResourceId+      <> " ("+      <> maybe "" cyan resourceType+      <> ")"+      <> maybe "" ((" " <>) . magenta) physicalResourceId+      <> maybe "" (("\n    Replacement: " <>) . colorReplacement) replacement+      <> maybe "" (("\n    Scope: " <>) . T.intercalate ", " . map toText) scope+      <> maybe "" (("\n    Details:" <>) . formatDetails) details++  formatDetails =+    mconcat . map ("\n      * " <>) . mapMaybe (formatDetail colors)++  colorAction = \case+    x@ChangeAction_Add -> green (toText x)+    x@ChangeAction_Modify -> yellow (toText x)+    x@ChangeAction_Remove -> red (toText x)+    ChangeAction' x -> x++  colorReplacement = \case+    x@Replacement_True -> red (toText x)+    x@Replacement_False -> green (toText x)+    x@Replacement_Conditional -> yellow (toText x)+    Replacement' x -> x++formatPullRequest :: Text -> Maybe ChangeSet -> Text+formatPullRequest name mChangeSet =+  emoji+    <> " This PR generates "+    <> description+    <> " for `"+    <> name+    <> "`."+    <> fromMaybe "" (commentBody <$> mChangeSet <*> rChanges)+    <> "\n"+ where+  emoji = case (mChangeSet, nChanges) of+    (Nothing, _) -> ":heavy_check_mark:"+    (_, Nothing) -> ":book:"+    (_, Just _) -> ":warning:"++  description = case (mChangeSet, nChanges) of+    (Nothing, _) -> "no changes"+    (_, Nothing) -> "only metadata changes (Tags, Outputs, etc)"+    (_, Just 1) -> "**1** change"+    (_, Just n) -> "**" <> pack (show n) <> "** changes"++  nChanges = length <$> rChanges++  rChanges = do+    cs <- mChangeSet+    changes <- csChanges cs+    NE.nonEmpty $ mapMaybe resourceChange changes++commentBody :: ChangeSet -> NonEmpty ResourceChange -> Text+commentBody cs rcs =+  mconcat+    $ [ "\n"+      , "\n| Action | Logical Id | Physical Id | Type | Replacement | Scope | Details |"+      , "\n| ---    | ---        | ---         | ---  | ---         | ---   | ---     |"+      ]+    <> map commentTableRow (NE.toList rcs)+    <> [ "\n"+       , "\n<details>"+       , "\n<summary>Full changes</summary>"+       , "\n"+       , "\n```json"+       , "\n" <> changeSetJSON cs+       , "\n```"+       , "\n"+       , "\n</details>"+       ]++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 <> " "+  , "|"+  ]++mdList :: [Text] -> Text+mdList xs =+  "<ul>" <> mconcat (map (\x -> "<li>" <> x <> "</li>") xs) <> "</ul>"++formatDetail :: Colors -> ResourceChangeDetail -> Maybe Text+formatDetail Colors {..} ResourceChangeDetail' {..} = do+  c <- changeSource+  t <- target++  let+    attr = attribute t+    n = name t+    rr = requiresRecreation t++  pure+    $ toText c+    <> 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)+    x@RequiresRecreation_Never -> green (toText x)+    x@RequiresRecreation_Conditionally -> yellow (toText x)+    RequiresRecreation' x -> x
+ src/Stackctl/Spec/Deploy.hs view
@@ -0,0 +1,232 @@+module Stackctl.Spec.Deploy+  ( DeployOptions(..)+  , DeployConfirmation(..)+  , runDeployOptions+  , runDeploy+  ) where++import Stackctl.Prelude++import Blammo.Logging.Logger (pushLogStrLn)+import qualified Data.Text as T+import Data.Time (defaultTimeLocale, formatTime, utcToLocalZonedTime)+import Options.Applicative+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.Action+import Stackctl.Colors+import Stackctl.DirectoryOption (HasDirectoryOption)+import Stackctl.FilterOption (HasFilterOption)+import Stackctl.Prompt+import Stackctl.Spec.Changes.Format+import Stackctl.Spec.Discover+import Stackctl.StackSpec+import System.Log.FastLogger (toLogStr)+import UnliftIO.Directory (createDirectoryIfMissing)++data DeployOptions = DeployOptions+  { sdoSaveChangeSets :: Maybe FilePath+  , sdoDeployConfirmation :: DeployConfirmation+  , sdoClean :: Bool+  }++-- brittany-disable-next-binding++runDeployOptions :: Parser DeployOptions+runDeployOptions = DeployOptions+  <$> optional (strOption+    (  long "save-change-sets"+    <> metavar "DIRECTORY"+    <> help "Save executed changesets to 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"+    )++runDeploy+  :: ( MonadMask m+     , MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasLogger env+     , HasAwsScope env+     , HasAwsEnv env+     , HasDirectoryOption env+     , HasFilterOption env+     , HasColorOption env+     )+  => DeployOptions+  -> m ()+runDeploy DeployOptions {..} = do+  specs <- discoverSpecs++  for_ specs $ \spec -> do+    withThreadContext ["stackName" .= stackSpecStackName spec] $ do+      handleRollbackComplete sdoDeployConfirmation $ stackSpecStackName spec++      emChangeSet <- createChangeSet spec++      case emChangeSet of+        Left err -> do+          logError $ "Error creating ChangeSet" :# ["error" .= err]+          exitFailure+        Right Nothing -> logInfo "Stack is up to date"+        Right (Just changeSet) -> do+          let stackName = stackSpecStackName spec++          for_ sdoSaveChangeSets $ \dir -> do+            let out = dir </> unpack (unStackName stackName) <.> "json"+            logInfo $ "Recording changeset" :# ["path" .= out]+            createDirectoryIfMissing True dir+            writeFileUtf8 out $ changeSetJSON changeSet++          deployChangeSet sdoDeployConfirmation changeSet+          runActions stackName PostDeploy $ stackSpecActions spec+          when sdoClean $ awsCloudFormationDeleteAllChangeSets stackName++data DeployConfirmation+  = DeployWithConfirmation+  | DeployWithoutConfirmation+  deriving stock Eq++handleRollbackComplete+  :: ( MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasLogger env+     , HasAwsEnv env+     )+  => DeployConfirmation+  -> StackName+  -> m ()+handleRollbackComplete 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]++    case confirmation of+      DeployWithConfirmation -> promptContinue+      DeployWithoutConfirmation -> do+        logError "Refusing to delete without confirmation"+        exitFailure++    result <- awsCloudFormationDeleteStack stackName++    case result of+      StackDeleteSuccess -> logInfo $ prettyStackDeleteResult result :# []+      StackDeleteFailure{} -> logWarn $ prettyStackDeleteResult result :# []++deployChangeSet+  :: ( MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasLogger env+     , HasAwsEnv env+     , HasColorOption env+     )+  => DeployConfirmation+  -> ChangeSet+  -> m ()+deployChangeSet confirmation changeSet = do+  colors <- getColorsStdout++  pushLogger $ formatTTY colors (unStackName stackName) $ Just changeSet++  case confirmation of+    DeployWithConfirmation -> promptContinue+    DeployWithoutConfirmation -> pure ()++  -- It can take a minute to get this batch of events to work out where we're+  -- tailing from, so do that part synchronously+  mLastId <- awsCloudFormationGetMostRecentStackEventId stackName+  asyncTail <- async $ tailStackEventsSince stackName mLastId++  logInfo $ "Executing ChangeSet" :# ["changeSetId" .= changeSetId]+  result <- do+    awsCloudFormationExecuteChangeSet changeSetId+    awsCloudFormationWait stackName++  cancel asyncTail++  let+    onSuccess = logInfo $ prettyStackDeployResult result :# []+    onFailure = do+      logError $ prettyStackDeployResult result :# []+      exitFailure++  case result of+    StackCreateSuccess -> onSuccess+    StackCreateFailure{} -> onFailure+    StackUpdateSuccess -> onSuccess+    StackUpdateFailure{} -> onFailure+ where+  stackName = csStackName changeSet+  changeSetId = csChangeSetId changeSet++tailStackEventsSince+  :: ( MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasLogger env+     , HasAwsEnv env+     , HasColorOption env+     )+  => StackName+  -> Maybe Text -- ^ StackEventId+  -> m a+tailStackEventsSince stackName mLastId = do+  colors <- getColorsStdout+  events <- awsCloudFormationDescribeStackEvents stackName mLastId+  traverse_ (pushLogger <=< formatStackEvent colors) $ reverse events++  -- Without this small delay before looping, our requests seem to hang+  -- intermittently (without errors) and often we miss events.+  threadDelay $ 1 * 1000000++  -- Tail from the next "last id". If we got no events, be sure to pass along+  -- any last-id we were given+  tailStackEventsSince stackName $ getLastEventId events <|> mLastId++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+    ]+ where+  colorStatus = \case+    ResourceStatus' x+      | "ROLLBACK" `T.isInfixOf` x -> red x+      | "COMPLETE" `T.isSuffixOf` x -> green x+      | "FAILED" `T.isSuffixOf` x -> red x+      | "IN_PROGRESS" `T.isSuffixOf` x -> blue x+      | "SKIPPED" `T.isSuffixOf` x -> yellow x+      | otherwise -> x++getLastEventId :: [StackEvent] -> Maybe Text+getLastEventId = fmap (^. stackEvent_eventId) . listToMaybe++pushLogger :: (MonadIO m, MonadReader env m, HasLogger env) => Text -> m ()+pushLogger msg = do+  logger <- view loggerL+  pushLogStrLn logger $ toLogStr msg
+ src/Stackctl/Spec/Discover.hs view
@@ -0,0 +1,107 @@+module Stackctl.Spec.Discover+  ( discoverSpecs+  , buildSpecPath+  ) where++import Stackctl.Prelude++import Data.List.Extra (dropPrefix)+import qualified Data.List.NonEmpty as NE+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.DirectoryOption (HasDirectoryOption(..))+import Stackctl.FilterOption (HasFilterOption(..), filterFilePaths)+import Stackctl.StackSpec+import Stackctl.StackSpecPath+import System.FilePath (isPathSeparator)+import System.FilePath.Glob++discoverSpecs+  :: ( MonadMask m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsScope env+     , HasDirectoryOption env+     , HasFilterOption env+     )+  => m [StackSpec]+discoverSpecs = do+  dir <- view directoryOptionL+  scope@AwsScope {..} <- view awsScopeL+  discovered <- globRelativeTo+    dir+    [ compile+    $ "stacks"+    </> unpack (unAccountId awsAccountId)+    <> ".*"+    </> unpack (fromRegion awsRegion)+    <> "**"+    </> "*"+    <.> "yaml"+    , compile+    $ "stacks"+    </> "*."+    <> unpack (unAccountId awsAccountId)+    </> unpack (fromRegion awsRegion)+    <> "**"+    </> "*"+    <.> "yaml"+    ]++  filterOption <- view filterOptionL++  let+    filtered = filterFilePaths filterOption discovered+    toSpecPath = stackSpecPathFromFilePath scope+    (errs, specPaths) = partitionEithers $ map toSpecPath filtered++    context =+      [ "path" .= dir+      , "filters" .= filterOption+      , "discovered" .= length discovered+      , "filtered" .= length filtered+      , "errors" .= length errs+      ]++  withThreadContext context $ do+    logDebug "Discovered specs"+    when (null filtered) $ logWarn "No specs found"+    checkForDuplicateStackNames specPaths++  sortStackSpecs <$> traverse (readStackSpec dir) specPaths++checkForDuplicateStackNames+  :: (MonadIO m, MonadLogger m) => [StackSpecPath] -> m ()+checkForDuplicateStackNames =+  traverse_ reportCollisions+    . NE.nonEmpty+    . filter ((> 1) . length)+    . NE.groupAllWith stackSpecPathStackName+ where+  reportCollisions+    :: (MonadIO m, MonadLogger m) => NonEmpty (NonEmpty StackSpecPath) -> m ()+  reportCollisions errs = do+    for_ errs $ \specPaths -> do+      let collidingPaths = stackSpecPathBasePath <$> specPaths++      logError+        $ "Multiple specifications produced the same Stack name"+        :# [ "name" .= stackSpecPathStackName (NE.head specPaths)+           , "paths" .= collidingPaths+           ]++    exitFailure++buildSpecPath+  :: (MonadReader env m, HasAwsScope env)+  => StackName+  -> FilePath+  -> m StackSpecPath+buildSpecPath stackName stackPath = do+  scope <- view awsScopeL+  pure $ stackSpecPath scope stackName stackPath++globRelativeTo :: MonadIO m => FilePath -> [Pattern] -> m [FilePath]+globRelativeTo dir ps = liftIO $ do+  map (dropWhile isPathSeparator . dropPrefix dir) . concat <$> globDir ps dir
+ src/Stackctl/Spec/Generate.hs view
@@ -0,0 +1,64 @@+module Stackctl.Spec.Generate+  ( Generate(..)+  , generate+  ) where++import Stackctl.Prelude++import Data.Aeson+import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.Action+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@+  , gStackPath :: Maybe FilePath+  -- ^ If not given will use @{stack-name}.yaml@+  , gStackName :: StackName+  , gDepends :: Maybe [StackName]+  , gActions :: Maybe [Action]+  , gParameters :: Maybe [Parameter]+  , gCapabilities :: Maybe [Capability]+  , gTags :: Maybe [Tag]+  , gTemplate :: Value+  }++generate+  :: ( MonadMask m+     , MonadUnliftIO m+     , MonadLogger m+     , MonadReader env m+     , HasAwsScope env+     )+  => Generate+  -> m FilePath+generate Generate {..} = do+  let+    path = unpack (unStackName gStackName) <.> "yaml"+    stackPath = fromMaybe path gStackPath++  specPath <- buildSpecPath gStackName stackPath++  let+    templatePath = fromMaybe path gTemplatePath+    specYaml = StackSpecYaml+      { ssyTemplate = templatePath+      , ssyDepends = gDepends+      , ssyActions = gActions+      , ssyParameters = map ParameterYaml <$> gParameters+      , ssyCapabilities = gCapabilities+      , ssyTags = map TagYaml <$> gTags+      }++    stackSpec = buildStackSpec gOutputDirectory specPath specYaml++  withThreadContext ["stackName" .= stackSpecStackName stackSpec] $ do+    logInfo "Generating specification"+    writeStackSpec gOutputDirectory stackSpec gTemplate+    pure $ stackSpecPathFilePath specPath
+ src/Stackctl/StackSpec.hs view
@@ -0,0 +1,131 @@+module Stackctl.StackSpec+  ( StackSpec+  , stackSpecSpecPath+  , stackSpecSpecBody+  , stackSpecStackName+  , stackSpecActions+  , stackSpecParameters+  , stackSpecCapabilities+  , stackSpecTags+  , buildStackSpec+  , writeStackSpec+  , readStackSpec+  , createChangeSet+  , sortStackSpecs+  ) where++import Stackctl.Prelude++import qualified CfnFlip+import Data.Aeson+import Data.Graph (graphFromEdges, topSort)+import qualified Data.Yaml as Yaml+import Stackctl.AWS+import Stackctl.Action+import Stackctl.StackSpecPath+import Stackctl.StackSpecYaml+import UnliftIO.Directory (createDirectoryIfMissing)++data StackSpec = StackSpec+  { ssSpecRoot :: FilePath+  , ssSpecPath :: StackSpecPath+  , ssSpecBody :: StackSpecYaml+  }++stackSpecSpecPath :: StackSpec -> StackSpecPath+stackSpecSpecPath = ssSpecPath++stackSpecSpecBody :: StackSpec -> StackSpecYaml+stackSpecSpecBody = ssSpecBody++stackSpecStackName :: StackSpec -> StackName+stackSpecStackName = stackSpecPathStackName . ssSpecPath++stackSpecDepends :: StackSpec -> [StackName]+stackSpecDepends = fromMaybe [] . ssyDepends . ssSpecBody++stackSpecActions :: StackSpec -> [Action]+stackSpecActions = fromMaybe [] . ssyActions . ssSpecBody++stackSpecTemplateFile :: StackSpec -> StackTemplate+stackSpecTemplateFile StackSpec {..} =+  StackTemplate $ ssSpecRoot </> "templates" </> ssyTemplate ssSpecBody++stackSpecParameters :: StackSpec -> [Parameter]+stackSpecParameters =+  maybe [] (map unParameterYaml) . ssyParameters . ssSpecBody++stackSpecCapabilities :: StackSpec -> [Capability]+stackSpecCapabilities = fromMaybe [] . ssyCapabilities . ssSpecBody++stackSpecTags :: StackSpec -> [Tag]+stackSpecTags = maybe [] (map unTagYaml) . ssyTags . ssSpecBody++buildStackSpec :: FilePath -> StackSpecPath -> StackSpecYaml -> StackSpec+buildStackSpec = StackSpec++writeStackSpec+  :: MonadUnliftIO m+  => FilePath -- ^ Parent directory+  -> StackSpec+  -> Value -- ^ Template body+  -> m ()+writeStackSpec parent stackSpec@StackSpec {..} templateBody = do+  createDirectoryIfMissing True $ takeDirectory templatePath++  case templateBody of+    -- Already Yaml+    String x -> writeFileUtf8 templatePath x+    _ -> CfnFlip.jsonToYamlFile templatePath templateBody++  createDirectoryIfMissing True $ takeDirectory specPath+  liftIO $ Yaml.encodeFile specPath ssSpecBody+ where+  templatePath = unStackTemplate $ stackSpecTemplateFile stackSpec+  specPath = parent </> stackSpecPathFilePath ssSpecPath++readStackSpec :: MonadIO m => FilePath -> StackSpecPath -> m StackSpec+readStackSpec dir specPath = do+  specBody <- liftIO $ either err pure =<< Yaml.decodeFileEither path++  pure StackSpec+    { ssSpecRoot = dir+    , ssSpecPath = specPath+    , ssSpecBody = specBody+    }+ where+  path = dir </> stackSpecPathFilePath specPath+  err e =+    throwString $ path <> " is invalid: " <> Yaml.prettyPrintParseException e++-- | Create a Change Set between a Stack Specification and deployed state+--+-- We use @aws cloudformation deploy --no-execute-changeset@ because it handles+-- the create-or-update logic for us.+--+createChangeSet+  :: ( MonadUnliftIO m+     , MonadResource m+     , MonadLogger m+     , MonadReader env m+     , HasAwsEnv env+     )+  => StackSpec+  -> m (Either Text (Maybe ChangeSet))+createChangeSet spec = awsCloudFormationCreateChangeSet+  (stackSpecStackName spec)+  (stackSpecTemplateFile spec)+  (stackSpecParameters spec)+  (stackSpecCapabilities spec)+  (stackSpecTags spec)++sortStackSpecs :: [StackSpec] -> [StackSpec]+sortStackSpecs specs = map nodeFromVertex $ reverse $ topSort graph+ where+  (graph, tripleFromVertex, _) = graphFromEdges $ map tripleFromNode specs++  nodeFromVertex = nodeFromTriple . tripleFromVertex++  tripleFromNode n = (n, stackSpecStackName n, stackSpecDepends n)++  nodeFromTriple (n, _, _) = n
+ src/Stackctl/StackSpecPath.hs view
@@ -0,0 +1,118 @@+{-# LANGUAGE NamedFieldPuns #-}++module Stackctl.StackSpecPath+  ( StackSpecPath++  -- * Fields+  , stackSpecPathAccountId+  , stackSpecPathAccountName+  , stackSpecPathRegion+  , stackSpecPathStackName+  , stackSpecPathBasePath+  , stackSpecPathFilePath++  -- * Construction+  , stackSpecPath+  , stackSpecPathFromFilePath+  ) where++import Stackctl.Prelude++import Data.Char (isDigit)+import qualified Data.Text as T+import Stackctl.AWS+import Stackctl.AWS.Scope+import System.FilePath (joinPath, splitDirectories)++data StackSpecPath = StackSpecPath+  { sspAwsScope :: AwsScope+  , sspAccountPathPart :: FilePath+  , sspStackName :: StackName+  , sspPath :: FilePath+  }++stackSpecPath :: AwsScope -> StackName -> FilePath -> StackSpecPath+stackSpecPath sspAwsScope@AwsScope {..} sspStackName sspPath = StackSpecPath+  { sspAwsScope+  , sspAccountPathPart+  , sspStackName+  , sspPath+  }+ where+  sspAccountPathPart =+    unpack $ unAccountId awsAccountId <> "." <> awsAccountName++stackSpecPathAccountId :: StackSpecPath -> AccountId+stackSpecPathAccountId = awsAccountId . sspAwsScope++stackSpecPathAccountName :: StackSpecPath -> Text+stackSpecPathAccountName = awsAccountName . sspAwsScope++stackSpecPathAccountPathPart :: StackSpecPath -> FilePath+stackSpecPathAccountPathPart = sspAccountPathPart++stackSpecPathRegion :: StackSpecPath -> Region+stackSpecPathRegion = awsRegion . sspAwsScope++stackSpecPathBasePath :: StackSpecPath -> FilePath+stackSpecPathBasePath = sspPath++stackSpecPathStackName :: StackSpecPath -> StackName+stackSpecPathStackName = sspStackName++-- | Render the (relative) 'StackSpecPath'+stackSpecPathFilePath :: StackSpecPath -> FilePath+stackSpecPathFilePath path =+  "stacks"+    </> stackSpecPathAccountPathPart path+    </> unpack (fromRegion $ stackSpecPathRegion path)+    </> stackSpecPathBasePath path++stackSpecPathFromFilePath+  :: AwsScope+  -> FilePath -- ^ Must be relative, @stacks/@+  -> Either String StackSpecPath+stackSpecPathFromFilePath awsScope@AwsScope {..} path =+  case splitDirectories path of+    ("stacks" : pathAccount : pathRegion : rest) -> do+      (accountName, pathAccountId) <- parseAccountPath pathAccount++      unless (pathAccountId == awsAccountId)+        $ Left+        $ "Unexpected account: "+        <> unpack (unAccountId pathAccountId)+        <> " != "+        <> unpack (unAccountId awsAccountId)++      unless (unpack (fromRegion awsRegion) == pathRegion)+        $ Left+        $ "Unexpected region: "+        <> 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+        }++    _ -> Left $ "Path is not stacks/././.: " <> path++-- | Handle @{account-name}.{account-id}@ or @{account-id}.{account-name}@+parseAccountPath :: FilePath -> Either String (Text, AccountId)+parseAccountPath path = case second (T.drop 1) $ T.breakOn "." $ pack path of+  (a, b) | isAccountId a -> Right (b, AccountId a)+  (a, b) | isAccountId b -> Right (a, AccountId b)+  _ ->+    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
+ src/Stackctl/StackSpecYaml.hs view
@@ -0,0 +1,100 @@+-- | Definition of our @stacks/<account>/<region>/<stack-name>.yaml@ format+--+-- @+-- Template: <path>+--+-- Depends:+-- - <string>+--+-- Parameters:+-- - ParameterKey: <string>+--   ParameterValue: <string>+--+-- Capabilities:+-- - <capability>+--+-- Tags:+-- - Key: <string>+--   Value: <string>+-- @+--+module Stackctl.StackSpecYaml+  ( StackSpecYaml(..)+  , ParameterYaml(..)+  , TagYaml(..)+  ) where++import Stackctl.Prelude++import Data.Aeson+import Data.Aeson.Casing+import qualified Data.Text as T+import Stackctl.AWS+import Stackctl.Action++data StackSpecYaml = StackSpecYaml+  { ssyTemplate :: FilePath+  , ssyDepends :: Maybe [StackName]+  , ssyActions :: Maybe [Action]+  , ssyParameters :: Maybe [ParameterYaml]+  , ssyCapabilities :: Maybe [Capability]+  , ssyTags :: Maybe [TagYaml]+  }+  deriving stock Generic++instance FromJSON StackSpecYaml where+  parseJSON = genericParseJSON $ aesonPrefix id++instance ToJSON StackSpecYaml where+  toJSON = genericToJSON $ aesonPrefix id+  toEncoding = genericToEncoding $ aesonPrefix id++newtype ParameterYaml = ParameterYaml+  { unParameterYaml :: Parameter+  }++instance FromJSON ParameterYaml where+  parseJSON = withObject "Parameter" $ \o ->+    (build <$> o .: "Name" <*> o .: "Value")+      <|> (build <$> o .: "ParameterKey" <*> o .: "ParameterValue")+   where+    build k v = ParameterYaml $ makeParameter k $ Just $ unParameterValue v++newtype ParameterValue = ParameterValue+  { unParameterValue :: Text+  }++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++instance ToJSON ParameterYaml where+  toJSON = object . parameterPairs+  toEncoding = pairs . mconcat . parameterPairs++parameterPairs :: KeyValue a => ParameterYaml -> [a]+parameterPairs (ParameterYaml p) = fromMaybe [] $ do+  k <- p ^. parameter_parameterKey+  v <- p ^. parameter_parameterValue+  pure ["ParameterKey" .= k, "ParameterValue" .= v]++newtype TagYaml = TagYaml+  { unTagYaml :: Tag+  }++instance FromJSON TagYaml where+  parseJSON = withObject "Tag" $ \o -> do+    t <- newTag <$> o .: "Key" <*> o .: "Value"+    pure $ TagYaml t++instance ToJSON TagYaml where+  toJSON = object . tagPairs+  toEncoding = pairs . mconcat . tagPairs++tagPairs :: KeyValue a => TagYaml -> [a]+tagPairs (TagYaml t) = ["Key" .= (t ^. tag_key), "Value" .= (t ^. tag_value)]++dropSuffix :: Text -> Text -> Text+dropSuffix suffix t = fromMaybe t $ T.stripSuffix suffix t
+ src/Stackctl/Subcommand.hs view
@@ -0,0 +1,41 @@+module Stackctl.Subcommand+  ( Subcommand(..)+  , subcommand+  , runSubcommand+  , runSubcommand'+  ) where++import Stackctl.Prelude++import Options.Applicative+import qualified Stackctl.CLI as CLI+import Stackctl.Colors+import Stackctl.Options+import Stackctl.VerboseOption++data Subcommand options env = Subcommand+  { name :: Text+  , description :: Text+  , parse :: Parser options+  , run :: options -> CLI.AppT env IO ()+  }++subcommand :: Subcommand options env -> Mod CommandFields (CLI.AppT env IO ())+subcommand Subcommand {..} =+  command (unpack name) (run <$> withInfo description parse)++runSubcommand :: Mod CommandFields (CLI.AppT (CLI.App Options) IO ()) -> IO ()+runSubcommand = runSubcommand' "Work with Stack specifications" optionsParser++runSubcommand'+  :: (HasVerboseOption options, HasColorOption options)+  => Text+  -> Parser options+  -> Mod CommandFields (CLI.AppT (CLI.App options) IO ())+  -> IO ()+runSubcommand' x op sp = do+  (options, act) <- execParser $ withInfo x $ (,) <$> op <*> subparser sp+  CLI.runAppT options act++withInfo :: Text -> Parser a -> ParserInfo a+withInfo d p = info (p <**> helper) $ progDesc (unpack d) <> fullDesc
+ src/Stackctl/VerboseOption.hs view
@@ -0,0 +1,37 @@+module Stackctl.VerboseOption+  ( Verbosity+  , verbositySetLogLevels+  , HasVerboseOption(..)+  , verboseOption+  ) where++import Stackctl.Prelude++import Blammo.Logging.LogSettings.LogLevels+import Options.Applicative++newtype Verbosity = Verbosity [()]++verbositySetLogLevels :: Verbosity -> (LogSettings -> LogSettings)+verbositySetLogLevels (Verbosity bs) = case bs of+  [] -> id+  [_] -> setLogSettingsLevels v+  [_, _] -> setLogSettingsLevels vv+  _ -> setLogSettingsLevels vvv+ where+  v = newLogLevels LevelDebug [("Amazonka", LevelInfo)]+  vv = newLogLevels LevelDebug []+  vvv = newLogLevels (LevelOther "trace") []++class HasVerboseOption env where+  verboseOptionL :: Lens' env Verbosity++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)"+  ]
+ src/Stackctl/Version.hs view
@@ -0,0 +1,12 @@+module Stackctl.Version+  ( logVersion+  ) where++import Stackctl.Prelude++import Data.Version+import qualified Paths_stackctl as Pkg+import Prelude (putStrLn)++logVersion :: MonadIO m => m ()+logVersion = liftIO $ putStrLn $ ("Stackctl v" <>) $ showVersion Pkg.version
+ src/UnliftIO/Exception/Lens.hs view
@@ -0,0 +1,33 @@+-- | 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 #-}
+ stackctl.cabal view
@@ -0,0 +1,209 @@+cabal-version: 1.18++-- This file has been generated from package.yaml by hpack version 0.35.0.+--+-- see: https://github.com/sol/hpack++name:           stackctl+version:        1.1.0.0+description:    Please see <https://github.com/freckle/stackctl#readme>+homepage:       https://github.com/freckle/stackctl#readme+bug-reports:    https://github.com/freckle/stackctl/issues+author:         Freckle Engineering+maintainer:     freckle-engineering@renaissance.com+copyright:      2022 Renaissance Learning Inc+license:        MIT+license-file:   LICENSE+build-type:     Simple+extra-doc-files:+    README.md+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/freckle/stackctl++library+  exposed-modules:+      Stackctl.Action+      Stackctl.AWS+      Stackctl.AWS.CloudFormation+      Stackctl.AWS.Core+      Stackctl.AWS.EC2+      Stackctl.AWS.Lambda+      Stackctl.AWS.Orphans+      Stackctl.AWS.Scope+      Stackctl.AWS.STS+      Stackctl.CLI+      Stackctl.ColorOption+      Stackctl.Colors+      Stackctl.Commands+      Stackctl.DirectoryOption+      Stackctl.FilterOption+      Stackctl.Options+      Stackctl.Prelude+      Stackctl.Prompt+      Stackctl.Spec.Capture+      Stackctl.Spec.Cat+      Stackctl.Spec.Changes+      Stackctl.Spec.Changes.Format+      Stackctl.Spec.Deploy+      Stackctl.Spec.Discover+      Stackctl.Spec.Generate+      Stackctl.StackSpec+      Stackctl.StackSpecPath+      Stackctl.StackSpecYaml+      Stackctl.Subcommand+      Stackctl.VerboseOption+      Stackctl.Version+      UnliftIO.Exception.Lens+  other-modules:+      Paths_stackctl+  hs-source-dirs:+      src+  default-extensions:+      BangPatterns+      DataKinds+      DeriveAnyClass+      DeriveFoldable+      DeriveFunctor+      DeriveGeneric+      DeriveLift+      DeriveTraversable+      DerivingStrategies+      DerivingVia+      FlexibleContexts+      FlexibleInstances+      GADTs+      GeneralizedNewtypeDeriving+      LambdaCase+      MultiParamTypeClasses+      NoImplicitPrelude+      NoMonomorphismRestriction+      OverloadedStrings+      RankNTypes+      RecordWildCards+      ScopedTypeVariables+      StandaloneDeriving+      TypeApplications+      TypeFamilies+  ghc-options: -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path+  build-depends:+      Blammo >=1.1.0.0+    , Glob+    , aeson+    , aeson-casing+    , aeson-pretty+    , amazonka+    , amazonka-cloudformation+    , amazonka-core+    , amazonka-ec2+    , amazonka-lambda+    , amazonka-sts+    , base ==4.*+    , bytestring+    , cfn-flip+    , conduit+    , containers+    , errors+    , exceptions+    , extra+    , fast-logger+    , filepath+    , lens+    , lens-aeson+    , monad-logger+    , optparse-applicative+    , resourcet+    , rio+    , text+    , time+    , unliftio+    , unliftio-core+    , unordered-containers+    , uuid+    , yaml+  default-language: Haskell2010++executable stackctl+  main-is: Main.hs+  other-modules:+      Paths_stackctl+  hs-source-dirs:+      app+  default-extensions:+      BangPatterns+      DataKinds+      DeriveAnyClass+      DeriveFoldable+      DeriveFunctor+      DeriveGeneric+      DeriveLift+      DeriveTraversable+      DerivingStrategies+      DerivingVia+      FlexibleContexts+      FlexibleInstances+      GADTs+      GeneralizedNewtypeDeriving+      LambdaCase+      MultiParamTypeClasses+      NoImplicitPrelude+      NoMonomorphismRestriction+      OverloadedStrings+      RankNTypes+      RecordWildCards+      ScopedTypeVariables+      StandaloneDeriving+      TypeApplications+      TypeFamilies+  ghc-options: -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -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+  build-depends:+      base ==4.*+    , stackctl+  default-language: Haskell2010++test-suite spec+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Stackctl.AWS.CloudFormationSpec+      Stackctl.FilterOptionSpec+      Stackctl.StackSpecSpec+      Stackctl.StackSpecYamlSpec+      Paths_stackctl+  hs-source-dirs:+      test+  default-extensions:+      BangPatterns+      DataKinds+      DeriveAnyClass+      DeriveFoldable+      DeriveFunctor+      DeriveGeneric+      DeriveLift+      DeriveTraversable+      DerivingStrategies+      DerivingVia+      FlexibleContexts+      FlexibleInstances+      GADTs+      GeneralizedNewtypeDeriving+      LambdaCase+      MultiParamTypeClasses+      NoImplicitPrelude+      NoMonomorphismRestriction+      OverloadedStrings+      RankNTypes+      RecordWildCards+      ScopedTypeVariables+      StandaloneDeriving+      TypeApplications+      TypeFamilies+  ghc-options: -fwrite-ide-info -Weverything -Wno-all-missed-specialisations -Wno-missing-import-lists -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module -Wno-unsafe -optP-Wno-nonportable-include-path+  build-depends:+      base ==4.*+    , hspec+    , stackctl+    , yaml+  default-language: Haskell2010
+ test/Spec.hs view
@@ -0,0 +1,2 @@+{-# OPTIONS_GHC -fno-warn-missing-export-lists #-}+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ test/Stackctl/AWS/CloudFormationSpec.hs view
@@ -0,0 +1,28 @@+module Stackctl.AWS.CloudFormationSpec+  ( spec+  ) where++import Stackctl.Prelude++import Data.List (isSuffixOf)+import Stackctl.AWS.CloudFormation+import Test.Hspec++spec :: Spec+spec = do+  describe "readParameter" $ do+    it "refuses empty key" $ do+      readParameter "=Value"+        `shouldSatisfy` either ("empty KEY" `isSuffixOf`) (const False)++    it "refuses empty value" $ do+      readParameter "Key"+        `shouldSatisfy` either ("empty VALUE" `isSuffixOf`) (const False)++    it "refuses empty value (with =)" $ do+      readParameter "Key="+        `shouldSatisfy` either ("empty VALUE" `isSuffixOf`) (const False)++    it "creates a parameter when valid" $ do+      readParameter "Key=Value=More"+        `shouldBe` Right (makeParameter "Key" $ Just "Value=More")
+ test/Stackctl/FilterOptionSpec.hs view
@@ -0,0 +1,35 @@+module Stackctl.FilterOptionSpec+  ( spec+  ) where++import Stackctl.Prelude++import Stackctl.FilterOption+import Test.Hspec++spec :: Spec+spec = do+  describe "filterFilePaths" $ do+    it "filters paths matching any of the given patterns" $ do+      let+        option =+          filterOptionFromPaths $ "some-path" :| ["prefix/*", "**/suffix"]+        paths =+          [ "some-path"+          , "some-path-other"+          , "other-some-path"+          , "prefix/foo"+          , "prefix/foo-bar"+          , "prefix/foo-bar/prefix"+          , "foo/suffix"+          , "foo/bar/suffix"+          , "foo/suffix/bar"+          ]++      filterFilePaths option paths+        `shouldMatchList` [ "some-path"+                          , "prefix/foo"+                          , "prefix/foo-bar"+                          , "foo/suffix"+                          , "foo/bar/suffix"+                          ]
+ test/Stackctl/StackSpecSpec.hs view
@@ -0,0 +1,50 @@+module Stackctl.StackSpecSpec+  ( spec+  ) where++import Stackctl.Prelude++import Stackctl.AWS+import Stackctl.AWS.Scope+import Stackctl.StackSpec+import Stackctl.StackSpecPath+import Stackctl.StackSpecYaml+import Test.Hspec++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" []+          ]++      map specName (sortStackSpecs specs)+        `shouldBe` ["iam", "roles", "networking", "app"]++toSpec :: Text -> [Text] -> StackSpec+toSpec name depends = buildStackSpec "." specPath specBody+ where+  stackName = StackName name+  specPath = stackSpecPath scope stackName "a/b.yaml"+  specBody = StackSpecYaml+    { ssyDepends = Just $ map StackName depends+    , ssyActions = Nothing+    , ssyTemplate = ""+    , ssyParameters = Nothing+    , ssyCapabilities = Nothing+    , ssyTags = Nothing+    }++  scope = AwsScope+    { awsAccountId = AccountId ""+    , awsAccountName = ""+    , awsRegion = Region' ""+    }++specName :: StackSpec -> Text+specName = unStackName . stackSpecStackName
+ test/Stackctl/StackSpecYamlSpec.hs view
@@ -0,0 +1,75 @@+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}++module Stackctl.StackSpecYamlSpec+  ( spec+  ) where++import Stackctl.Prelude++import qualified Data.Yaml as Yaml+import Stackctl.AWS+import Stackctl.StackSpecYaml+import Test.Hspec++spec :: Spec+spec = do+  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 [ParameterYaml param] = 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 [ParameterYaml param] = 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 [ParameterYaml param] = 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 "also accepts CloudGenesis formatted values" $ do+      StackSpecYaml {..} <- Yaml.decodeThrow $ mconcat+        [ "Template: foo.yaml\n"+        , "Parameters:\n"+        , "  - Name: Foo\n"+        , "    Value: Bar\n"+        ]++      let Just [ParameterYaml param] = ssyParameters+      param ^. parameter_parameterKey `shouldBe` Just "Foo"+      param ^. parameter_parameterValue `shouldBe` Just "Bar"