hwm-0.1.0: src/HWM/Runtime/Cache.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HWM.Runtime.Cache
( Cache,
Registry (..),
askCache,
getRegistry,
updateRegistry,
modifyCache,
loadCache,
saveCache,
getVersions,
Versions,
VersionMap,
prepareDir,
getSnapshotGHC,
Snapshot (..),
getSnapshot,
getVersion,
getLatestNightlySnapshot,
http,
)
where
import qualified Control.Concurrent.STM as STM
import Control.Monad.Except (MonadError (..))
import Data.Aeson (FromJSON, ToJSON, Value, eitherDecode, (.:))
import qualified Data.Aeson as Aeson
import Data.Aeson.Types (withObject)
import qualified Data.ByteString.Lazy as BL
import qualified Data.List.NonEmpty as NE
import qualified Data.Map as Map
import qualified Data.Text as T
import Data.Time (getCurrentTime, utctDay)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Yaml (Object, Parser, decodeEither', prettyPrintParseException)
import HWM.Core.Common (Name)
import HWM.Core.Formatting (Format (..))
import HWM.Core.Has (Has, askEnv)
import HWM.Core.Parsing (genUrl, parse, parsePkgString)
import HWM.Core.Pkg (PkgName (..))
import HWM.Core.Result (Issue, Result (..), ResultT (..))
import HWM.Core.Version (Version, parseGHCVersion)
import HWM.Runtime.Files (select)
import Network.HTTP.Req (GET (..), LbsResponse, NoReqBody (..), Option, Req, Url, defaultHttpConfig, lbsResponse, req, responseBody, runReq, useURI)
import Relude
import System.Directory (createDirectoryIfMissing, doesFileExist)
import Text.URI (mkURI)
askCache :: (MonadReader env m, Has env Cache) => m Cache
askCache = askEnv
data Registry = Registry
{ currentEnv :: Name,
versions :: Map PkgName Versions
}
deriving (Generic, Show, FromJSON, ToJSON)
newtype Cache = Cache (STM.TVar Registry)
type Versions = NonEmpty Version
type VersionMap = Map PkgName Version
cacheDir :: FilePath
cacheDir = ".hwm/cache"
path :: FilePath
path = cacheDir <> "/state.json"
initRegistry :: Name -> Registry
initRegistry t = Registry {currentEnv = t, versions = mempty}
loadCache :: Name -> IO Cache
loadCache t = do
exists <- doesFileExist path
vm <-
if exists
then do
bs <- BL.readFile path
case Aeson.decode bs of
Just vm' -> pure vm'
Nothing -> pure (initRegistry t)
else pure (initRegistry t)
Cache <$> STM.newTVarIO vm
readCache :: Cache -> IO Registry
readCache (Cache tvar) = STM.readTVarIO tvar
modifyCache :: (MonadIO m) => Cache -> (Registry -> Registry) -> m ()
modifyCache (Cache tvar) f = liftIO $ STM.atomically $ STM.modifyTVar' tvar f
saveCache :: Cache -> IO ()
saveCache cache = do
vm <- readCache cache
createDirectoryIfMissing True cacheDir
BL.writeFile path (Aeson.encode vm)
getRegistry :: (MonadReader env m, Has env Cache, MonadIO m) => m Registry
getRegistry = do
Cache tvar <- askCache
liftIO $ STM.readTVarIO tvar
updateRegistry :: (MonadReader env m, Has env Cache, MonadIO m) => (Registry -> Registry) -> m ()
updateRegistry f = do
c <- askCache
modifyCache c f
getReq :: (Url s, Option s) -> Req LbsResponse
getReq (u, o) = req GET u NoReqBody lbsResponse o
parseBody :: (MonadError Issue m) => Text -> m (Req LbsResponse)
parseBody url = do
uri <- maybe (throwError $ fromString $ "Invalid Endpoint: " <> toString url <> "!") pure (mkURI url >>= useURI)
pure (either getReq getReq uri)
http :: (MonadError Issue m, MonadIO m) => Text -> [Text] -> m BL.ByteString
http dom p = do
request <- parseBody (genUrl dom p)
responseBody <$> liftIO (runReq defaultHttpConfig request)
hackage :: (MonadIO m, MonadError Issue m) => Text -> m (Map Name (NonEmpty Version))
hackage name = http "https://hackage.haskell.org/package" [format name, "preferred.json"] >>= either (throwError . fromString) pure . eitherDecode
getVersions :: (MonadIO m, MonadError Issue m, MonadReader env m, Has env Cache) => PkgName -> m Versions
getVersions name = do
Cache tvar <- askCache
r <- liftIO $ STM.readTVarIO tvar
case Map.lookup name (versions r) of
Just vs -> pure vs
Nothing -> do
vs <- hackage (format name) >>= select "Field" "normal-version"
modifyCache (Cache tvar) (\reg -> reg {versions = Map.singleton name vs <> versions reg})
pure vs
prepareDir :: (MonadIO m) => FilePath -> m ()
prepareDir dir = liftIO $ createDirectoryIfMissing True dir
data Snapshot = Snapshot {snapshotCompiler :: Version, snapshotPackages :: Map PkgName Version}
deriving (Show)
parseValue :: Value -> Parser (PkgName, Version)
parseValue = withObject "Package" $ \v -> do
package <- v .: "hackage"
let (name, versionStr) = parsePkgString package
version <- parse versionStr
pure (PkgName name, version)
parseMap :: [Value] -> Parser (Map PkgName Version)
parseMap pairs = Map.fromList <$> mapM parseValue pairs
instance FromJSON Snapshot where
parseJSON = withObject "Snapshot" $ \v ->
Snapshot <$> readCompilerVersion v <*> (v .: "packages" >>= parseMap)
readCompilerVersion :: Object -> Parser Version
readCompilerVersion v = do
x <- (v .: "resolver" >>= (.: "compiler")) <|> v .: "compiler"
parseGHCVersion x
genName :: (MonadError Issue m) => Text -> m [Text]
genName resolver
| Just ltsNum <- T.stripPrefix "lts-" resolver = buildSegments "lts" "." ltsNum
| Just nightlyDate <- T.stripPrefix "nightly-" resolver = buildSegments "nightly" "-" nightlyDate
| otherwise = throwError $ fromString $ "Unsupported resolver: " <> toString resolver
where
buildSegments prefix delimiter value =
case NE.nonEmpty (T.splitOn delimiter value) of
Nothing -> throwError $ fromString $ "Malformed resolver: " <> toString resolver
Just parts ->
let segments = NE.init parts
lastPart = NE.last parts
in pure (prefix : segments <> [lastPart <> ".yaml"])
getSnapshotGHC :: (MonadIO m, MonadError Issue m) => Name -> m Version
getSnapshotGHC name = snapshotCompiler <$> getSnapshot name
getSnapshot :: (MonadError Issue m, MonadIO m) => Text -> m Snapshot
getSnapshot name = do
pathSegments <- genName name
body <- runResultT (http "https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master" pathSegments)
case body of
Failure {failure} -> throwError $ fromString $ "HTTP Error: " <> show failure
Success {result} -> case decodeEither' (BL.toStrict result) of
Left err -> throwError $ fromString $ "Snapshot Error: " <> toString (T.intercalate "/" pathSegments) <> " - " <> prettyPrintParseException err
Right snapshot -> pure snapshot
getVersion :: PkgName -> Snapshot -> Maybe Version
getVersion name snapshot = Map.lookup name (snapshotPackages snapshot)
getLatestNightlySnapshot :: (MonadIO m, MonadError Issue m) => m Snapshot
getLatestNightlySnapshot = do
today <- liftIO $ utctDay <$> getCurrentTime
let name = "nightly-" <> T.pack (formatTime defaultTimeLocale "%Y-%-m-%-d" today)
getSnapshot name