packages feed

futhark-0.18.2: src/Futhark/Pkg/Info.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Obtaining information about packages over THE INTERNET!
module Futhark.Pkg.Info
  ( -- * Package info
    PkgInfo (..),
    lookupPkgRev,
    pkgInfo,
    PkgRevInfo (..),
    GetManifest (getManifest),
    downloadZipball,

    -- * Package registry
    PkgRegistry,
    MonadPkgRegistry (..),
    lookupPackage,
    lookupPackageRev,
    lookupNewestRev,
  )
where

import qualified Codec.Archive.Zip as Zip
import Control.Monad.IO.Class
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import Data.IORef
import Data.List (foldl', intersperse)
import qualified Data.Map as M
import Data.Maybe
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)
import Futhark.Pkg.Types
import Futhark.Util (maybeHead)
import Futhark.Util.Log
import System.Exit
import qualified System.FilePath.Posix as Posix
import System.IO
import System.Process.ByteString (readProcessWithExitCode)

-- | Download URL via shelling out to @curl@.
curl :: String -> IO (Either String BS.ByteString)
curl url = do
  (code, out, err) <-
    -- The -L option follows HTTP redirects.
    liftIO $ readProcessWithExitCode "curl" ["-L", url] mempty
  case code of
    ExitFailure 127 ->
      return $
        Left $
          "'" <> unwords ["curl", "-L", url] <> "' failed (program not found?)."
    ExitFailure _ -> do
      liftIO $ BS.hPutStr stderr err
      return $ Left $ "'" <> unwords ["curl", "-L", url] <> "' failed."
    ExitSuccess ->
      return $ Right out

-- | The manifest is stored as a monadic action, because we want to
-- fetch them on-demand.  It would be a waste to fetch it information
-- for every version of every package if we only actually need a small
-- subset of them.
newtype GetManifest m = GetManifest {getManifest :: m PkgManifest}

instance Show (GetManifest m) where
  show _ = "#<revdeps>"

instance Eq (GetManifest m) where
  _ == _ = True

-- | Information about a version of a single package.  The version
-- number is stored separately.
data PkgRevInfo m = PkgRevInfo
  { pkgRevZipballUrl :: T.Text,
    -- | The directory inside the zipball
    -- containing the @lib@ directory, in
    -- which the package files themselves
    -- are stored (Based on the package
    -- path).
    pkgRevZipballDir :: FilePath,
    -- | The commit ID can be used for
    -- verification ("freezing"), by
    -- storing what it was at the time this
    -- version was last selected.
    pkgRevCommit :: T.Text,
    pkgRevGetManifest :: GetManifest m,
    -- | Timestamp for when the revision
    -- was made (rarely used).
    pkgRevTime :: UTCTime
  }
  deriving (Eq, Show)

-- | Create memoisation around a 'GetManifest' action to ensure that
-- multiple inspections of the same revisions will not result in
-- potentially expensive network round trips.
memoiseGetManifest :: MonadIO m => GetManifest m -> m (GetManifest m)
memoiseGetManifest (GetManifest m) = do
  ref <- liftIO $ newIORef Nothing
  return $
    GetManifest $ do
      v <- liftIO $ readIORef ref
      case v of
        Just v' -> return v'
        Nothing -> do
          v' <- m
          liftIO $ writeIORef ref $ Just v'
          return v'

-- | Download the zip archive corresponding to a specific package
-- version.
downloadZipball ::
  (MonadLogger m, MonadIO m, MonadFail m) =>
  PkgRevInfo m ->
  m Zip.Archive
downloadZipball info = do
  let url = pkgRevZipballUrl info
  logMsg $ "Downloading " <> T.unpack url

  let bad = fail . (("When downloading " <> T.unpack url <> ": ") <>)
  http <- liftIO $ curl $ T.unpack url
  case http of
    Left e -> bad e
    Right r ->
      case Zip.toArchiveOrFail $ LBS.fromStrict r of
        Left e -> bad $ show e
        Right a -> return a

