packages feed

a-piece-of-flake-0.0.1: src/PieceOfFlake/Fetcher.hs

module PieceOfFlake.Fetcher where

import Crypto.Hash.SHA1 (hashlazy)
import Data.Aeson qualified as A
import Data.Aeson
    ( FromJSON(parseJSON),
      eitherDecodeStrict,
      genericParseJSON,
      defaultOptions,
      Options(fieldLabelModifier) )
import Data.Char ( toLower )
import Data.ByteArray.Encoding (Base(Base16), convertToBase)
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as M
import Data.Text qualified as T
import PieceOfFlake.Aeson ( Some, someToList )
import PieceOfFlake.CmdArgs
import PieceOfFlake.Flake
    ( FlakeUrl(..),
      Architecture(..),
      PackageName(..),
      PackageInfo(..),
      MetaFlake(..) )
import PieceOfFlake.Flake.Repo ( FetcherReq(FetcherReq) )
import PieceOfFlake.Prelude
import PieceOfFlake.Req
    ( DynamicUrl,
      setResponseTimeout,
      (/:),
      defaultHttpConfig,
      isStatusCodeException,
      jsonResponse,
      responseBody,
      responseStatusCode,
      runReq,
      GET(GET),
      HttpConfig(httpConfigRetryJudge, httpConfigRetryJudgeException),
      HttpException(VanillaHttpException),
      NoReqBody(NoReqBody),
      POST(POST),
      ReqBodyJson(ReqBodyJson),
      dynReq,
      dynReq' )

import PieceOfFlake.WebService
    ( FetcherAutoConfig(httpMinTimeout, heartbeatPeriod),
      FetcherHeartbeat(fetcherSecret, FetcherHeartbeat, fetcherId,
                       workingOn),
      FetcherSecret,
      Period(Period, unPeriod) )
import System.Exit ( ExitCode(ExitFailure, ExitSuccess) )
import System.Process ( callProcess, readProcess, readProcessWithExitCode )
import Text.Regex.TDFA ( AllTextSubmatches(getAllTextSubmatches), (=~) )
import UnliftIO.Async ( withAsync )
import UnliftIO.Retry
    ( capDelay,
      fibonacciBackoff,
      limitRetries,
      recoverAll,
      recovering )
import UnliftIO.Directory
    ( doesFileExist,
      createDirectoryIfMissing,
      removeFile )
import System.FilePath ( (</>) )


showDigest :: ByteString -> String
showDigest = BS8.unpack . convertToBase Base16

readJsonDirect :: (FromJSON a, MonadIO m) => Text -> [Text] -> m a
readJsonDirect prg prgArgs = do
  prgOut <- liftIO (readProcess (toString prg) (toString <$> prgArgs) "")
  case eitherDecodeStrict $ encodeUtf8 prgOut of
    Right x -> pure x
    Left e ->
      throwIO . stringException $
      "Failed to parse output as JSON:\n" <> prgOut <> "\nFrom: " <>
      toString (T.intercalate " " prgArgs)  <> "\nError: " <> e

newtype ReadJsonCached = ReadJsonCached FilePath

{-  hash path cut 2 chars join with </> mkdir -p
    by that path create files out.json and cmd.sh
-}

isFileOlderThan :: (MonadIO m, ClockMonad m) => Tagged a FilePath -> Tagged a NominalDiffTime -> m Bool
isFileOlderThan (Tagged fp) (Tagged age) = do
  getModificationTime fp >>= (`doAfter` \mt -> do now <- getTimeAfter mt; pure $ now `diffUTCTime` mt > age)

readJsonCached :: (FromJSON a, FetcherM m) => FilePath -> Text -> [Text] -> m a
readJsonCached cacheDir prg prgArgs = do
  let cmd = LBS.intercalate " " (encodeUtf8 <$> (prg : prgArgs))
      (cmdH2, cmdHashRest) = splitAt 2 $ showDigest $ hashlazy cmd
      callDir = cacheDir </> cmdH2 </> cmdHashRest
      exOut = Tagged @RawNixCacheErrorMaxAge $ callDir </> "err.txt"
      outJson = Tagged @RawNixCacheMaxAge $ callDir </> "out.json"
      cmdFile = callDir </> "cmd.sh"
      doDirect = do
        $(logDebug) $ "Direct read " <> show cmd
        createDirectoryIfMissing True callDir
        prgOut <- liftIO $ do
          readProcessWithExitCode (toString prg) (toString <$> prgArgs) "" >>= \case
            (ExitSuccess, out, _err) -> pure out
            (ExitFailure ec, _, err) -> do
              writeFileBS (untag exOut) $ encodeUtf8 err
              throwIO . stringException $ "Command [" <> show cmd <> "] exited with: " <>
                show ec <> " and output: " <> err
        let prgOutBs = encodeUtf8 prgOut
        writeFileBS (untag outJson) prgOutBs
        writeFileLBS cmdFile cmd
        case eitherDecodeStrict prgOutBs  of
          Right x -> do
            pure x
          Left e ->
            throwIO . stringException $
            "Failed to parse output as JSON:\n" <> prgOut <> "\nFrom: " <>
            toString (T.intercalate " " prgArgs)  <> "\nError: " <> e

  $(logInfo) $ "readJsonCached " <> show cmd
  ifM (doesFileExist $ untag exOut)
    (do
      $(logDebug) $ "Ex cache exist for " <> show cmd
      ifM (isFileOlderThan exOut . rawNixCacheErrMaxAge =<< asks fetcherArgs)
        (do $(logDebug) $ "Ex cache is expired for " <> show cmd
            removeFile $ untag exOut
            doDirect)
        (throwIO . stringException . show =<< readFileBS (untag exOut))
    )
    (ifM (doesFileExist $ untag outJson)
      (do
          $(logDebug) $ "Out cache exist for " <> show cmd
          ifM (isFileOlderThan outJson . rawNixCacheMaxAge =<< asks fetcherArgs)
            doDirect
            (do
              $(logDebug) $ "Read cache from: " <> show outJson
              jsonLbs <- readFileBS $ untag outJson
              case eitherDecodeStrict jsonLbs of
                Right x -> pure x
                Left e ->
                  throwIO . stringException $
                  "Failed to parse output as JSON:\n" <> show jsonLbs <> "\nFrom: " <>
                  toString (T.intercalate " " prgArgs)  <> "\nError: " <> e
            )
      )
      doDirect
    )

data FetcherConf
  = FetcherConf
  { fetUrl :: DynamicUrl
  , arch :: Architecture
  , fetcherSecret :: FetcherSecret
  , fetcherArgs :: FetcherCmdArgs
  , flakeLoser :: Maybe (FlakeUrl, Either Text MetaFlake) -> Maybe (FlakeUrl, Either Text MetaFlake)
  , confFromWs :: FetcherAutoConfig
  }

type FetcherM m = (MonadReader FetcherConf m, PoF m)

readJson :: (FetcherM m, FromJSON a) => Text -> [Text] -> m a
readJson prg prgArgs = do
  asks fetcherArgs <&> rawNixCache >>= \case
    Tagged Nothing -> readJsonDirect prg prgArgs
    Tagged (Just c) -> readJsonCached c prg prgArgs

data RawFlakeOrigin
  = RawFlakeOrigin
  { flakeOriginType :: Text
  , flakeOriginOwner :: Maybe Text
  , flakeOriginRepo :: Maybe Text
  , flakeOriginRev :: Maybe Text
  , flakeOriginUrl :: Maybe Text
  } deriving (Show, Eq, Generic)

instance FromJSON RawFlakeOrigin where
  parseJSON = genericParseJSON
    (defaultOptions { fieldLabelModifier = fmap  toLower . drop (length ("flakeOrigin" :: String)) })

data RawNode
  = RawNode
  { flake :: Maybe Bool
  , original :: Maybe RawFlakeOrigin
  } deriving (Show, Eq, Generic)
instance FromJSON RawNode

newtype RawLocks
  = RawLocks
  { nodes :: Map Text RawNode
  } deriving (Show, Eq, Generic)
instance FromJSON RawLocks

data RawFlakeInfo
  = RawFlakeInfo
  { revision :: Text
  , description :: Maybe Text
  , lastModified :: Integer
  , locks :: RawLocks
  } deriving (Show, Eq, Generic)
instance FromJSON RawFlakeInfo

-- Filesystem      Size  Used Avail Use% Mounted on
-- /dev/nvme0n1p5  907G  485G  376G  57% /
dfOutputRegex :: String
dfOutputRegex = " (1?[0-9]{1,2}[%])"

parseDfLine :: String -> Maybe Percent
parseDfLine l =
  case getAllTextSubmatches (l =~ dfOutputRegex) of
    ([_full, pG ] :: [String]) -> parsePercent pG
    _ -> Nothing

rootDiskUsedSpacePercent :: FetcherM m => m Percent
rootDiskUsedSpacePercent = do
  liftIO (readProcess "df" ["-h", "/"] "") <&> mapMaybe (parseDfLine . toString) . lines . toText >>= \case
    [] -> throwIO . stringException $ "No result from df -h /"
    [r] -> do
      $(logDebug) $ "Root volume used space: " <> show r
      pure r
    mu -> throwIO . stringException $ "Multiple results " <> show mu

runNixCG :: FetcherM m => m ()
runNixCG = liftIO $ callProcess "nix-store" ["--gc"]

checkDiskFreeSpace :: FetcherM m => m ()
checkDiskFreeSpace = do
  Tagged criticalDiskUsage <- runNixGCIfUsedMoreThan <$> asks fetcherArgs
  diskUsage <- rootDiskUsedSpacePercent
  if criticalDiskUsage <= diskUsage
    then runNixCG
    else $(logDebug) "Skip nix GC step"

nixFlakeInfo :: FetcherM m => FlakeUrl -> m RawFlakeInfo
nixFlakeInfo (FlakeUrl fu) =
  readJson "nix" ["flake", "metadata", "--json", fu]

nixCurrentArch :: MonadIO m => m Architecture
nixCurrentArch =
  Architecture . toText <$>
  liftIO (readProcess "nix" (toString <$> words "eval --impure --raw --expr builtins.currentSystem") "")

data RawFlakeOutputs
  = RawFlakeOutputs
  { packages :: Maybe (Map Architecture (Map PackageName ()))
  , nixosModules :: Maybe (Map Text ())
  } deriving (Show, Eq, Generic)

instance FromJSON RawFlakeOutputs

nixFlakeShow :: FetcherM m => FlakeUrl -> m RawFlakeOutputs
nixFlakeShow (FlakeUrl fu) =
  readJson "nix" ["flake", "show", "--json", fu]

data RawLicense
  = RawLicense
  { deprecated :: Bool
  , free :: Bool
  , fullName :: Text
  , shortName :: Text
  , spdxId :: Maybe Text
  , url :: Maybe Text
  } deriving (Show, Eq, Generic)

instance FromJSON RawLicense where

newtype RawPlatform = RawPlatform { unRawPlatform :: Text } deriving (Show, Eq)

instance FromJSON RawPlatform where
  parseJSON (A.String x) = pure $ RawPlatform x
  parseJSON _ = pure $ RawPlatform "trash"

data RawPackage
  = RawPackage
  { broken :: Maybe Bool
  , description :: Maybe Text
  , insecure :: Maybe Bool
  , license :: Maybe (Some RawLicense)
  , name :: Maybe Text
  , platforms :: Maybe [RawPlatform]
  , unfree :: Maybe Bool
  , unsupported :: Maybe Bool
  } deriving (Show, Eq, Generic)

instance FromJSON RawPackage

nixEvalPkgMeta :: FetcherM m => FlakeUrl -> Architecture -> PackageName -> m RawPackage
nixEvalPkgMeta (FlakeUrl fu) (Architecture arch) (PackageName pn) =
  readJson "nix" ["eval", "--json", fu <> "#packages." <> arch <> "." <> pn <> ".meta" ]

rawPackageToPackageInfo :: PackageName -> RawPackage -> PackageInfo
rawPackageToPackageInfo pn rp =
  PackageInfo
  { description = rp.description
  , license = shortName <$> join (maybeToList (fmap someToList rp.license))
  , name = maybe pn PackageName rp.name
  , platforms = unRawPlatform <$> fromMaybe [] rp.platforms
  , unfree = rp.unfree
  , broken = rp.broken
  }

forkWhile :: MonadUnliftIO m => (t -> m a) -> (t -> m b) -> t -> m b
forkWhile f m a = withAsync (f a) (\_ -> m a)


metaFlakeFromUrl :: FetcherM m => FlakeUrl -> m MetaFlake
metaFlakeFromUrl fu = do
  checkDiskFreeSpace
  rfi <- nixFlakeInfo fu
  curArch <- asks arch
  rfo <- nixFlakeShow fu
  let archPkgs = fmap M.keys . M.filterWithKey (\a _ps -> a == curArch) $ fromMaybe mempty rfo.packages
  metaPackages <- M.fromList <$> mapM mapArchPkgs (M.toList archPkgs)
  pure MetaFlake
    { description = rfi.description
    , packages = metaPackages
    , rev = rfi.revision
    , hasNixOsModules = not $ null rfo.nixosModules
    , flakeDeps = []
    }
  where
    getPackageInfo arch pkgName = nixEvalPkgMeta fu arch pkgName <&> rawPackageToPackageInfo pkgName
    mapArchPkgs (arch, pkgNames) =
          (arch, ) . M.fromList <$> mapM (\pkgName -> (pkgName,) <$> getPackageInfo arch pkgName)
            (filter (/= PackageName "default") pkgNames)

uploadFlakeAndFetch :: (FetcherM m) =>
  Maybe (FlakeUrl, Either Text MetaFlake)
  -> m ()
uploadFlakeAndFetch f = do
  fctx <- ask
  let fetr = FetcherReq fctx.fetcherArgs.fetcherId f fctx.fetcherSecret
      rqb = ReqBodyJson fetr
  $(logDebug) $ "sending " <> show fetr
  jr <- recovering
          (fibonacciBackoff 100_000 <> limitRetries 6)
          [ \_rs -> Handler $
              \case
                he@VanillaHttpException {} -> do
                  $(logError) $ "Vanila Exception " <> show he
                  case isStatusCodeException he of
                    Nothing -> pure False
                    Just r ->
                      case responseStatusCode r of
                        scode ->
                          pure $ scode < 400 || scode >= 500
                oe -> do
                  $(logError) $ "Other Exception " <> show oe
                  pure False
          ]
          (\_ ->
              runReq noRetry $ dynReq POST fctx.fetUrl "fetch-new-flake-submitions" rqb jsonResponse)
  $(logDebug) $ "Response from WS for " <> show (fmap fst f) <>  ": "   <> show (responseBody jr)
  case responseBody jr of
    Nothing -> uploadFlakeAndFetch Nothing
    Just fu -> catchAny (go fctx fu) (onEx fctx fu)
  where
    onEx fctx fu e = do
      $(logError) $ "nix failed for " <> show fu <> " with " <> show e
      uploadFlakeAndFetch $ fctx.flakeLoser (Just (fu, Left $ show e))
    go fctx fu =
      uploadFlakeAndFetch . fctx.flakeLoser . Just . (fu,) . Right
      =<< forkWhile launchHeartbeats metaFlakeFromUrl fu

noRetry :: HttpConfig
noRetry =
  defaultHttpConfig
  { httpConfigRetryJudgeException = \_ _ -> False
  , httpConfigRetryJudge = \_ _ -> False
  }

runFetcher :: PoF m => FetcherCmdArgs -> FetcherSecret -> m ()
runFetcher fa fsec = do
  fac <- loadFetcherAutoConf fa.webServiceUrl
  let serviceUrl = setResponseTimeout fa.webServiceUrl . coerce . (1 + ) $ untag fac.httpMinTimeout
  $(logInfo) $ "Fetcher " <> show fa.fetcherId <> " started for " <> show serviceUrl <> " with " <> show fac
  ca <- nixCurrentArch
  forever $ do
    recoverAll
      (capDelay (toMs (6 :: Second)) (fibonacciBackoff (toMs (1 :: Second))))
      (\_ ->
         runReaderT (uploadFlakeAndFetch Nothing)
           $ FetcherConf serviceUrl ca fsec fa looseF fac)
  where
    looseF :: forall a. Maybe a -> Maybe a
    looseF = if untag fa.looseFlakes
      then const Nothing
      else id

launchHeartbeats :: FetcherM m => FlakeUrl -> m ()
launchHeartbeats fu = do
  $(logInfo) "Fetcher Heartbeat thread is forked"
  ctx <- ask
  forever $ do
    sendHeartbeat fu
    threadDelay . unPeriod $ untag ctx.confFromWs.heartbeatPeriod

sendHeartbeat :: FetcherM m => FlakeUrl -> m ()
sendHeartbeat fu = do
  ctx <- ask
  responseBody <$> runReq noRetry
     (dynReq' POST ctx.fetUrl (\u -> u /: "fetcher" /: "heartbeat") (ReqBodyJson $ fhb ctx) jsonResponse)
  where
    fhb ctx = FetcherHeartbeat
      { fetcherId = ctx.fetcherArgs.fetcherId
      , workingOn = fu
      , fetcherSecret = ctx.fetcherSecret
      }

loadFetcherAutoConf :: PoF m => DynamicUrl -> m FetcherAutoConfig
loadFetcherAutoConf serviceUrl = do
  r <- runReq defaultHttpConfig $ dynReq' GET serviceUrl (\ur -> ur /: "fetcher" /: "config") NoReqBody jsonResponse
  pure $ responseBody r