packages feed

koji-install-0.5: src/Main.hs

{-# LANGUAGE CPP #-}

-- SPDX-License-Identifier: BSD-3-Clause

module Main (main) where

import Control.Monad.Extra
import Data.Char
import Data.List.Extra
import Data.Maybe
import Data.RPM
import Distribution.Koji
import qualified Distribution.Koji.API as Koji
import Network.HTTP.Directory (httpFileSize', httpLastModified', (+/+))
import SimpleCmd
import SimpleCmdArgs
import System.Directory
import System.FilePath
import System.FilePath.Glob
import System.IO

import DownloadDir
import Paths_koji_install (version)

-- mbox kojihub is locked
knownHubs :: [String]
knownHubs = ["fedora","stream","rpmfusion", "or URL"]

hubURL :: String -> String
hubURL "fedora" = fedoraKojiHub
-- later use centosKojiHub
hubURL "stream" = "https://kojihub.stream.centos.org/kojihub"
--hubURL "mbox" = "https://koji.mbox.centos.org/kojihub"
hubURL "rpmfusion" = "https://koji.rpmfusion.org/kojihub"
hubURL "fusion" = "https://koji.rpmfusion.org/kojihub"
hubURL hub =
  if "http" `isPrefixOf` hub
  then hub
  else error' $ "unknown hub: try " ++ show knownHubs

defaultPkgsURL :: String -> String
defaultPkgsURL url =
  case dropSuffix "/" url of
    "https://koji.fedoraproject.org/kojihub" ->
      "https://kojipkgs.fedoraproject.org/packages"
    "https://kojihub.stream.centos.org/kojihub" ->
      "https://kojihub.stream.centos.org/kojifiles/packages"
    _ ->
      if "kojihub" `isSuffixOf` url
      then replace "kojihub" "kojifiles" url +/+ "packages"
      else error' $ "use --files-url to specify kojifiles url for " ++ url

data Mode = All
          | Ask
          -- distinguish except and exclude
          | PkgsReq [String] [String] -- ^ include, except/exclude
  deriving Eq

data Request = ReqName | ReqNV | ReqNVR
  deriving Eq

-- FIXME --include devel, --exclude *
-- FIXME specify tag or task
-- FIXME support enterprise builds
-- FIXME --arch (including src)
-- FIXME --debuginfo
-- FIXME --delete after installing
main :: IO ()
main = do
  sysdisttag <- do
    dist <- cmd "rpm" ["--eval", "%{dist}"]
    return $ if dist == "%{dist}" then "" else dist
  simpleCmdArgs (Just Paths_koji_install.version)
    "Download and install latest package build from Koji tag."
    ("HUB = " ++ intercalate ", " knownHubs) $
    program
    <$> switchWith 'n' "dry-run" "Don't actually download anything"
    <*> switchWith 'D' "debug" "More detailed output"
    <*> optional (strOptionWith 'H' "hub" "HUB"
                  "KojiHub shortname or url [default: fedora]")
    <*> optional (strOptionWith 'P' "packages-url" "URL"
                  "KojiFiles packages url [default: Fedora]")
    <*> switchWith 'l' "list" "List builds"
    <*> modeOpt
    <*> disttagOpt sysdisttag
    <*> (flagWith' ReqNVR 'R' "nvr" "Give an N-V-R instead of package name"
         <|> flagWith ReqName ReqNVR 'V' "nv" "Give an N-V instead of package name")
    <*> some (strArg "PKG|NVR|TASKID...")
  where
    modeOpt :: Parser Mode
    modeOpt =
      flagWith' All 'a' "all" "all subpackages" <|>
      flagWith' Ask 'A' "ask" "ask for each subpackge [default if not installed]" <|>
      pkgsReqOpts

    pkgsReqOpts = PkgsReq
      <$> many (strOptionWith 'p' "package" "SUBPKG" "Subpackage (glob) to install") <*> many (strOptionWith 'x' "exclude" "SUBPKG" "Subpackage (glob) not to install")

    disttagOpt :: String -> Parser String
    disttagOpt disttag = startingDot <$> strOptionalWith 'd' "disttag" "DISTTAG" ("Use a different disttag [default: " ++ disttag ++ "]") disttag

    startingDot cs =
      case cs of
        "" -> error' "empty disttag"
        (c:_) -> if c == '.' then cs else '.' : cs

program :: Bool -> Bool -> Maybe String -> Maybe String -> Bool -> Mode
        -> String -> Request -> [String] -> IO ()
program dryrun debug mhuburl mpkgsurl listmode mode disttag request pkgbldtsks = do
  let huburl = maybe fedoraKojiHub hubURL mhuburl
      pkgsurl = fromMaybe (defaultPkgsURL huburl) mpkgsurl
  when debug $ do
    putStrLn huburl
    putStrLn pkgsurl
  -- FIXME use this location?
  dlDir <- setDownloadDir dryrun "rpms"
  when debug $ putStrLn dlDir
  setNoBuffering
  mapM (kojiRPMs huburl pkgsurl dlDir) pkgbldtsks
    >>= if listmode
        then mapM_ putStrLn . mconcat
        else installRPMs dryrun . mconcat
  where
    kojiRPMs :: String -> String -> String -> String -> IO [String] -- ([String],String)
    kojiRPMs huburl pkgsurl dlDir bldtask =
      if all isDigit bldtask
      then kojiTaskRPMs dryrun debug huburl pkgsurl listmode mode dlDir bldtask
      else kojiBuildRPMs huburl pkgsurl dlDir bldtask

    kojiBuildRPMs :: String -> String -> String -> String -> IO [String]
    kojiBuildRPMs huburl pkgsurl dlDir pkgbld = do
      nvrs <- kojiBuildOSBuilds debug huburl listmode disttag request pkgbld
      if listmode
        then if mode /= PkgsReq [] []
             then error' "modes not supported for listing build"
             else return nvrs
        else
        case nvrs of
          [] -> error' $ pkgbld ++ " not found for " ++ disttag
          [nvr] -> do
            putStrLn $ nvr ++ "\n"
            allRpms <- map (<.> "rpm") . sort . filter (not . debugPkg) <$> kojiGetBuildRPMs huburl nvr
            when debug $ print allRpms
            dlRpms <- decideRpms listmode mode (maybeNVRName nvr) allRpms
            when debug $ print dlRpms
            unless (dryrun || null dlRpms) $ do
              mapM_ (downloadBuildRpm debug pkgsurl (readNVR nvr)) dlRpms
              -- FIXME once we check file size - can skip if no downloads
              putStrLn $ "Packages downloaded to " ++ dlDir
            return dlRpms
          _ -> error $ "multiple build founds for " ++ pkgbld ++ ": " ++
               unwords nvrs

    debugPkg :: String -> Bool
    debugPkg p = "-debuginfo-" `isInfixOf` p || "-debugsource-" `isInfixOf` p

kojiTaskRPMs :: Bool -> Bool -> String -> String -> Bool -> Mode -> String
             -> String -> IO [String]
kojiTaskRPMs dryrun debug huburl pkgsurl listmode mode dlDir task = do
  let taskid = read task
  if listmode
    then do
    mtaskinfo <- Koji.getTaskInfo huburl taskid True
    case mtaskinfo of
      Just taskinfo -> do
        when debug $ mapM_ print taskinfo
        if isNothing (lookupStruct "parent" taskinfo :: Maybe Int)
          then do
          children <- Koji.getTaskChildren huburl taskid False
          return $ fromMaybe "" (showTask taskinfo) : mapMaybe showChildTask children
          else getTaskRPMs taskid >>= decideRpms listmode mode Nothing
      Nothing -> error' "failed to get taskinfo"
    else do
    rpms <- getTaskRPMs taskid
    if null rpms
      then do
      kojiTaskRPMs dryrun debug huburl pkgsurl True mode dlDir task >>= mapM_ putStrLn
      return []
      else do
      when debug $ print rpms
      let srpm =
            case filter (".src.rpm" `isExtensionOf`) rpms of
              [src] -> src
              _ -> error' "could not determine nvr from any srpm"
          nvr = dropSuffix ".src.rpm" srpm
      dlRpms <- decideRpms listmode mode (maybeNVRName nvr) $ rpms \\ [srpm]
      when debug $ print dlRpms
      unless (dryrun || null dlRpms) $ do
        mapM_ (downloadTaskRpm debug pkgsurl task) dlRpms
        putStrLn $ "Packages downloaded to " ++ dlDir
      return dlRpms
  where
    getTaskRPMs :: Int -> IO [String]
    getTaskRPMs taskid =
       sort . filter (".rpm" `isExtensionOf`) . map fst <$>
       Koji.listTaskOutput huburl taskid False True False

maybeNVRName :: String -> Maybe String
maybeNVRName = fmap nvrName . maybeNVR

decideRpms :: Bool -> Mode -> Maybe String -> [String] -> IO [String]
decideRpms listmode mode mbase allRpms =
  case mode of
    All -> if listmode
           then error' "cannot use --list and --all together"
           else return allRpms
    Ask -> if listmode
           then error' "cannot use --list and --ask together"
           else mapMaybeM rpmPrompt allRpms
    PkgsReq [] [] ->
      if listmode
      then return allRpms
      else do
      rpms <- filterM (isInstalled . nvraName) $
              filter isBinaryRpm allRpms
      if null rpms
        then decideRpms listmode Ask mbase allRpms
        else return rpms
    PkgsReq subpkgs exclpkgs ->
      return $ selectRPMs mbase (subpkgs,exclpkgs) allRpms

isInstalled :: String -> IO Bool
isInstalled rpm = cmdBool "rpm" ["--quiet", "-q", rpm]

selectRPMs :: Maybe String -> ([String],[String])  -> [String] -> [String]
selectRPMs mbase (subpkgs,[]) rpms =
  sort . mconcat $
  flip map subpkgs $ \ pkgpat ->
  case filter (match (compile pkgpat) . nvraName) rpms of
    [] -> case mbase of
      Just base | head pkgpat /= '*' ->
                  selectRPMs Nothing ([base ++ '-' : pkgpat],[]) rpms
      _ -> error' $ "no subpackage match for " ++ pkgpat
    result -> result
selectRPMs mbase ([], subpkgs) rpms =
  -- FIXME somehow determine unused excludes
  foldl' (exclude subpkgs) [] rpms
  where
    exclude :: [String] -> [String] -> String -> [String]
    exclude [] acc rpm = acc ++ [rpm]
    exclude (pat:pats) acc rpm =
        if checkMatch (nvraName rpm)
        then acc
        else exclude pats acc rpm
      where
        checkMatch :: String -> Bool
        checkMatch rpmname =
          let comppat = compile pat
          in if isLiteral comppat
             then pat == rpmname ||
                  maybe False (\b -> (b ++ '-' : pat) == rpmname) mbase
             else match comppat rpmname
selectRPMs mbase (subpkgs,exclpkgs) rpms =
  let needed = selectRPMs mbase (subpkgs,[]) rpms
      excluded = selectRPMs mbase ([], exclpkgs) rpms
  in nub . sort $ needed ++ excluded

nvraName :: String -> String
nvraName = rpmName . readNVRA

rpmPrompt :: String -> IO (Maybe String)
rpmPrompt rpm = do
  putStr $ rpm ++ " [y/n]: "
  c <- getChar
  putStrLn ""
  case toLower c of
    'y' -> return $ Just rpm
    'n' -> return Nothing
    _ -> rpmPrompt rpm

kojiBuildOSBuilds :: Bool -> String -> Bool -> String -> Request -> String
                  -> IO [String]
kojiBuildOSBuilds debug hub listmode disttag request pkgpat = do
  let (pkg,full) = packageOfPattern pkgpat
      oldkoji = "rpmfusion" `isInfixOf` hub
  when (oldkoji && ("*" `isInfixOf` pkgpat || request /= ReqName)) $
    error' "cannot use pattern with this kojihub"
  mpkgid <- Koji.getPackageID hub pkg
  case mpkgid of
    Nothing -> error $ "package not found: " ++ pkg
    Just pkgid -> do
      -- strictly should getAPIVersion
      let opts = (if oldkoji
                  then id
                  else (("pattern", ValueString (if full then pkgpat else dropSuffix "*" pkgpat ++ "*" ++ disttag ++ "*")) :))
                 [("packageID", ValueInt pkgid),
                  ("state", ValueInt (fromEnum BuildComplete)),
                  ("queryOpts",ValueStruct
                    [("limit",ValueInt $ if listmode || oldkoji then 10 else 1),
                     ("order",ValueString "-build_id")])]
      when debug $ print opts
      nvrs <- mapMaybe (lookupStruct "nvr") <$> Koji.listBuilds hub opts
      if null nvrs
        then error' $ "no builds found for " ++ disttag
        else
        return $
        if oldkoji
        then case filter (disttag `isInfixOf`) nvrs of
               [] -> error' $ "no builds found for " ++ disttag
               [res] -> [res]
               rs@(r:_) ->
                 if listmode then rs else [r]
        else nvrs
  where
    packageOfPattern :: String -> (String, Bool)
    packageOfPattern pat =
      case request of
        ReqName -> (dropSuffix "-" $ takeWhile (/= '*') pat, False)
        ReqNV ->
          case readNV pat of
            NV n _ -> (n, False)
        ReqNVR ->
          case readNVR pat of
            NVR n _ -> (n, True)

kojiGetBuildRPMs :: String -> String -> IO [String]
kojiGetBuildRPMs huburl nvr = do
  mbid <- kojiGetBuildID huburl nvr
  case mbid of
    Nothing -> error $ "Build id not found for " ++ nvr
    Just (BuildId bid) -> do
      rpms <- Koji.listBuildRPMs huburl bid
      sysarch <- cmd "rpm" ["--eval", "%{_arch}"]
      return $ map getNVRA $ filter (forArch sysarch) rpms
   where
     forArch :: String -> Struct -> Bool
     forArch sysarch st =
       case lookupStruct "arch" st of
         Just arch -> arch `elem` [sysarch, "noarch"]
         Nothing -> error $ "No arch found for rpm for: " ++ nvr

     getNVRA :: Struct -> String
     getNVRA st =
       case lookupStruct "nvr" st of
         Nothing -> error' "NVR not found"
         Just pnvr ->
           case lookupStruct "arch" st of
             Nothing -> error "arch not found"
             Just arch ->
               pnvr <.> arch

setNoBuffering :: IO ()
setNoBuffering = do
  hSetBuffering stdin NoBuffering
  hSetBuffering stdout NoBuffering

installRPMs :: Bool -> [FilePath] -> IO ()
installRPMs _ [] = return ()
installRPMs dryrun rpms = do
  installed <- filterM (isInstalled . dropExtension) rpms
  unless (null installed) $
    if dryrun
    then mapM_ putStrLn $ "would update:" : installed
    else sudo_ "dnf" ("reinstall" : installed)
  let rest = rpms \\ installed
  unless (null rest) $
    if dryrun
    then mapM_ putStrLn $ "would install:" : rest
    else sudo_ "dnf" ("localinstall" : rest)

downloadBuildRpm :: Bool -> String -> NVR -> String -> IO ()
downloadBuildRpm debug pkgsurl (NVR n (VerRel v r)) rpm = do
  let arch = rpmArch (readNVRA rpm)
      url = pkgsurl +/+ n  +/+ v +/+ r +/+ arch +/+ rpm
  downloadRPM debug url

downloadTaskRpm :: Bool -> String -> String -> String -> IO ()
downloadTaskRpm debug pkgsurl taskid rpm = do
  let url = dropSuffix "packages" pkgsurl +/+ "work/tasks/" ++ lastFew +/+ taskid +/+ rpm
  downloadRPM debug url
  where
    lastFew =
      let few = dropWhile (== '0') $ takeEnd 4 taskid in
        if null few then "0" else few

-- FIXME check file size
-- FIXME check timestamp
downloadRPM :: Bool -> String -> IO ()
downloadRPM debug url = do
  let rpm = takeFileName url
  exists <- doesFileExist rpm
  notfile <-
    if exists
    then do
      old <- outOfDate rpm
      when old $ removeFile rpm
      return old
    else return True
  when notfile $ do
    putStrLn $ "Downloading " ++ if debug then url else rpm
    cmd_ "curl" ["--remote-time", "--fail", "--silent", "-C-", "--show-error", "--remote-name", url]
  where
    outOfDate :: String -> IO Bool
    outOfDate file = do
      mremotetime <- httpLastModified' url
      case mremotetime of
        Just remotetime -> do
          localtime <- getModificationTime file
          return $ localtime < remotetime
        Nothing -> do
          remotesize <- httpFileSize' url
          localsize <- getFileSize file
          return $ remotesize /= Just localsize

showTask :: Struct -> Maybe String
showTask struct = do
  state <- getTaskState struct
  request <- lookupStruct "request" struct
  method <- lookupStruct "method" struct
  let mparent = lookupStruct "parent" struct :: Maybe Int
      showreq = takeWhileEnd (/= '/') . unwords . mapMaybe getString . take 3
  return $ showreq request +-+ method +-+ (if state == TaskClosed then "" else show state) +-+ maybe "" (\p -> "(" ++ show p ++ ")") mparent

showChildTask :: Struct -> Maybe String
showChildTask struct = do
  arch <- lookupStruct "arch" struct
  state <- getTaskState struct
  method <- lookupStruct "method" struct
  taskid <- lookupStruct "id" struct
  return $ arch ++ replicate (8 - length arch) ' ' +-+ show (taskid :: Int) +-+ method +-+ show state

isBinaryRpm :: FilePath -> Bool
isBinaryRpm file =
  ".rpm" `isExtensionOf` file && not (".src.rpm" `isExtensionOf` file)

#if !MIN_VERSION_filepath(1,4,2)
isExtensionOf :: String -> FilePath -> Bool
isExtensionOf ext@('.':_) = isSuffixOf ext . takeExtensions
isExtensionOf ext         = isSuffixOf ('.':ext) . takeExtensions
#endif