packages feed

fbrnch-1.8: src/Cmd/Copr.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module Cmd.Copr (
  BuildBy(..),
  coprCmd,
  CoprMode(..)
  )
where

import Data.Aeson.Types (Object)
#if MIN_VERSION_aeson(2,0,0)
import Data.Aeson.Key
import qualified Data.Aeson.KeyMap as M
#else
import qualified Data.HashMap.Strict as M
#endif
import qualified Data.ByteString.Lazy.UTF8 as U
import Data.Char (isDigit)
import  Data.Either.Combinators (whenLeft)
import Data.Ini.Config
import Data.RPM.NVR (nvrVerRel)
import Data.RPM.VerRel (showVerRel)
import Data.Tuple.Extra (first)
import Distribution.Fedora.Branch (getActiveBranches, getActiveBranched)
import Network.HTTP.Query (lookupKey, lookupKey')
import Network.HTTP.Simple as HTTP
import Safe (headDef, lastMay)
import System.Environment.XDG.BaseDir (getUserConfigDir)
import System.Time.Extra (sleep)
import Web.Fedora.Copr (coprChroots, fedoraCopr)
import Web.Fedora.Copr.API (coprGetBuild, coprGetBuildList, coprMonitorProject)

import Branches
import Common
import Common.System
import qualified Common.Text as T
import Package
import RpmBuild (generateSrpmNoDist)
import Types (Archs(..))

data BuildBy = SingleBuild
             | ValidateByRelease
             | ValidateByArch
             | BuildByRelease
  deriving Eq

coprServer :: String
coprServer = "copr.fedorainfracloud.org"

releaseToBranch :: String -> Branch
releaseToBranch release
  | "fedora-" `isPrefixOf` release =
    case dropPrefix "fedora-" release of
      "rawhide" -> Rawhide
      n | all isDigit n -> Fedora (read n)
        | otherwise -> error' $ "unknown fedora release:" +-+ release
  | "epel-" `isPrefixOf` release =
    let next = "-next" `isSuffixOf` release
    in case dropSuffix "-next" (dropPrefix "epel-" release) of
         n | all isDigit n -> (if next then EPELNext else EPEL) $ read n
           | otherwise -> error' $ "unknown epel release:" +-+ release
  | otherwise = error' $ "unknown release:" +-+ release

branchToRelease :: Branch -> String
branchToRelease Rawhide = "fedora-rawhide"
branchToRelease (Fedora n) = "fedora-" ++ show n
branchToRelease (EPEL n) = "epel-" ++ show n
branchToRelease (EPELNext n) = "epel-" ++ show n ++ "-next"
-- unsupported really, maybe map to rhel?
branchToRelease (EPELMinor n _) = "epel-" ++ show n

data Chroot = Chroot { chrootBranch :: Branch
                     , chrootArch :: Arch }
  deriving Eq

readChroot :: String -> Chroot
readChroot ch =
  case stripInfixEnd "-" ch of
    Nothing -> error' $ "invalid chroot:" +-+ ch
    Just (b,a) -> Chroot (releaseToBranch b) (readArch a)

showChroot :: Chroot -> String
showChroot (Chroot br ar) = branchToRelease br ++ '-' : showArch ar

instance Ord Chroot where
  compare (Chroot b1 _) (Chroot b2 _) = compare b1 b2

data Arch = X86_64 | I386 | AARCH64 | PPC64LE | S390X
  deriving (Eq, Enum)

allCoprArchs :: [Arch]
allCoprArchs = [X86_64 ..]

readArch :: String -> Arch
readArch "x86_64" = X86_64
readArch "i386" = I386
readArch "aarch64" = AARCH64
readArch "ppc64le" = PPC64LE
readArch "s390x" = S390X
readArch a = error' $ "unknown arch:" +-+ a

showArch :: Arch -> String
showArch X86_64 = "x86_64"
showArch I386 = "i386"
showArch AARCH64 = "aarch64"
showArch PPC64LE = "ppc64le"
showArch S390X = "s390x"

data CoprMode = ListChroots
              | CoprMonitor (Maybe String) Bool -- FIXME should be Maybe (String, Bool) probably
              | CoprBuild
              | CoprNew
              | CoprFailures (Maybe String)

-- FIXME take ExclusiveArch/ExcludeArch into account
-- FIXME -1 for only first unbuilt chroot
-- FIXME check branch
-- FIXME handle fedora-eln
-- FIXME make project optional (if same as pkg??) or configurable ?
-- FIXME repo config with a setup command?
-- FIXME interact with copr dist-git
-- FIXME parallel copr builds
-- FIXME time builds?
coprCmd :: Bool -> CoprMode -> Bool -> Maybe BuildBy -> Maybe Archs -> String
        -> (BranchesReq,[String]) -> IO ()
coprCmd dryrun mode force mbuildBy marchs copr (breq, pkgs) = do
  -- FIXME disallow user/project for build/new?
  (user,project) <- coprUserProject copr
  let buildBy = fromMaybe ValidateByRelease mbuildBy
  case mode of
    ListChroots -> coprGetChroots user project >>= mapM_ (putStrLn . showChroot)
    CoprMonitor mneedle nameonly -> coprMonitorPackages user project >>= mapM_ (printPkgRes mneedle nameonly)
    CoprNew -> coprNewProject dryrun project marchs breq pkgs
    CoprFailures mgrep -> coprMonitorPackages user project >>= mapM_ (printFailures user project mgrep)
    CoprBuild -> do
      chroots <- coprGetChroots user project
      if null pkgs
        then coprBuildPkg user project buildBy chroots False
        else
        mapM_ (\(n,p) -> withExistingDirectory p $ coprBuildPkg user project buildBy chroots (n>0)) $ zip (reverse [0..length pkgs - 1]) pkgs
  where
    coprGetChroots :: String -> String -> IO [Chroot]
    coprGetChroots user project = do
      chroots <- reverseSort . map (readChroot . T.unpack) <$> coprChroots coprServer user project
      when (null chroots) $
        error' $ "No chroots found for" +-+ user ++ "/" ++ project
      branches <-
        case breq of
          Branches brs ->
            if null brs
            then return $ (nub . map chrootBranch) chroots
            else listOfBranches False False breq
          BranchOpt AllFedora -> filter isFedoraBranch <$> getActiveBranches
          BranchOpt AllEPEL -> filter isEPELBranch <$> getActiveBranched
          _ -> listOfBranches False False breq
      let buildroots =
            -- FIXME sort archs appropriately
            case marchs of
              Nothing ->
                [chroot | arch <- sortArchs allCoprArchs, br <- branches, let chroot = Chroot br arch, chroot `elem` chroots]
              -- FIXME warn about missing arch chroots
              Just (Archs archs) ->
                [chroot | arch <- sortArchs (map readArch archs), br <- branches, let chroot = Chroot br arch, chroot `elem` chroots]
              -- FIXME warn about missing arch chroots
              Just (ExcludedArchs exarchs) ->
                [chroot | arch <- sortArchs allCoprArchs, arch `notElem` map readArch exarchs, br <- branches, let chroot = Chroot br arch, chroot `elem` chroots]
      if null buildroots
        then error' "No valid chroots"
        else return buildroots

    coprBuildPkg :: String -> String -> BuildBy -> [Chroot] -> Bool -> IO ()
    coprBuildPkg user project buildBy chroots morepkgs = do
      -- FIXME check is pkg.spec
      -- was: localBranchSpecFile pkg (RelBranch Rawhide)
      spec <- findSpecfile
      -- pkg <- takeFileName <$> getCurrentDirectory
      srpm <- if not dryrun
              then generateSrpmNoDist True False Nothing spec
              else return spec -- hack to avoid generating srpm for dryrun
      verrel <- showVerRel . nvrVerRel <$> pkgNameVerRelNodist spec
      actualpkg <- cmd "rpmspec" ["-q", "--srpm", "--qf", "%{name}", spec]
      builtChroots <- existingChrootBuilds dryrun user project (Package actualpkg) verrel chroots
      let finalChroots = chroots \\ if force then [] else map taskChroot builtChroots
      case finalChroots of
        [] -> putStrLn $ actualpkg ++ '-' : verrel +-+ "built"
        (final:_) ->
          case buildBy of
            SingleBuild -> coprBuild dryrun user project srpm spec finalChroots
            -- FIXME or default to secondary parallel to previous primary
            ValidateByRelease -> do
              let initialChroots =
                    let primaryArch = chrootArch final
                    in map pure $ filter (isArch primaryArch) finalChroots
                  remainingChroots = finalChroots \\ concat initialChroots
              staggerBuilds srpm spec initialChroots remainingChroots
            ValidateByArch -> do
              let initialChroots =
                    let newestRelease = chrootBranch final
                    in map pure $ filter ((== newestRelease) . chrootBranch) finalChroots
                  remainingChroots = finalChroots \\ concat initialChroots
              staggerBuilds srpm spec initialChroots remainingChroots
            BuildByRelease -> do
              let releaseChroots = groupBy sameRelease finalChroots
              staggerBuilds srpm spec releaseChroots []
      when morepkgs putNewLn
      where
        staggerBuilds :: FilePath -> FilePath -> [[Chroot]] -> [Chroot] -> IO ()
        staggerBuilds srpm spec initialChroots remainingChroots = do
          mapM_ (coprBuild dryrun user project srpm spec) initialChroots
          unless (null remainingChroots) $
            coprBuild dryrun user project srpm spec remainingChroots

    isArch arch release = chrootArch release == arch

    sameRelease r1 r2 = chrootBranch r1 == chrootBranch r2

-- from copr-tool
-- FIXME CoprProject datatype
coprUserProject :: String -> IO (String,String)
coprUserProject copr =
  case splitCopr of
    Just (u,p) -> return (u,p)
    Nothing -> do
      fasid <- getUsername
      return (fasid, copr)
  where
    splitCopr :: Maybe (String, String)
    splitCopr =
      case splitOn "/" copr of
        [u,c] | not (null u || null c) -> Just (u,c)
        _ -> Nothing

getUsername :: IO String
getUsername = do
  rc <- getUserConfigDir "copr"
  readIniConfig rc rcParser id
  where
    rcParser :: IniParser String
    rcParser =
      section "copr-cli" $
      fieldOf "username" string

-- changed from Bugzilla.hs
readIniConfig :: FilePath -> IniParser a -> (a -> b) -> IO b
readIniConfig inifile iniparser record = do
  havefile <- doesFileExist inifile
  if not havefile
    then error' $ inifile +-+ "not found: try https://" ++ coprServer ++ "/api"
    else do
    ini <- T.readFile inifile
    let config = parseIniFile ini iniparser
    return $ either error' record config

-- from copr/frontend/coprs_frontend/coprs/logic/builds_logic.py
-- FIXME after adding to copr-api
coprProcessingStates :: [String]
coprProcessingStates =
  ["running", "pending", "starting", "importing", "waiting"]

-- coprEndedStates :: [String]
-- coprEndedStates =
--   ["canceled", "failed", "skipped", "succeeded"]

-- FIXME restrict to requested chroots?
existingChrootBuilds :: Bool -> String -> String -> Package -> String
                     -> [Chroot] -> IO [CoprTask]
existingChrootBuilds dryrun user project actualpkg verrel chroots = do
  monitorPkgs <- coprMonitorPackages user project
  let pkgmonitor = fromMaybe [] $ lookup actualpkg monitorPkgs
  let buildingChroots =
        filterTasks verrel (`elem` coprProcessingStates) pkgmonitor
      builds = filterTasks verrel (`notElem` ["failed","skipped"]) pkgmonitor
  if null buildingChroots
    then return builds
    else do
    mapM_ printCoprTask buildingChroots
    putNewLn
    unless dryrun $
      forM_ buildingChroots $ \building ->
      when (taskChroot building `elem` chroots) $
      -- FIXME check failure
      void $ coprWatchBuild Nothing $ Left building
    return builds

filterTasks :: String -> (String -> Bool) -> [CoprTask] -> [CoprTask]
filterTasks verrel statustest =
  filter (\CoprTask{..} -> taskVerRel == verrel && statustest taskStatus)

coprBuild :: Bool -> String -> String -> FilePath -> FilePath -> [Chroot] -> IO ()
coprBuild _ _ _ _ _ [] = error' "No chroots chosen"
coprBuild dryrun user project srpm spec chroots = do
  let chrootargs = mconcat [["-r", showChroot bldrt] | bldrt <- chroots]
      buildargs = ["build", "--nowait"] ++ chrootargs ++ [project, srpm]
  putNewLn
  putStrLn $ unwords $ map showChroot chroots
  unless dryrun $ do
    output <- cmd "copr" buildargs
    putStrLn output
    let bid = read $ last $ words $ last $ lines output
    -- FIXME get the actual build time
    ok <- timeIO $ coprWatchBuild Nothing $ Right bid
    unless ok $ do
      putStrLn $ "Failed: copr" +-+ unwords buildargs
      -- FIXME determine which chroot(s) failed
      -- eg 06482247
      actualpkg <- cmd "rpmspec" ["-q", "--srpm", "--qf", "%{name}", spec]
      -- FIXME which chroot?
      -- FIXME print buildlog size
      error' $ "https://download.copr.fedorainfracloud.org/results" +/+ user +/+ project +/+ showChroot (headDef (error' " nochroot!") chroots) +/+ zeroBuildId bid ++ "-" ++ actualpkg +/+ "builder-live.log.gz"