-- | Information about a package.  The name of the package is stored
-- separately.
data PkgInfo m = PkgInfo
  { pkgVersions :: M.Map SemVer (PkgRevInfo m),
    -- | Look up information about a specific
    -- commit, or HEAD in case of Nothing.
    pkgLookupCommit :: Maybe T.Text -> m (PkgRevInfo m)
  }

-- | Lookup information about a given version of a package.
lookupPkgRev :: SemVer -> PkgInfo m -> Maybe (PkgRevInfo m)
lookupPkgRev v = M.lookup v . pkgVersions

majorRevOfPkg :: PkgPath -> (PkgPath, [Word])
majorRevOfPkg p =
  case T.splitOn "@" p of
    [p', v] | [(v', "")] <- reads $ T.unpack v -> (p', [v'])
    _ -> (p, [0, 1])

-- | Retrieve information about a package based on its package path.
-- This uses Semantic Import Versioning when interacting with
-- repositories.  For example, a package @github.com/user/repo@ will
-- match version 0.* or 1.* tags only, a package
-- @github.com/user/repo/v2@ will match 2.* tags, and so forth..
pkgInfo ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  PkgPath ->
  m (Either T.Text (PkgInfo m))
pkgInfo path
  | ["github.com", owner, repo] <- T.splitOn "/" path =
    let (repo', vs) = majorRevOfPkg repo
     in ghPkgInfo owner repo' vs
  | "github.com" : owner : repo : _ <- T.splitOn "/" path =
    return $
      Left $
        T.intercalate
          "\n"
          [nope, "Do you perhaps mean 'github.com/" <> owner <> "/" <> repo <> "'?"]
  | ["gitlab.com", owner, repo] <- T.splitOn "/" path =
    let (repo', vs) = majorRevOfPkg repo
     in glPkgInfo owner repo' vs
  | "gitlab.com" : owner : repo : _ <- T.splitOn "/" path =
    return $
      Left $
        T.intercalate
          "\n"
          [nope, "Do you perhaps mean 'gitlab.com/" <> owner <> "/" <> repo <> "'?"]
  | otherwise =
    return $ Left nope
  where
    nope = "Unable to handle package paths of the form '" <> path <> "'"

-- For GitHub, we unfortunately cannot use the (otherwise very nice)
-- GitHub web API, because it is rate-limited to 60 requests per hour
-- for non-authenticated users.  Instead we fall back to a combination
-- of calling 'git' directly and retrieving things from the GitHub
-- webserver, which is not rate-limited.  This approach is also used
-- by other systems (Go most notably), so we should not be stepping on
-- any toes.

gitCmd :: (MonadIO m, MonadFail m) => [String] -> m BS.ByteString
gitCmd opts = do
  (code, out, err) <- liftIO $ readProcessWithExitCode "git" opts mempty
  liftIO $ BS.hPutStr stderr err
  case code of
    ExitFailure 127 -> fail $ "'" <> unwords ("git" : opts) <> "' failed (program not found?)."
    ExitFailure _ -> fail $ "'" <> unwords ("git" : opts) <> "' failed."
    ExitSuccess -> return out

-- The GitLab and GitHub interactions are very similar, so we define a
-- couple of generic functions that are used to implement support for
-- both.

ghglRevGetManifest ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  T.Text ->
  T.Text ->
  T.Text ->
  T.Text ->
  GetManifest m
ghglRevGetManifest url owner repo tag = GetManifest $ do
  logMsg $ "Downloading package manifest from " <> url

  let path =
        T.unpack $
          owner <> "/" <> repo <> "@"
            <> tag
            <> "/"
            <> T.pack futharkPkg
      msg = (("When reading " <> path <> ": ") <>)
  http <- liftIO $ curl $ T.unpack url
  case http of
    Left e -> fail e
    Right r' ->
      case T.decodeUtf8' r' of
        Left e -> fail $ msg $ show e
        Right s ->
          case parsePkgManifest path s of
            Left e -> fail $ msg $ errorBundlePretty e
            Right pm -> return pm

ghglLookupCommit ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  T.Text ->
  T.Text ->
  (T.Text -> T.Text) ->
  T.Text ->
  T.Text ->
  T.Text ->
  T.Text ->
  T.Text ->
  m (PkgRevInfo m)
ghglLookupCommit archive_url manifest_url mk_zip_dir owner repo d ref hash = do
  gd <- memoiseGetManifest $ ghglRevGetManifest manifest_url owner repo ref
  let dir = Posix.addTrailingPathSeparator $ T.unpack $ mk_zip_dir d
  time <- liftIO getCurrentTime -- FIXME
  return $ PkgRevInfo archive_url dir hash gd time

ghglPkgInfo ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  T.Text ->
  (T.Text -> T.Text) ->
  (T.Text -> T.Text) ->
  (T.Text -> T.Text) ->
  T.Text ->
  T.Text ->
  [Word] ->
  m (Either T.Text (PkgInfo m))
ghglPkgInfo repo_url mk_archive_url mk_manifest_url mk_zip_dir owner repo versions = do
  logMsg $ "Retrieving list of tags from " <> repo_url
  remote_lines <- T.lines . T.decodeUtf8 <$> gitCmd ["ls-remote", T.unpack repo_url]

  head_ref <-
    maybe (fail $ "Cannot find HEAD ref for " <> T.unpack repo_url) return $
      maybeHead $ mapMaybe isHeadRef remote_lines
  let def = fromMaybe head_ref

  rev_info <- M.fromList . catMaybes <$> mapM revInfo remote_lines

  return $
    Right $
      PkgInfo rev_info $ \r ->
        ghglLookupCommit
          (mk_archive_url (def r))
          (mk_manifest_url (def r))
          mk_zip_dir
          owner
          repo
          (def r)
          (def r)
          (def r)
  where
    isHeadRef l
      | [hash, "HEAD"] <- T.words l = Just hash
      | otherwise = Nothing

    revInfo l
      | [hash, ref] <- T.words l,
        ["refs", "tags", t] <- T.splitOn "/" ref,
        "v" `T.isPrefixOf` t,
        Right v <- parseVersion $ T.drop 1 t,
        _svMajor v `elem` versions = do
        pinfo <-
          ghglLookupCommit
            (mk_archive_url t)
            (mk_manifest_url t)
            mk_zip_dir
            owner
            repo
            (prettySemVer v)
            t
            hash
        return $ Just (v, pinfo)
      | otherwise = return Nothing

ghPkgInfo ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  T.Text ->
  T.Text ->
  [Word] ->
  m (Either T.Text (PkgInfo m))
ghPkgInfo owner repo versions =
  ghglPkgInfo
    repo_url
    mk_archive_url
    mk_manifest_url
    mk_zip_dir
    owner
    repo
    versions
  where
    repo_url = "https://github.com/" <> owner <> "/" <> repo
    mk_archive_url r = repo_url <> "/archive/" <> r <> ".zip"
    mk_manifest_url r =
      "https://raw.githubusercontent.com/"
        <> owner
        <> "/"
        <> repo
        <> "/"
        <> r
        <> "/"
        <> T.pack futharkPkg
    mk_zip_dir r = repo <> "-" <> r

glPkgInfo ::
  (MonadIO m, MonadLogger m, MonadFail m) =>
  T.Text ->
  T.Text ->
  [Word] ->
  m (Either T.Text (PkgInfo m))
glPkgInfo owner repo versions =
  ghglPkgInfo
    repo_url
    mk_archive_url
    mk_manifest_url
    mk_zip_dir
    owner
    repo
    versions
  where
    base_url = "https://gitlab.com/" <> owner <> "/" <> repo
    repo_url = base_url <> ".git"
    mk_archive_url r =
      base_url <> "/-/archive/" <> r
        <> "/"
        <> repo
        <> "-"
        <> r
        <> ".zip"
    mk_manifest_url r =
      base_url <> "/raw/"
        <> r
        <> "/"
        <> T.pack futharkPkg
    mk_zip_dir r
      | Right _ <- parseVersion r = repo <> "-v" <> r
      | otherwise = repo <> "-" <> r

-- | A package registry is a mapping from package paths to information
-- about the package.  It is unlikely that any given registry is
-- global; rather small registries are constructed on-demand based on
-- the package paths referenced by the user, and may also be combined
-- monoidically.  In essence, the PkgRegistry is just a cache.
newtype PkgRegistry m = PkgRegistry (M.Map PkgPath (PkgInfo m))

instance Semigroup (PkgRegistry m) where
  PkgRegistry x <> PkgRegistry y = PkgRegistry $ x <> y

instance Monoid (PkgRegistry m) where
  mempty = PkgRegistry mempty

lookupKnownPackage :: PkgPath -> PkgRegistry m -> Maybe (PkgInfo m)
lookupKnownPackage p (PkgRegistry m) = M.lookup p m

-- | Monads that support a stateful package registry.  These are also
-- required to be instances of 'MonadIO' because most package registry
-- operations involve network operations.
class (MonadIO m, MonadLogger m, MonadFail m) => MonadPkgRegistry m where
  getPkgRegistry :: m (PkgRegistry m)
  putPkgRegistry :: PkgRegistry m -> m ()
  modifyPkgRegistry :: (PkgRegistry m -> PkgRegistry m) -> m ()
  modifyPkgRegistry f = putPkgRegistry . f =<< getPkgRegistry

-- | Given a package path, look up information about that package.
lookupPackage ::
  MonadPkgRegistry m =>
  PkgPath ->
  m (PkgInfo m)
lookupPackage p = do
  r@(PkgRegistry m) <- getPkgRegistry
  case lookupKnownPackage p r of
    Just info ->
      return info
    Nothing -> do
      e <- pkgInfo p
      case e of
        Left e' -> fail $ T.unpack e'
        Right pinfo -> do
          putPkgRegistry $ PkgRegistry $ M.insert p pinfo m
          return pinfo

lookupPackageCommit ::
  MonadPkgRegistry m =>
  PkgPath ->
  Maybe T.Text ->
  m (SemVer, PkgRevInfo m)
lookupPackageCommit p ref = do
  pinfo <- lookupPackage p
  rev_info <- pkgLookupCommit pinfo ref
  let timestamp =
        T.pack $
          formatTime defaultTimeLocale "%Y%m%d%H%M%S" $
            pkgRevTime rev_info
      v = commitVersion timestamp $ pkgRevCommit rev_info
      pinfo' = pinfo {pkgVersions = M.insert v rev_info $ pkgVersions pinfo}
  modifyPkgRegistry $ \(PkgRegistry m) ->
    PkgRegistry $ M.insert p pinfo' m
  return (v, rev_info)

-- | Look up information about a specific version of a package.
lookupPackageRev ::
  MonadPkgRegistry m =>
  PkgPath ->
  SemVer ->
  m (PkgRevInfo m)
lookupPackageRev p v
  | Just commit <- isCommitVersion v =
    snd <$> lookupPackageCommit p (Just commit)
  | otherwise = do
    pinfo <- lookupPackage p
    case lookupPkgRev v pinfo of
      Nothing ->
        let versions = case M.keys $ pkgVersions pinfo of
              [] -> "Package " <> p <> " has no versions.  Invalid package path?"
              ks ->
                "Known versions: "
                  <> T.concat (intersperse ", " $ map prettySemVer ks)
            major
              | (_, vs) <- majorRevOfPkg p,
                _svMajor v `notElem` vs =
                "\nFor major version " <> T.pack (show (_svMajor v))
                  <> ", use package path "
                  <> p
                  <> "@"
                  <> T.pack (show (_svMajor v))
              | otherwise = mempty
         in fail $
              T.unpack $
                "package " <> p <> " does not have a version " <> prettySemVer v <> ".\n"
                  <> versions
                  <> major
      Just v' -> return v'

-- | Find the newest version of a package.
lookupNewestRev ::
  MonadPkgRegistry m =>
  PkgPath ->
  m SemVer
lookupNewestRev p = do
  pinfo <- lookupPackage p
  case M.keys $ pkgVersions pinfo of
    [] -> do
      logMsg $ "Package " <> p <> " has no released versions.  Using HEAD."
      fst <$> lookupPackageCommit p Nothing
    v : vs -> return $ foldl' max v vs