hwm-0.5.0: src/HWM/Domain/Environments.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HWM.Domain.Environments
( Environments (..),
EnvironmentProfile (..),
BuildEnvironment (..),
StackBuildConfig (..),
SyncMode (..),
TargetModes (..),
StackEnvironment (..),
getBuildEnvironments,
getBuildEnvironment,
hkgRefs,
printEnvironments,
getTestedRange,
removeEnvironmentByName,
existsEnvironment,
environmentHash,
NixEnvironment (..),
Feature (..),
selectEnvironments,
overrideBuilder,
mkEnvironments,
mkEnvironment,
addProfile,
)
where
import Control.Monad.Except (MonadError (..))
import Data.Aeson (FromJSON (..), ToJSON (toJSON), genericParseJSON, genericToJSON, object)
import Data.Aeson.Types (Value (..))
import Data.Foldable (Foldable (..))
import qualified Data.Map as M
import qualified Data.Map as Map
import qualified Data.Set as Set
import Data.Traversable (for)
import HWM.Core.Common
( Check (..),
Name,
)
import HWM.Core.Formatting (Color (..), Format (..), availableOptions, chalk, formatList)
import HWM.Core.Has (Has (..), HasAll, askEnv)
import HWM.Core.Pkg (Pkg (..), PkgName)
import HWM.Core.Result (Issue)
import HWM.Core.Sync (SyncMode (..))
import HWM.Core.Version (Era (..), Version, selectEra)
import HWM.Domain.Bounds (TestedRange (..))
import HWM.Domain.Build (Builder (..))
import HWM.Domain.Workspace (Workspace, WorkspaceRef, allPackages, checkWorkspaceRefs, isMember)
import HWM.Runtime.Cache (Cache, Registry (currentEnv), VersionMap, getLatestNightlySnapshot, getRegistry, getSnapshot, getVersions)
import HWM.Runtime.Files (Signature, aesonYAMLOptions, aesonYAMLOptionsAdvanced, genSignature)
import HWM.Runtime.UI (MonadUI, forTable_, sectionEnvironments)
import Relude
type Extras = VersionMap
data Environments = Environments
{ envsDefault :: Name,
envsProfiles :: Map Name EnvironmentProfile,
envsBuilder :: Maybe Builder,
envsTargets :: Maybe TargetsPolicy
}
deriving
( Generic,
Show
)
addProfile :: Name -> EnvironmentProfile -> Environments -> Environments
addProfile name profile envs = envs {envsProfiles = Map.insert name profile (envsProfiles envs)}
mkEnvironment :: Version -> EnvironmentProfile
mkEnvironment ghc =
EnvironmentProfile
{ profileGhc = ghc,
profileExclude = Nothing,
profileStack = Nothing,
profileNix = Nothing,
profileBuilder = Nothing,
profileTargets = Nothing
}
mkEnvironments :: Version -> Environments
mkEnvironments ghc =
let defaultEnv = ("default", mkEnvironment ghc)
in Environments
{ envsDefault = fst defaultEnv,
envsProfiles = Map.fromList [defaultEnv],
envsTargets = Nothing,
envsBuilder = Nothing
}
environmentHash :: Environments -> Signature
environmentHash Environments {..} =
genSignature $ Set.toList $ Set.fromList $ map toSig $ concatMap Map.toList $ mapMaybe (extraDeps <=< unfeature <=< profileStack) (toList envsProfiles)
where
toSig (pkg, v) = format pkg <> "-" <> format v
prefix :: String
prefix = "envs"
instance FromJSON Environments where
parseJSON = genericParseJSON (aesonYAMLOptionsAdvanced prefix)
instance ToJSON Environments where
toJSON = genericToJSON (aesonYAMLOptionsAdvanced prefix)
instance
( MonadError Issue m,
MonadReader env m,
Has env Environments,
Has env Workspace,
Has env Cache,
Has env Signature,
MonadIO m
) =>
Check m Environments
where
check Environments {..} = do
fileSig <- askEnv
traverse_ (checkTarget fileSig) envsProfiles
where
signature = environmentHash Environments {..}
checkTarget fileSig EnvironmentProfile {..}
| fileSig == signature = checkExclude
-- checking all hkgRefs is expensive, so we skip it if the signature matches
| otherwise = sequence_ [traverse_ check (maybe [] hkgRefs (extraDeps =<< unfeature =<< profileStack)), checkExclude]
where
checkExclude = checkWorkspaceRefs (fromMaybe [] profileExclude)
data Feature a = Enabled a | Disabled deriving (Generic, Show, Ord, Eq)
unfeature :: Feature a -> Maybe a
unfeature (Enabled a) = Just a
unfeature Disabled = Nothing
instance FromJSON (Feature StackEnvironment) where
parseJSON (Bool b) = pure $ if b then Enabled (StackEnvironment Nothing Nothing Nothing) else Disabled
parseJSON v = Enabled <$> parseJSON v
instance FromJSON (Feature NixEnvironment) where
parseJSON (Bool b) = pure $ if b then Enabled NixEnvironment else Disabled
parseJSON v = Enabled <$> parseJSON v
instance (ToJSON a) => ToJSON (Feature a) where
toJSON (Enabled se) = toJSON se
toJSON Disabled = Bool False
data NixEnvironment = NixEnvironment deriving (Generic, Show, Ord, Eq)
instance ToJSON NixEnvironment where
toJSON NixEnvironment = object []
instance FromJSON NixEnvironment where
parseJSON (Object _) = pure NixEnvironment
parseJSON _ = fail "Invalid Nix environment configuration. Expected an object or a boolean."
data EnvironmentProfile = EnvironmentProfile
{ profileGhc :: Version,
profileExclude :: Maybe [WorkspaceRef],
profileStack :: Maybe (Feature StackEnvironment),
profileNix :: Maybe (Feature NixEnvironment),
profileBuilder :: Maybe Builder,
profileTargets :: Maybe TargetsPolicy
}
deriving
( Generic,
Show,
Ord,
Eq
)
profilePrefix :: String
profilePrefix = "profile"
instance FromJSON EnvironmentProfile where
parseJSON = genericParseJSON (aesonYAMLOptionsAdvanced profilePrefix)
instance ToJSON EnvironmentProfile where
toJSON = genericToJSON (aesonYAMLOptionsAdvanced profilePrefix)
data StackEnvironment = StackEnvironment
{ resolver :: Maybe Name,
extraDeps :: Maybe Extras,
allowNewer :: Maybe Bool
}
deriving (Generic, Show, Ord, Eq)
instance FromJSON StackEnvironment where
parseJSON = genericParseJSON aesonYAMLOptions
instance ToJSON StackEnvironment where
toJSON = genericToJSON aesonYAMLOptions
data TargetsPolicy = TargetsPolicy
{ tpCabal :: Maybe SyncMode,
tpStack :: Maybe SyncMode,
tpNix :: Maybe SyncMode,
tpHie :: Maybe SyncMode,
tpPackages :: Maybe SyncMode
}
deriving (Generic, Show, Ord, Eq)
targetsPrefix :: String
targetsPrefix = "tp"
instance FromJSON TargetsPolicy where
parseJSON = genericParseJSON (aesonYAMLOptionsAdvanced targetsPrefix)
instance ToJSON TargetsPolicy where
toJSON = genericToJSON (aesonYAMLOptionsAdvanced targetsPrefix)
data TargetModes = TargetModes
{ targetCabal :: SyncMode,
targetStack :: SyncMode,
targetNix :: SyncMode,
targetHie :: SyncMode,
targetPackages :: SyncMode
}
deriving
( Generic,
Show,
Ord,
Eq
)
data StackBuildConfig = StackBuildConfig
{ stackResolver :: Name,
stackExtraDeps :: Maybe Extras,
stackAllowNewer :: Maybe Bool
}
deriving
( Generic,
Show,
Ord,
Eq
)
data BuildEnvironment = BuildEnvironment
{ buildGHC :: Version,
buildPkgs :: [Pkg],
buildName :: Name,
buildStack :: StackBuildConfig,
buildBuilder :: Builder,
buildTargets :: TargetModes
}
deriving
( Generic,
Show,
Ord,
Eq
)
overrideBuilder :: Builder -> BuildEnvironment -> BuildEnvironment
overrideBuilder builder env = env {buildBuilder = builder}
instance Format BuildEnvironment where
format BuildEnvironment {..} = buildName <> " (" <> format buildGHC <> ")"
instance Format TargetModes where
format TargetModes {..} =
let enabled :: [Text]
enabled =
["cabal" | active targetCabal]
<> ["stack" | active targetStack]
<> ["nix" | active targetNix]
<> ["hie" | active targetHie]
in "targets=" <> if null enabled then "-" else formatList "," enabled
where
active mode = mode /= SyncModeIgnore
deriveTargetModes :: Builder -> Bool -> Bool -> TargetModes
deriveTargetModes builder stackEnabled nixEnabled =
case builder of
CabalBuilder {inNixDevelopment = False} -> TargetModes {targetCabal = SyncModeSync, targetStack = SyncModeIgnore, targetNix = SyncModeIgnore, targetHie = SyncModeSync, targetPackages = SyncModeSync}
CabalBuilder {inNixDevelopment = True} ->
TargetModes
{ targetCabal = SyncModeSync,
targetStack = SyncModeIgnore,
targetNix = if nixEnabled then SyncModeSync else SyncModeIgnore,
targetHie = SyncModeSync,
targetPackages = SyncModeSync
}
StackBuilder ->
TargetModes
{ targetCabal = SyncModeIgnore,
targetStack = if stackEnabled then SyncModeSync else SyncModeIgnore,
targetNix = SyncModeIgnore,
targetHie = SyncModeSync,
targetPackages = SyncModeSync
}
NixBuilder ->
TargetModes
{ targetCabal = SyncModeIgnore,
targetStack = SyncModeIgnore,
targetNix = if nixEnabled then SyncModeSync else SyncModeIgnore,
targetHie = SyncModeSync,
targetPackages = SyncModeSync
}
applyTargetsPolicy :: Maybe TargetsPolicy -> Maybe TargetsPolicy -> TargetModes -> TargetModes
applyTargetsPolicy globalPolicy profilePolicy defaults =
defaults
{ targetCabal = pick tpCabal (targetCabal defaults),
targetStack = pick tpStack (targetStack defaults),
targetNix = pick tpNix (targetNix defaults),
targetHie = pick tpHie (targetHie defaults),
targetPackages = pick tpPackages (targetPackages defaults)
}
where
pick getter fallback = fromMaybe fallback ((getter =<< profilePolicy) <|> (getter =<< globalPolicy))
getBuildEnvironments ::
( MonadReader env m,
Has env Environments,
Has env Workspace,
MonadIO m,
MonadError Issue m
) =>
m [BuildEnvironment]
getBuildEnvironments = do
globalEnv <- askEnv
envs <- envsProfiles <$> askEnv
for (Map.toList envs) $ \(name, env) -> do
pkgs <- allPackages
let builder = fromMaybe (CabalBuilder False) (profileBuilder env <|> envsBuilder globalEnv)
let stackEnabled = case profileStack env of
Just Disabled -> False
_ -> True
let nixEnabled = case profileNix env of
Just Disabled -> False
_ -> True
let targetModes = applyTargetsPolicy (envsTargets globalEnv) (profileTargets env) (deriveTargetModes builder stackEnabled nixEnabled)
pure
BuildEnvironment
{ buildPkgs = excludePkgs env pkgs,
buildName = name,
buildStack =
StackBuildConfig
{ stackExtraDeps = extraDeps =<< unfeature =<< profileStack env,
stackResolver = fromMaybe (eraStackageResolverName $ selectEra (profileGhc env)) (resolver =<< unfeature =<< profileStack env),
stackAllowNewer = profileStack env >>= unfeature >>= allowNewer
},
buildGHC = profileGhc env,
buildBuilder = builder,
buildTargets = targetModes
}
where
excludePkgs build pkgs =
let excluseion = fromMaybe [] (profileExclude build)
in filter (not . (\pkg -> any (`isMember` pkg) excluseion)) pkgs
getBuildEnvironment ::
( MonadReader env m,
Has env Environments,
Has env Workspace,
Has env Cache,
MonadIO m,
MonadError Issue m
) =>
Maybe Name ->
m BuildEnvironment
getBuildEnvironment inputName = do
envs <- getBuildEnvironments
defaultname <- envsDefault <$> askEnv
case inputName of
Just name -> matchEnv envs name (select envs name)
Nothing -> do
cachedName <- currentEnv <$> getRegistry
matchEnv envs defaultname (select envs cachedName <|> select envs defaultname)
where
select envs name = find ((name ==) . buildName) envs
matchEnv envs name = maybe (throwError $ fromString $ toString ("No matching Environment for input '" <> name <> "'! " <> availableOptions (map buildName envs))) pure
data HkgRef = HkgRef
{ pkgName :: PkgName,
pkgVersion :: Version
}
deriving (Eq, Ord)
instance
( MonadError Issue m,
MonadReader env m,
HasAll env [Workspace, Cache],
MonadIO m
) =>
Check m HkgRef
where
check HkgRef {..} = getVersions pkgName >>= checkElem (pkgVersion ==) . toList
where
checkElem f xs =
if isJust (find f xs)
then pure ()
else
throwError
$ fromString
$ toString
("No matching version for '" <> format pkgVersion <> "'! " <> availableOptions xs)
hkgRefs :: VersionMap -> [HkgRef]
hkgRefs = map (uncurry HkgRef) . M.toList
instance Format HkgRef where
format HkgRef {..} = format pkgName <> "-" <> format pkgVersion
existsEnvironment :: (MonadReader env m, Has env Environments) => Name -> m Bool
existsEnvironment n = do
envs <- envsProfiles <$> askEnv
pure $ isJust $ Map.lookup n envs
printEnvironments :: (Monad m, MonadUI m, MonadReader env m, Has env Workspace, Has env Environments, MonadIO m, MonadError Issue m, Has env Cache) => m ()
printEnvironments = do
active <- getBuildEnvironment Nothing
def <- envsDefault <$> askEnv
environments <- getBuildEnvironments
sectionEnvironments (Just $ format def) $ forTable_ environments $ \env ->
( format env,
pure
$ if env == active
then chalk Cyan (stackResolver (buildStack env) <> " (active)")
else chalk Gray (stackResolver (buildStack env))
)
getTestedRange :: (Monad m, MonadReader env m, Has env Workspace, Has env Environments, MonadIO m, MonadError Issue m) => m TestedRange
getTestedRange = do
env <- getBuildEnvironments
legacy <- getSnapshot (minimum $ map (stackResolver . buildStack) env)
nightly <- getLatestNightlySnapshot
pure TestedRange {legacy = legacy, nightly = nightly}
-- | Remove an environment from the matrix by name, with optional default migration.
removeEnvironmentByName :: (MonadError Issue m) => Name -> Maybe Name -> Environments -> m Environments
removeEnvironmentByName envName maybeNewDefault envs@Environments {..}
| not (Map.member envName envsProfiles) = throwError $ fromString $ "Environment '" <> toString envName <> "' does not exist."
| Map.size envsProfiles <= 1 = throwError "Cannot remove the last environment. Add another environment first."
| envName /= envsDefault && isJust maybeNewDefault = throwError "--set-default can only be used when removing the current default environment."
| otherwise =
let nextProfiles = Map.delete envName envsProfiles
in if envName /= envsDefault
then pure envs {envsProfiles = nextProfiles}
else case maybeNewDefault of
Nothing -> throwError "Removing the current default environment requires --set-default <ENV>."
Just newDefault
| newDefault == envName -> throwError "--set-default cannot point to the environment being removed."
| not (Map.member newDefault nextProfiles) -> throwError $ fromString $ "Environment '" <> toString newDefault <> "' does not exist after removal."
| otherwise -> pure envs {envsProfiles = nextProfiles, envsDefault = newDefault}
selectEnvironments :: (MonadError Issue m, MonadIO m, MonadReader env m, Has env Workspace, Has env Environments, Has env Cache) => [Name] -> m [BuildEnvironment]
selectEnvironments ["all"] = getBuildEnvironments
selectEnvironments [] = getBuildEnvironment Nothing >>= \env -> pure [env]
selectEnvironments names = for names (getBuildEnvironment . Just)