zeroBuildId :: Int -> String
zeroBuildId bid =
  let s = show bid
  in (if length s < 8 then ('0' :) else id) s

-- FIXME idea: Maybe Seconds to increment FIXME
-- sleep should have all chroots in pending CoprTask build
coprWatchBuild :: Maybe String -> Either CoprTask Int -> IO Bool
coprWatchBuild mstate ebuild = do
  let bid = either taskBuild id ebuild
  res <- coprGetBuild fedoraCopr bid
  case lookupKey "state" res of
    Just state ->
      if mstate == Just state
      then sleep 20 >> coprWatchBuild mstate (Right bid)
      else do
        whenLeft ebuild $ \t -> putStrLn $ "#" +-+ showChroot (taskChroot t)
        logMsg $ "Build" +-+ show bid +-+ state +-+ either taskVerRel (const "") ebuild
        case state of
          "succeeded" -> return True
          "skipped" -> return True
          "canceled" -> return False
          "failed" -> return False
          _ -> sleep 1 >> coprWatchBuild (Just state) (Right bid)
    Nothing -> do
      let err = lookupKey' "error" res
      logMsg $ "Error:" +-+ err
      return False

archPriorities :: [(Arch,Int)]
archPriorities = [(X86_64,1), (AARCH64,2)]

