stackctl 1.6.1.0 → 1.6.1.1
raw patch · 5 files changed
+177/−6 lines, 5 filesdep +http-types
Dependencies added: http-types
Files
- CHANGELOG.md +5/−1
- src/Stackctl/RemovedStack.hs +8/−3
- stackctl.cabal +7/−1
- test/Stackctl/RemovedStackSpec.hs +117/−0
- test/Stackctl/Test/App.hs +40/−1
CHANGELOG.md view
@@ -1,4 +1,8 @@-## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.6.1.0...main)+## [_Unreleased_](https://github.com/freckle/stackctl/compare/v1.6.1.1...main)++## [v1.6.1.1](https://github.com/freckle/stackctl/compare/v1.6.1.0...v1.6.1.1)++- Fix: finding removed stacks now respects `STACKCTL_DIRECTORY` ## [v1.6.1.0](https://github.com/freckle/stackctl/compare/v1.6.0.0...v1.6.1.0)
src/Stackctl/RemovedStack.hs view
@@ -9,6 +9,7 @@ import Stackctl.AWS.CloudFormation import Stackctl.AWS.Core as AWS import Stackctl.AWS.Scope+import Stackctl.DirectoryOption import Stackctl.FilterOption import UnliftIO.Directory (doesFileExist) @@ -17,26 +18,30 @@ , MonadAWS m , MonadReader env m , HasAwsScope env+ , HasDirectoryOption env , HasFilterOption env ) => m [Stack] inferRemovedStacks = do scope <- view awsScopeL paths <- view $ filterOptionL . to filterOptionToPaths- catMaybes <$> traverse (findRemovedStack scope) paths+ dir <- view $ directoryOptionL . to unDirectoryOption+ catMaybes <$> traverse (findRemovedStack scope dir) paths findRemovedStack :: (MonadUnliftIO m, MonadAWS m) => AwsScope -> FilePath+ -- ^ Root directory+ -> FilePath -> m (Maybe Stack)-findRemovedStack scope path = runMaybeT $ do+findRemovedStack scope dir path = runMaybeT $ do -- The filter is a full path to a specification in the current -- account/region... stackName <- hoistMaybe $ awsScopeSpecStackName scope path -- that no longer exists...- guard . not =<< doesFileExist path+ guard . not =<< doesFileExist (dir </> path) -- but the Stack it would point to does MaybeT $ awsCloudFormationDescribeStackMaybe stackName
stackctl.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.18 name: stackctl-version: 1.6.1.0+version: 1.6.1.1 license: MIT license-file: LICENSE copyright: 2022 Renaissance Learning Inc@@ -165,6 +165,7 @@ Stackctl.ConfigSpec Stackctl.FilterOptionSpec Stackctl.OneOrListOfSpec+ Stackctl.RemovedStackSpec Stackctl.Spec.Changes.FormatSpec Stackctl.StackDescriptionSpec Stackctl.StackSpecSpec@@ -195,6 +196,7 @@ Glob >=0.10.2, QuickCheck >=2.14.2, aeson >=2.0.3.0,+ amazonka >=2.0, amazonka-cloudformation >=2.0, amazonka-ec2 >=2.0, amazonka-lambda >=2.0,@@ -205,7 +207,11 @@ hspec >=2.9.7, hspec-expectations-lifted >=0.10.0, hspec-golden >=0.2.1.0,+ http-types >=0.12.3, lens >=5.1.1, mtl >=2.2.2, stackctl,+ text >=1.2.5.0,+ time >=1.11.1.1,+ unliftio >=0.2.25.0, yaml >=0.11.8.0
+ test/Stackctl/RemovedStackSpec.hs view
@@ -0,0 +1,117 @@+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}++module Stackctl.RemovedStackSpec+ ( spec+ ) where++import Stackctl.Test.App++import qualified Amazonka+import qualified Amazonka.CloudFormation as CloudFormation+import Amazonka.CloudFormation.DescribeStacks+import Amazonka.CloudFormation.Types.Stack+import qualified Data.Text as T+import Data.Time (UTCTime (..))+import Data.Time.Calendar (DayOfMonth, MonthOfYear, Year, fromGregorian)+import Network.HTTP.Types.Status (status400)+import Stackctl.AWS.CloudFormation+import Stackctl.DirectoryOption (DirectoryOption (..), directoryOptionL)+import Stackctl.FilterOption (filterOptionFromText, filterOptionL)+import Stackctl.RemovedStack+import UnliftIO.Directory (createDirectoryIfMissing)++spec :: Spec+spec = do+ describe "inferRemovedStacks" $ do+ it "returns stacks in filters that aren't on disk" $ example $ runTestAppT $ do+ let+ Just filterOption =+ filterOptionFromText+ $ T.intercalate+ ","+ [ pack $ testAppStackFilePath "stack-exists"+ , pack $ testAppStackFilePath "stack-is-missing"+ , "stacks/0123456789.test/us-east-2/wrong-region.yaml"+ , "stacks/2123456789.test/us-east-1/wrong-account.yaml"+ ]++ setup :: TestApp -> TestApp+ setup = filterOptionL .~ filterOption++ matchers =+ [ describeStackMatcher "stack-exists" $ Just $ someStack "stack-exists"+ , describeStackMatcher "stack-is-missing" Nothing+ , describeStackMatcher "wrong-region" Nothing+ , describeStackMatcher "wrong-account" Nothing+ ]++ stacks <- local setup $ withMatchers matchers inferRemovedStacks+ map (^. stack_stackName) stacks `shouldBe` ["stack-exists"]++ -- If we don't check for file existence respecting STACKCTL_DIRECTORY, then+ -- any non-default value will cause all specs to appear non-existent and be+ -- flagged for removal. Eek.+ it "respects STACKCTL_DIRECTORY" $ example $ runTestAppT $ do+ let+ dir = "/tmp/stackctl-test"+ toRemove = "stack-to-remove"+ toKeep = "stack-to-keep"+ relativeToRemove = testAppStackFilePath toRemove+ relativeToKeep = testAppStackFilePath toKeep+ absoluteToKeep = dir </> relativeToKeep+ Just filterOption =+ filterOptionFromText+ $ pack relativeToRemove+ <> ","+ <> pack relativeToKeep++ setup :: TestApp -> TestApp+ setup app =+ app+ & filterOptionL .~ filterOption+ & directoryOptionL .~ DirectoryOption dir++ matchers =+ [ describeStackMatcher toRemove $ Just $ someStack toRemove+ , describeStackMatcher toKeep $ Just $ someStack toKeep+ ]++ -- Create a spec on disk for toKeep, then we should only find toRemove+ createDirectoryIfMissing True $ takeDirectory absoluteToKeep+ writeFileUtf8 absoluteToKeep "{}"++ stacks <- local setup $ withMatchers matchers inferRemovedStacks+ map (^. stack_stackName) stacks `shouldBe` [toRemove]++describeStackMatcher :: Text -> Maybe Stack -> Matcher+describeStackMatcher name =+ SendMatcher ((== Just name) . (^. describeStacks_stackName))+ . maybe+ (Left cloudFormationValidationError)+ ( \stack ->+ Right+ $ newDescribeStacksResponse 200+ & describeStacksResponse_stacks ?~ [stack]+ )++someStack :: Text -> Stack+someStack name = newStack name (midnight 2024 1 1) StackStatus_CREATE_COMPLETE++midnight :: Year -> MonthOfYear -> DayOfMonth -> UTCTime+midnight y m d =+ UTCTime+ { utctDay = fromGregorian y m d+ , utctDayTime = 0+ }++cloudFormationValidationError :: Amazonka.Error+cloudFormationValidationError =+ Amazonka.ServiceError+ $ Amazonka.ServiceError'+ { Amazonka.abbrev = CloudFormation.defaultService ^. Amazonka.service_abbrev+ , Amazonka.status = status400+ , Amazonka.headers = []+ , Amazonka.code = "ValidationError"+ , Amazonka.message = Nothing+ , Amazonka.requestId = Nothing+ }
test/Stackctl/Test/App.hs view
@@ -1,5 +1,8 @@ module Stackctl.Test.App- ( TestAppT+ ( TestApp+ , testAppAwsScope+ , testAppStackFilePath+ , TestAppT , runTestAppT -- * Re-exports@@ -16,12 +19,19 @@ import Control.Lens ((?~)) import Control.Monad.AWS import Control.Monad.AWS.ViaMock+import Stackctl.AWS.Core (AccountId (..))+import Stackctl.AWS.Scope+import Stackctl.DirectoryOption+import Stackctl.FilterOption import Test.Hspec (Spec, describe, example, it) import Test.Hspec.Expectations.Lifted data TestApp = TestApp { taLogger :: Logger , taMatchers :: Matchers+ , taAwsScope :: AwsScope+ , taFilterOption :: FilterOption+ , taDirectoryOption :: DirectoryOption } instance HasLogger TestApp where@@ -30,6 +40,15 @@ instance HasMatchers TestApp where matchersL = lens taMatchers $ \x y -> x {taMatchers = y} +instance HasAwsScope TestApp where+ awsScopeL = lens taAwsScope $ \x y -> x {taAwsScope = y}++instance HasFilterOption TestApp where+ filterOptionL = lens taFilterOption $ \x y -> x {taFilterOption = y}++instance HasDirectoryOption TestApp where+ directoryOptionL = lens taDirectoryOption $ \x y -> x {taDirectoryOption = y}+ newtype TestAppT m a = TestAppT { unTestAppT :: ReaderT TestApp (LoggingT m) a }@@ -53,5 +72,25 @@ TestApp <$> newTestLogger defaultLogSettings <*> pure mempty+ <*> pure testAppAwsScope+ <*> pure defaultFilterOption+ <*> pure defaultDirectoryOption runLoggerLoggingT app $ runReaderT (unTestAppT f) app++testAppAwsScope :: AwsScope+testAppAwsScope =+ AwsScope+ { awsAccountId = AccountId "0123456789"+ , awsAccountName = "test"+ , awsRegion = "us-east-1"+ }++-- | Gives a filepath relative to 'testAwsScope'+testAppStackFilePath :: Text -> FilePath+testAppStackFilePath base =+ "stacks"+ </> "0123456789.test"+ </> "us-east-1"+ </> unpack base+ <.> "yaml"