packages feed

stackctl-1.6.0.0: src/Stackctl/Spec/Discover.hs

module Stackctl.Spec.Discover
  ( forEachSpec_
  , discoverSpecs
  , buildSpecPath
  ) where

import Stackctl.Prelude

import Data.List.Extra (dropPrefix)
import qualified Data.List.NonEmpty as NE
import qualified Data.List.NonEmpty.Extra as NE
import Data.Text.Metrics (levenshtein)
import Stackctl.AWS
import Stackctl.AWS.Scope
import Stackctl.Config (HasConfig)
import Stackctl.DirectoryOption (HasDirectoryOption (..), unDirectoryOption)
import Stackctl.FilterOption (HasFilterOption (..), filterStackSpecs)
import Stackctl.StackSpec
import Stackctl.StackSpecPath
import System.FilePath (isPathSeparator)
import System.FilePath.Glob

forEachSpec_
  :: ( MonadIO m
     , MonadMask m
     , MonadLogger m
     , MonadReader env m
     , HasAwsScope env
     , HasConfig env
     , HasDirectoryOption env
     , HasFilterOption env
     )
  => (StackSpec -> m ())
  -> m ()
forEachSpec_ f = traverse_ f =<< discoverSpecs

discoverSpecs
  :: ( MonadIO m
     , MonadMask m
     , MonadLogger m
     , MonadReader env m
     , HasAwsScope env
     , HasConfig env
     , HasDirectoryOption env
     , HasFilterOption env
     )
  => m [StackSpec]
discoverSpecs = do
  dir <- unDirectoryOption <$> view directoryOptionL
  scope <- view awsScopeL
  paths <- globRelativeTo dir $ awsScopeSpecPatterns scope
  filterOption <- view filterOptionL

  let
    toSpecPath = stackSpecPathFromFilePath scope
    (errs, specPaths) = partitionEithers $ map toSpecPath paths

    context =
      [ "path" .= dir
      , "filters" .= filterOption
      , "paths" .= length paths
      , "errors" .= length errs
      , "specs" .= length specPaths
      ]

  withThreadContext context $ do
    checkForDuplicateStackNames specPaths

    mAllSpecs <- NE.nonEmpty <$> traverse (readStackSpec dir) specPaths

    case mAllSpecs of
      Nothing -> do
        []
          <$ logWarn
            ( "Missing or empty specification directory"
                :# [ "directory" .= dir
                   , "hint" .= ("Is this the correct directory?" :: Text)
                   ]
            )
      Just allSpecs -> do
        let
          known = stackSpecStackName <$> allSpecs
          specs =
            sortStackSpecs
              $ filterStackSpecs filterOption
              $ NE.toList allSpecs

        traverse_ (checkForUnknownDepends known) specs
        when (null specs) $ logWarn "No specs matched filters"
        specs <$ logDebug ("Discovered specs" :# ["matched" .= length specs])

checkForDuplicateStackNames
  :: (MonadIO m, MonadLogger m) => [StackSpecPath] -> m ()
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

-- | Warn if a 'StackSpec' depends on a name not in the given 'StackName's
--
-- The 'StackName's are built from all specs, but we only run this with specs
-- that are filtered in.
--
-- NB. This function is written so it can easily be made into a fatal error
-- (like 'checkForDuplicateStackNames'), but we only warn for now.
checkForUnknownDepends
  :: MonadLogger m => NonEmpty StackName -> StackSpec -> m ()
checkForUnknownDepends known spec =
  traverse_ reportUnknownDepends
    $ NE.nonEmpty
    $ filter (`notElem` known)
    $ stackSpecDepends spec
 where
  reportUnknownDepends depends = do
    for_ depends $ \depend -> do
      let (nearest, _distance) =
            NE.minimumBy1 (comparing snd)
              $ (id &&& getDistance depend)
              <$> known

      logWarn
        $ "Stack lists dependency that does not exist"
        :# [ "dependency"
              .= ( unStackName (stackSpecStackName spec)
                    <> " -> "
                    <> unStackName depend
                 )
           , "hint" .= ("Did you mean " <> unStackName nearest <> "?")
           ]

  getDistance = levenshtein `on` unStackName

buildSpecPath
  :: (MonadReader env m, HasAwsScope env)
  => StackName
  -> 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