sortArchs :: [Arch] -> [Arch]
sortArchs = sortBy priority
  where
    priority a1 a2 =
      let mp1 = lookup a1 archPriorities
          mp2 = lookup a2 archPriorities
      in
        case (mp1,mp2) of
          (Just p1,Just p2) ->
            compare p1 p2
          (Just _, Nothing) -> LT
          (Nothing, Just _) -> GT
          (Nothing,Nothing) -> EQ

type CoprPackage = (Package,[CoprTask])

data CoprTask = CoprTask {taskChroot :: Chroot,
                          taskBuild :: Int,
                          taskStatus :: String,
                          taskVerRel :: String
                         }

-- FIXME wait for None package to appear
coprMonitorPackages :: String -> String -> IO [CoprPackage]
coprMonitorPackages user proj = do
  builds <- coprGetBuildList fedoraCopr user proj []
  mapM_ coprWaitPackage (lookupKey' "items" builds :: [Object])
  mapMaybe pkgResults . lookupKey' "packages" <$>
    coprMonitorProject fedoraCopr user proj []
  where
    pkgResults :: Object -> Maybe CoprPackage
    pkgResults obj = do
      name <- lookupKey "name" obj
      chroots <- map (first toText) . M.toList <$> lookupKey "chroots" obj
      return (Package name, mapMaybe chrootResult chroots)
#if !MIN_VERSION_aeson(2,0,0)
      where toText = id
#endif

    chrootResult :: (T.Text,Object) -> Maybe CoprTask
    chrootResult (chroot, obj) = do
      state <- lookupKey "state" obj
      version <- lookupKey "pkg_version" obj
      build <- lookupKey "build_id" obj
      return $ CoprTask (readChroot (T.unpack chroot)) build state version

-- FIXME NonEmpty String
printPkgRes :: Maybe String -> Bool -> CoprPackage -> IO ()
printPkgRes Nothing _nameonly (pkg,chroots) = do
  putStr $ "# " <> unPackage pkg
  case chroots of
    [] ->   putNewLn
    [ch] -> putStr ": " >> printCoprTask ch
    _ -> putNewLn >> mapM_ printCoprTask chroots
  putNewLn
printPkgRes (Just needle) nameonly (pkg,chroots) = do
  case filterResults chroots of
    [] ->   return ()
    [ch] ->
      if nameonly
        then putStrLn $ unPackage pkg
      else do
        putStr $ "# " <> unPackage pkg <> ": "
        printCoprTask ch
    _ -> do
      putStrLn $ "# " <> unPackage pkg
      mapM_ printCoprTask chroots
  where
    filterResults :: [CoprTask] -> [CoprTask]
    filterResults [] = []
    filterResults (c:cs) =
      (if needle `isInfixOf` renderCoprTask c then (c :) else id)
      $ filterResults cs

renderCoprTask :: CoprTask -> String
renderCoprTask (CoprTask chr build status version) =
  version +-+ status +-+ "on" +-+ showChroot chr +-+ show build

printCoprTask :: CoprTask -> IO ()
printCoprTask =
  putStrLn . renderCoprTask

-- FIXME only latest pkg builds
printFailures :: String -> String -> Maybe String -> CoprPackage -> IO ()
printFailures user project mgrep (pkg,chroots) = do
  let fails = filter ((== "failed") . taskStatus) chroots
  unless (null fails) $ do
    putStrLn $ "#" +-+ unPackage pkg
    mapM_ (displayLog mgrep . logUrl) fails
  where
    logUrl task = "https://download.copr.fedorainfracloud.org/results" +/+ user +/+ project +/+ showChroot (taskChroot task) +/+ zeroBuildId (taskBuild task) ++ "-" ++ unPackage pkg +/+ "builder-live.log.gz"

-- adapted from koji-tool Tasks
displayLog :: Maybe String -> String -> IO ()
displayLog mgrep logurl = do
  req <- HTTP.parseRequest logurl
  resp <- HTTP.httpLBS req
  let out = U.toString $ HTTP.getResponseBody resp
      ls = lines out
  putStrLn ""
  let output
        | length out < 4000 =
          let excluded = ["Executing command:",
                          "Child return code was: 0",
                          "child environment: None",
                          "ensuring that dir exists:",
                          "touching file:",
                          "creating dir:",
                          "kill orphans"]
          in
            map (dropPrefix "DEBUG ") $ takeEnd 30 $
            filter (\l -> not (any (`isInfixOf` l) excluded)) ls
        | otherwise =
            case breakOnEnd ["RPM build errors:"] ls of
              ([],ls') -> ls'
              (ls',_) -> dropEnd 2 ls'
  putStr $ unlines $
    case mgrep of
      Nothing -> takeEnd 80 output
      Just needle ->
        filter (match needle) ls
  putStrLn $ '\n' : logurl ++ "\n"
  where
    match :: String -> String -> Bool
    match "" _ = error' "empty grep string not allowed"
    match _ "" = False
    match ('^':needle) ls =
      if lastMay needle == Just '$'
      then needle == ls
      else needle `isPrefixOf` ls
    match needle ls =
      if lastMay needle == Just '$'
      then needle `isSuffixOf` ls
      else needle `isInfixOf` ls


coprWaitPackage :: Object -> IO ()
coprWaitPackage build = do
  case buildResult of
    Nothing -> error' "unknown source_package"
    Just (_, "failed", _) -> return ()
    Just (_, _, Just _pkg) -> return ()
    Just (bid, _, Nothing) -> do
      sleep 5
      print bid
      coprGetBuild fedoraCopr bid >>= coprWaitPackage
  where
    buildResult = do
      bid <- lookupKey "id" build
      source <- lookupKey "source_package" build
      state <- lookupKey "state" build
      let mname = lookupKey "name" source
      return (bid :: Int, state :: String, mname :: Maybe String)

coprNewProject :: Bool -> String -> Maybe Archs -> BranchesReq -> [String]
               -> IO ()
coprNewProject dryrun project marchs breq pkgs = do
  branches <- listOfBranches False False breq
  let archs =
        case marchs of
          Nothing -> [X86_64]
          Just (Archs as) -> map readArch as
          Just (ExcludedArchs eas) -> allCoprArchs \\ map readArch eas
  let chroots = [ Chroot b a | b <- branches, a <- archs]
  (if dryrun then cmdN else cmd_) "copr" $ "create" : concatMap (\ch -> ["--chroot", showChroot ch]) chroots ++ [project]
  unless (null pkgs) $
    coprCmd False CoprBuild False Nothing Nothing project (breq, pkgs)