stackctl 1.4.0.0 → 1.4.0.1
raw patch · 10 files changed
+231/−44 lines, 10 filesdep +transformers
Dependencies added: transformers
Files
- CHANGELOG.md +6/−1
- src/Stackctl/AWS/Scope.hs +41/−0
- src/Stackctl/FilterOption.hs +12/−6
- src/Stackctl/RemovedStack.hs +43/−0
- src/Stackctl/Spec/Changes.hs +14/−11
- src/Stackctl/Spec/Changes/Format.hs +7/−0
- src/Stackctl/Spec/Deploy.hs +46/−4
- src/Stackctl/Spec/Discover.hs +2/−21
- stackctl.cabal +4/−1
- test/Stackctl/AWS/ScopeSpec.hs +56/−0
CHANGELOG.md view
@@ -1,4 +1,9 @@-## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.4.0.0...main)+## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.4.0.1...main)++## [v1.4.0.1](https://github.com/freckle/stackctl/compare/v1.4.0.0...v1.4.0.1)++- Document and read a consistently-named `STACKCTL_FILTER` for `--filter`. For+ now, the old and incorrect `STACKCTL_FILTERS` will still work. ## [v1.4.0.0](https://github.com/freckle/stackctl/compare/v1.3.0.2...v1.4.0.0)
src/Stackctl/AWS/Scope.hs view
@@ -1,13 +1,18 @@ module Stackctl.AWS.Scope ( AwsScope(..)+ , awsScopeSpecPatterns+ , awsScopeSpecStackName , HasAwsScope(..) , fetchAwsScope ) where import Stackctl.Prelude +import qualified Data.Text as T import Stackctl.AWS import System.Environment (lookupEnv)+import System.FilePath (joinPath, splitPath)+import System.FilePath.Glob (Pattern, compile, match) data AwsScope = AwsScope { awsAccountId :: AccountId@@ -16,6 +21,42 @@ } deriving stock (Eq, Show, Generic) deriving anyclass ToJSON++awsScopeSpecPatterns :: AwsScope -> [Pattern]+awsScopeSpecPatterns AwsScope {..} =+ [ compile+ $ "stacks"+ </> unpack (unAccountId awsAccountId)+ <> ".*"+ </> unpack (fromRegion awsRegion)+ <> "**"+ </> "*"+ <.> "yaml"+ , compile+ $ "stacks"+ </> "*."+ <> unpack (unAccountId awsAccountId)+ </> unpack (fromRegion awsRegion)+ <> "**"+ </> "*"+ <.> "yaml"+ ]++awsScopeSpecStackName :: AwsScope -> FilePath -> Maybe StackName+awsScopeSpecStackName scope path = do+ guard $ any (`match` path) $ awsScopeSpecPatterns scope++ -- once we've guarded that the path matches our scope patterns, we can play it+ -- pretty fast and loose with the "parsing" step+ pure+ $ path -- stacks/account/region/x/y.yaml+ & splitPath -- [stacks/, account/, region/, x/, y.yaml]+ & drop 3 -- [x, y.yaml]+ & joinPath -- x/y.yaml+ & dropExtension -- x/y+ & pack+ & T.replace "/" "-" -- x-y+ & StackName class HasAwsScope env where awsScopeL :: Lens' env AwsScope
src/Stackctl/FilterOption.hs view
@@ -6,6 +6,7 @@ , filterOption , filterOptionFromPaths , filterOptionFromText+ , filterOptionToPaths , filterStackSpecs ) where @@ -37,12 +38,14 @@ filterOptionL = id envFilterOption :: String -> Env.Parser Env.Error FilterOption-envFilterOption items =- Env.var (first Env.UnreadError . readFilterOption) "FILTERS"- $ Env.help- $ "Filter "- <> items- <> " by patterns"+envFilterOption items = var "FILTERS" <|> var "FILTER"+ where+ var name =+ Env.var (first Env.UnreadError . readFilterOption) name+ $ Env.help+ $ "Filter "+ <> items+ <> " by patterns" filterOption :: String -> Parser FilterOption filterOption items = option (eitherReader readFilterOption) $ mconcat@@ -92,6 +95,9 @@ defaultFilterOption :: FilterOption defaultFilterOption = filterOptionFromPaths $ pure "**/*"++filterOptionToPaths :: FilterOption -> [FilePath]+filterOptionToPaths = map decompile . NE.toList . unFilterOption filterStackSpecs :: FilterOption -> [StackSpec] -> [StackSpec] filterStackSpecs fo =
+ src/Stackctl/RemovedStack.hs view
@@ -0,0 +1,43 @@+module Stackctl.RemovedStack+ ( inferRemovedStacks+ ) where++import Stackctl.Prelude++import Control.Error.Util (hoistMaybe)+import Control.Monad.Trans.Maybe (MaybeT(..), runMaybeT)+import Stackctl.AWS.CloudFormation+import Stackctl.AWS.Core+import Stackctl.AWS.Scope+import Stackctl.FilterOption+import UnliftIO.Directory (doesFileExist)++inferRemovedStacks+ :: ( MonadUnliftIO m+ , MonadResource m+ , MonadReader env m+ , HasAwsEnv env+ , HasAwsScope env+ , HasFilterOption env+ )+ => m [Stack]+inferRemovedStacks = do+ scope <- view awsScopeL+ paths <- view $ filterOptionL . to filterOptionToPaths+ catMaybes <$> traverse (findRemovedStack scope) paths++findRemovedStack+ :: (MonadUnliftIO m, MonadResource m, MonadReader env m, HasAwsEnv env)+ => AwsScope+ -> FilePath+ -> m (Maybe Stack)+findRemovedStack scope path = runMaybeT $ do+ -- The filter is a full path to a specification in the current+ -- account/region...+ stackName <- hoistMaybe $ awsScopeSpecStackName scope path++ -- that no longer exists...+ guard . not =<< doesFileExist path++ -- but the Stack it would point to does+ MaybeT $ awsCloudFormationDescribeStackMaybe stackName
src/Stackctl/Spec/Changes.hs view
@@ -16,6 +16,7 @@ import Stackctl.DirectoryOption (HasDirectoryOption) import Stackctl.FilterOption (HasFilterOption) import Stackctl.ParameterOption+import Stackctl.RemovedStack import Stackctl.Spec.Changes.Format import Stackctl.Spec.Discover import Stackctl.StackSpec@@ -63,6 +64,15 @@ -- Clear file before starting, as we have to use append for each spec liftIO $ traverse_ (`T.writeFile` "") scoOutput + colors <- case scoOutput of+ Nothing -> getColorsLogger+ Just{} -> pure noColors++ let+ write formatted = case scoOutput of+ Nothing -> pushLoggerLn formatted+ Just p -> liftIO $ T.appendFile p $ formatted <> "\n"+ specs <- discoverSpecs for_ specs $ \spec -> do@@ -74,15 +84,8 @@ logError $ "Error creating ChangeSet" :# ["error" .= err] exitFailure Right mChangeSet -> do- colors <- case scoOutput of- Nothing -> getColorsLogger- Just{} -> pure noColors-- let- name = pack $ stackSpecPathFilePath $ stackSpecSpecPath spec- formatted =- formatChangeSet colors scoOmitFull name scoFormat mChangeSet+ let name = pack $ stackSpecPathFilePath $ stackSpecSpecPath spec+ write $ formatChangeSet colors scoOmitFull name scoFormat mChangeSet - case scoOutput of- Nothing -> pushLoggerLn formatted- Just p -> liftIO $ T.appendFile p $ formatted <> "\n"+ removed <- inferRemovedStacks+ traverse_ (write . formatRemovedStack colors scoFormat) removed
src/Stackctl/Spec/Changes/Format.hs view
@@ -4,6 +4,7 @@ , OmitFull(..) , omitFullOption , formatChangeSet+ , formatRemovedStack , formatTTY ) where @@ -56,6 +57,12 @@ formatChangeSet colors omitFull name = \case FormatTTY -> formatTTY colors name FormatPullRequest -> formatPullRequest omitFull name++formatRemovedStack :: Colors -> Format -> Stack -> Text+formatRemovedStack Colors {..} format stack = case format of+ FormatTTY -> red "DELETE" <> " stack " <> cyan name+ FormatPullRequest -> ":x: This PR will **delete** the stack `" <> name <> "`"+ where name = stack ^. stack_stackName formatTTY :: Colors -> Text -> Maybe ChangeSet -> Text formatTTY colors@Colors {..} name mChangeSet = case (mChangeSet, rChanges) of
src/Stackctl/Spec/Deploy.hs view
@@ -20,6 +20,7 @@ import Stackctl.FilterOption (HasFilterOption) import Stackctl.ParameterOption import Stackctl.Prompt+import Stackctl.RemovedStack import Stackctl.Spec.Changes.Format import Stackctl.Spec.Discover import Stackctl.StackSpec@@ -31,6 +32,7 @@ , sdoTags :: [Tag] , sdoSaveChangeSets :: Maybe FilePath , sdoDeployConfirmation :: DeployConfirmation+ , sdoRemovals :: Bool , sdoClean :: Bool } @@ -50,6 +52,10 @@ ( long "no-confirm" <> help "Don't confirm changes before executing" )+ <*> (not <$> switch+ ( long "no-remove"+ <> help "Don't delete removed Stacks"+ )) <*> switch ( long "clean" <> help "Remove all changesets from Stack after deploy"@@ -98,6 +104,35 @@ runActions stackName PostDeploy $ stackSpecActions spec when sdoClean $ awsCloudFormationDeleteAllChangeSets stackName + when sdoRemovals $ do+ removed <- inferRemovedStacks+ traverse_ (deleteRemovedStack sdoDeployConfirmation) removed++deleteRemovedStack+ :: ( MonadMask m+ , MonadResource m+ , MonadLogger m+ , MonadReader env m+ , HasLogger env+ , HasAwsEnv env+ )+ => DeployConfirmation+ -> Stack+ -> m ()+deleteRemovedStack confirmation stack = do+ withThreadContext ["stack" .= stackName] $ do+ colors <- getColorsLogger+ pushLoggerLn $ formatRemovedStack colors FormatTTY stack++ case confirmation of+ DeployWithConfirmation -> do+ promptContinue+ logInfo "Deleting Stack"+ DeployWithoutConfirmation -> pure ()++ deleteStack stackName+ where stackName = StackName $ stack ^. stack_stackName+ data DeployConfirmation = DeployWithConfirmation | DeployWithoutConfirmation@@ -130,11 +165,18 @@ exitFailure logInfo "Deleting Stack"- result <- awsCloudFormationDeleteStack stackName+ deleteStack stackName - case result of- StackDeleteSuccess -> logInfo $ prettyStackDeleteResult result :# []- StackDeleteFailure{} -> logWarn $ prettyStackDeleteResult result :# []+deleteStack+ :: (MonadResource m, MonadLogger m, MonadReader env m, HasAwsEnv env)+ => StackName+ -> m ()+deleteStack stackName = do+ result <- awsCloudFormationDeleteStack stackName++ case result of+ StackDeleteSuccess -> logInfo $ prettyStackDeleteResult result :# []+ StackDeleteFailure{} -> logWarn $ prettyStackDeleteResult result :# [] deployChangeSet :: ( MonadUnliftIO m
src/Stackctl/Spec/Discover.hs view
@@ -30,27 +30,8 @@ => m [StackSpec] discoverSpecs = do dir <- unDirectoryOption <$> view directoryOptionL- scope@AwsScope {..} <- view awsScopeL- paths <- globRelativeTo- dir- [ compile- $ "stacks"- </> unpack (unAccountId awsAccountId)- <> ".*"- </> unpack (fromRegion awsRegion)- <> "**"- </> "*"- <.> "yaml"- , compile- $ "stacks"- </> "*."- <> unpack (unAccountId awsAccountId)- </> unpack (fromRegion awsRegion)- <> "**"- </> "*"- <.> "yaml"- ]-+ scope <- view awsScopeL+ paths <- globRelativeTo dir $ awsScopeSpecPatterns scope filterOption <- view filterOptionL let
stackctl.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.18 name: stackctl-version: 1.4.0.0+version: 1.4.0.1 license: MIT license-file: LICENSE copyright: 2022 Renaissance Learning Inc@@ -41,6 +41,7 @@ Stackctl.ParameterOption Stackctl.Prelude Stackctl.Prompt+ Stackctl.RemovedStack Stackctl.Sort Stackctl.Spec.Capture Stackctl.Spec.Cat@@ -111,6 +112,7 @@ semigroups, text, time,+ transformers, unliftio, unliftio-core, unordered-containers,@@ -149,6 +151,7 @@ hs-source-dirs: test other-modules: Stackctl.AWS.CloudFormationSpec+ Stackctl.AWS.ScopeSpec Stackctl.Config.RequiredVersionSpec Stackctl.ConfigSpec Stackctl.FilterOptionSpec
+ test/Stackctl/AWS/ScopeSpec.hs view
@@ -0,0 +1,56 @@+module Stackctl.AWS.ScopeSpec+ ( spec+ ) where++import Stackctl.Prelude++import Stackctl.AWS.CloudFormation+import Stackctl.AWS.Core+import Stackctl.AWS.Scope+import Test.Hspec++spec :: Spec+spec = do+ describe "awsScopeSpecStackName" $ do+ let+ scope = AwsScope+ { awsAccountId = AccountId "123"+ , awsAccountName = "testing"+ , awsRegion = "us-east-1"+ }++ it "parses full paths to stacks in the current scope" $ do+ awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo.yaml"+ `shouldBe` Just (StackName "foo")++ it "parses name.account style too" $ do+ awsScopeSpecStackName scope "stacks/testing.123/us-east-1/foo.yaml"+ `shouldBe` Just (StackName "foo")++ it "handles sub-directories" $ do+ awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo/bar.yaml"+ `shouldBe` Just (StackName "foo-bar")++ it "handles mismatched name" $ do+ awsScopeSpecStackName scope "stacks/123.x/us-east-1/foo.yaml"+ `shouldBe` Just (StackName "foo")++ it "handles mismatched name in name.account style" $ do+ awsScopeSpecStackName scope "stacks/x.123/us-east-1/foo.yaml"+ `shouldBe` Just (StackName "foo")++ it "avoids wrong region" $ do+ awsScopeSpecStackName scope "stacks/123.testing/us-east-2/foo.yaml"+ `shouldBe` Nothing++ it "avoids arong account id" $ do+ awsScopeSpecStackName scope "stacks/124.testing/us-east-1/foo.yaml"+ `shouldBe` Nothing++ it "requires a stacks/ prefix" $ do+ awsScopeSpecStackName scope "123.testing/us-east-1/foo.yaml"+ `shouldBe` Nothing++ it "requires a .yaml suffix" $ do+ awsScopeSpecStackName scope "stacks/123.testing/us-east-1/foo.yml"+ `shouldBe` Nothing