packages feed

fbrnch-1.8.1: src/Koji.hs

module Koji (
  kojiNVRTags,
  kojiBuildStatus,
  kojiBuildTags,
  kojiGetBuildID,
  kojiGetBuildState,
  kojiGetBuildTaskID,
  kojiLatestNVR,
  kojiOpenTasks,
  kojiScratchBuild,
  kojiUserSideTags,
  buildIDInfo,
  BuildInfo(BuildInfoNVR),
  BuildState(..),
  kojiBuildBranch,
  kojiBuildBranchNoWait,
  kojiSource,
  kojiBuildTarget',
  kojiTagArchs,
  kojiWaitRepoNVR,
  kojiWaitRepoNVRs,
  kojiWaitRepo,
  kojiWatchTask,
  kojiWaitTask,
  putTaskinfoUrl,
  TaskID,
  displayID,
  fedoraHub,
  getKojiProfileHub,
  maybeTimeout,
  createKojiSidetag,
  targetMaybeSidetag
  ) where

import Data.Char (isDigit)

import qualified Data.ByteString.Lazy.Char8 as B
import Control.Concurrent (threadDelay)
import Control.Exception.Extra (retry)
import Data.Either.Extra (eitherToMaybe, isRight)
import Data.Fixed (Micro)
import qualified Data.Map.Strict as M
import Data.RPM (dropArch)
import Data.RPM.NVR (NVR, maybeNVR)
import Data.RPM.NVRA (maybeNVRA)
import Data.Time.Clock
import Data.Time.Format
import Data.Time.LocalTime
import Distribution.Koji
import qualified Distribution.Koji.API as Koji
import Distribution.Fedora.Branch (branchRelease)
import Distribution.Fedora.Release (releaseDistTag)
import Fedora.Krb (fasIdFromKrb, krbTicket)
import Safe (headMay, tailSafe)
import Say (sayString)
import SimplePrompt (promptEnter, promptNonEmpty, yesNo)
import System.Console.Pretty (color, Color(Cyan))
import System.Environment (lookupEnv)
import System.Exit
import System.Process.Typed (proc, readProcessStdout)
import System.Timeout (timeout)
import System.Time.Extra (sleep)

import Branches
import Common
import Common.System
import Git
import Package (fedpkg)
import Pagure
import Types

fedoraHub :: String
fedoraHub = fedoraKojiHub

getKojiProfile :: IO (Maybe String)
getKojiProfile = lookupEnv "FBRNCH_KOJI_PROFILE"

getKojiProfileHub' :: Maybe String -> IO String
getKojiProfileHub' mprofile = do
  case mprofile of
    Nothing -> return fedoraKojiHub
    Just p -> do
      let file = "/etc/koji.conf.d" </> p <.> "conf"
      exists <- doesFileExist file
      if exists
        then do
        serv <- grep "^server =" $ "/etc/koji.conf.d" </> p <.> "conf"
        case serv of
          [sl] ->
            case words sl of
              ["server","=",hub] -> return hub
              _ -> error' $ "Invalid server config in" +-+ file
          _ -> error' $ "Undefined server in" +-+ file
        else error' $ "Undefined koji profile:" +-+ p

getKojiProfileHub :: IO String
getKojiProfileHub =
  getKojiProfile >>= getKojiProfileHub'

kojiNVRTags :: NVR -> IO [String]
kojiNVRTags nvr = do
  hub <- getKojiProfileHub
  mbldid <- kojiGetBuildID hub $ showNVR nvr
  case mbldid of
    Nothing -> error' $ showNVR nvr +-+ "koji build not found"
    Just bldid -> kojiBuildTags hub (buildIDInfo bldid)

kojiBuildStatus :: NVR -> IO (Maybe BuildState)
kojiBuildStatus nvr = do
  hub <- getKojiProfileHub
  kojiGetBuildState hub (BuildInfoNVR (showNVR nvr))

kojiLatestNVR :: String -> String -> IO (Maybe NVR)
kojiLatestNVR tag pkg = do
  hub <- getKojiProfileHub
  mbld <- kojiLatestBuild hub tag pkg
  return $ case mbld of
             Nothing -> Nothing
             Just bld -> lookupStruct "nvr" bld >>= maybeNVR


kojiOpenTasks :: Package -> Maybe String -> String -> IO [TaskID]
kojiOpenTasks pkg mref target = do
  hub <- getKojiProfileHub
  user <- fasIdFromKrb
  muserid <- kojiGetUserID hub user
  let userid = fromMaybe (error' $ "Koji failed to return userid for '" ++ user ++ "'") muserid
  commit <- maybe (git "rev-parse" ["HEAD"]) return mref
  let source = kojiSource pkg commit
  kojiUserBuildTasks hub userid (Just source) (Just target)

-- * Koji building

kojiScratchBuild :: String -> [String] -> FilePath -> IO String
kojiScratchBuild target args srpm = do
  Right url <- kojiBuild' True target $ args ++ ["--scratch", "--no-rebuild-srpm", srpm]
  return url

type KojiBuildTask = Either TaskID String

-- FIXME setTermTitle nvr
kojiBuild' :: Bool -> String -> [String] -> IO KojiBuildTask
kojiBuild' wait target args = do
  krbTicket
  let srpm = if null args
             then error' "no args passed to koji build"
             else ".src.rpm" `isSuffixOf` last args
  -- FIXME use tee functionality
  mprofile <- getKojiProfile
  when srpm $ putStrLn $ "koji srpm build: uploading..." +-+ maybe "" ("to" +-+) mprofile
  -- can fail like:
  -- [ERROR] koji: Request error: POST::https://koji.fedoraproject.org/kojihub/ssllogin::<PreparedRequest [POST]>
  -- [ERROR] koji: AuthError: unable to obtain a session
  -- readCreateProcess: koji "build" "--nowait" "f33-build-side-25385" "--fail-fast" "--background" ... (exit 1): failed
  (ret,out) <- readProcessStdout $ proc "koji" $ kojiProfileOpt mprofile ++ ["build", "--nowait", target] ++ args
  -- for srpm: drop uploading line until doing tee
  -- for git: drop "Created task: "
  -- init to drop final newline
  unless (B.null out) $
    -- FIXME include output example here
    logMsg $ (dropPrefix "Task info: " . B.unpack . B.init . B.unlines . tailSafe . B.lines) out
  if ret == ExitSuccess
    then do
    let kojiurl = B.unpack $ last $ B.words out
        task = (TaskId . read) $ takeWhileEnd isDigit kojiurl
    when wait $ do
      -- FIXME get actual build time
      timeIO $ kojiWatchTask task
      cmd_ "date" ["+%T"]
    return $ if wait then Right kojiurl else Left task
    else do
    promptEnter "Press Enter to resubmit Koji build"
    kojiBuild' wait target args

-- kojiBuild :: String -> [String] -> IO String
-- kojiBuild target args = do
--   Right url <- kojiBuild' True target args
--   return url

kojiProfileOpt :: Maybe String -> [String]
kojiProfileOpt Nothing = []
kojiProfileOpt (Just p) = ["--profile", p]

-- # Native Watch Task

threadDelaySeconds :: Int -> IO ()
threadDelaySeconds m =
  -- convert seconds to microseconds
  threadDelay (fromEnum (fromIntegral m :: Micro))

-- FIXME import from koji-hs once released
showTaskState :: TaskState -> String
showTaskState state =
  case state of
    TaskFree -> "free"
    TaskOpen -> "open"
    TaskClosed -> "closed"
    TaskCanceled -> "Canceled"
    TaskAssigned -> "assigned"
    TaskFailed -> "FAILED"

data TaskStatus a =
  TaskStatus { tsId :: Int -- FIXME should be TaskID
             , tsArch :: String -- FIXME use Arch type
             , tsHostId :: Int
             , tsMethod :: String
             , tsNVR :: Maybe NVR
             , tsState :: a}

readTaskStatus :: Struct -> Maybe (TaskStatus TaskState)
readTaskStatus st = do
  tid <- lookupStruct "id" st
  arch <- lookupStruct "arch" st
  hostid <- lookupStruct "host_id" st
  let mnvr = eitherToMaybe $ kojiTaskRequestNVRTag st
  method <- lookupStruct "method" st
  state <- readTaskState <$> lookupStruct "state" st
  return $ TaskStatus tid arch hostid method mnvr state

kojiGetTaskState' :: String -- ^ hub url
                  -> TaskID
                  -> IO TaskState
kojiGetTaskState' hub taskid = do
  mstate <- retry 2 $ kojiGetTaskState hub taskid
  case mstate of
    Nothing -> error' $ "No state for task" +-+ displayID taskid
    Just state -> return state

-- FIXME return failures
kojiWatchBuildTask :: String -> TimeZone -> Bool -> TaskID -> IO ()
kojiWatchBuildTask hub tz nvrdisplayed' taskid = do
  current <- getChildrenTasks
  nvrdisplayed <- maybeDisplayNVR nvrdisplayed' current
  mapM_ printHost current
  mapM_ (printTaskState showTaskState) current
  loopWatch nvrdisplayed current
  where
    getChildrenTasks =
      -- fbrnch: user error (502 Bad Gateway)
      sortOn tsId . mapMaybe readTaskStatus <$> retry 2 (kojiGetTaskChildren hub taskid True)

    pad txt suffix =
      let spaces = 8 - length txt
      in txt ++ suffix ++ replicate spaces ' '

    -- FIXME create a host cache
    printHost ts = do
      let txt =
            let method = tsMethod ts in
              if method == "buildArch"
              then tsArch ts
              else
                if "SRPM" `isInfixOf` method
                then "src"
                else method
      host <- Koji.getHost hub (Koji.InfoID $ tsHostId ts) Nothing
      whenJust (lookupStruct "name" host) $ \name ->
        sayString $ pad txt ":" +-+ name

    printTaskState render ts =
      logSay tz $ parens (show (tsId ts)) +-+ maybeMethod +-+ pad (tsArch ts) ":" +-+ render (tsState ts)
      where
        maybeMethod =
          let method = tsMethod ts
          in if method == "buildArch" then "" else method

    maybeDisplayNVR nvrdisplayed current =
      if nvrdisplayed
      then return True
      else
        case mapMaybe tsNVR current of
          [] -> return False
          (nvr:_) -> do
            putStrLn $ "Koji NVR:" +-+ color Cyan (showNVR nvr)
            return True

    loopWatch wasdisplayed previous = do
      current <- getChildrenTasks
      nvrdisplayed <- maybeDisplayNVR wasdisplayed current
      mapM_ printTaskChange $ updateStates previous current
      if any (\t -> tsState t `elem` openTaskStates) current
        then do
        threadDelaySeconds 31
        loopWatch nvrdisplayed current
        else do
        pstate <- kojiGetTaskState' hub taskid
        when (pstate `elem` openTaskStates) $ do
          threadDelaySeconds 31
          loopWatch nvrdisplayed current

    updateStates :: [TaskStatus TaskState] -> [TaskStatus TaskState] -> [TaskStatus (Maybe TaskState,TaskState)]
    updateStates previous current =
      let old = M.fromList [ (tsId task, tsState task) | task <- previous]
          addOld ts =
            let moldState = M.lookup (tsId ts) old
            in ts {tsState = (moldState, tsState ts)}
      in map addOld current

    printTaskChange ts =
      let (mold,new) = tsState ts in
        do
          when (isNothing mold) $
            printHost ts
          unless (mold == Just new) $
            let change = maybe "" (\s -> showTaskState s +-+ "->") mold +-+ showTaskState new
            in printTaskState id $ ts {tsState = change}

kojiWatchTask1 :: String -> TaskID -> IO (String, TaskState) -- method
kojiWatchTask1 hub taskid = do
  mtaskinfo <- kojiGetTaskInfo hub taskid
  case mtaskinfo of
    Nothing -> error' $ "no taskinfo for task" +-+ displayID taskid
    Just taskinfo -> do
      case kojiTaskRequestNVRTag taskinfo of
        Left pkgtag -> sayString $ color Cyan pkgtag
        Right nvr -> sayString $ showNVR nvr
      let mstate = getTaskState taskinfo
      case mstate of
        Nothing -> error' $ "No state for task" +-+ displayID taskid
        Just state -> do
          tz <- getCurrentTimeZone
          case lookupStruct "method" taskinfo of
            Nothing -> error' $ "No method for task" +-+ displayID taskid
            Just method -> do
              when (state `elem` openTaskStates) $
                logSay tz $
                parens (displayID taskid) +-+ method ++ ":" +-+ showTaskState state
              kojiDoWatchTask tz method state
        where
          kojiDoWatchTask tz method oldstate = do
            if method == "build" && oldstate /= TaskFree
              then kojiWatchBuildTask hub tz (isRight $ kojiTaskRequestNVRTag taskinfo) taskid
              else threadDelaySeconds 30
            state <- kojiGetTaskState' hub taskid
            when (state /= oldstate) $ do
              logSay tz $
                parens (displayID taskid) +-+ method ++ ":" +-+ showTaskState oldstate +-+ "->" +-+ showTaskState state
            if state `elem` openTaskStates
              then kojiDoWatchTask tz method state
              else do
              logSay tz $
                parens (displayID taskid) +-+ method ++ ":" +-+ showTaskState state
              return (method,state)

-- FIXME print target tag
kojiWatchTask :: TaskID -> IO ()
kojiWatchTask task = do
  mprofile <- getKojiProfile
  -- (2026) seeing frequent timeouts for each build (every few minutes)
  hub <- getKojiProfileHub' mprofile
  (method,state) <- kojiWatchTask1 hub task
  when (state == TaskFailed) $ do
    whenJustM (findExecutable "koji-tool") $ \kojitool -> do
      cmdLog_ kojitool ["tasks", "--hub", hub, "--children", displayID task, "--tail", "-s", "fail"]
      putTaskinfoUrl hub task
  (if state == TaskClosed then putStrLn else error') $
    method +-+ "task" +-+ showTaskState state

putTaskinfoUrl :: String -> TaskID -> IO ()
putTaskinfoUrl hub tid =
  putStrLn $ dropSuffix "hub" hub +/+ "taskinfo?taskID=" ++ displayID tid

-- FIXME during network disconnection:
-- Connection timed out: retrying
-- Connection timed out: retrying
-- Network.Socket.connect: <socket: 11>: does not exist (No route to host)
kojiWaitTask :: TaskID -> IO Bool
kojiWaitTask task = do
  hub <- getKojiProfileHub
  -- FIXME can error:
  -- eg1 [ERROR] koji: HTTPError: 503 Server Error: Service Unavailable for url: https://koji.fedoraproject.org/kojihub
  -- eg2 [ERROR] koji: ServerOffline: database outage: - user error (Error 1014: database outage)
  -- was: maybeTimeout 45 $
  state <- kojiGetTaskState' hub task
  if state `elem` openTaskStates
  then do
    sleep 20
    kojiWaitTask task
  else return $ state == TaskClosed

kojiSource :: Package -> String -> String
kojiSource pkg ref =
  "git+https://" ++ srcfpo ++ "/rpms" +/+ unPackage pkg ++ ".git#" ++ ref

kojiBuildBranch' :: Bool -> String -> Package -> Maybe String -> [String]
                 -> IO KojiBuildTask
kojiBuildBranch' wait target pkg mref args = do
  commit <- maybe (git "rev-parse" ["HEAD"]) return mref
  kojiBuild' wait target $ args ++ [kojiSource pkg commit]

kojiBuildBranch :: String -> Package -> Maybe String -> [String] -> IO ()
kojiBuildBranch target pkg mref args =
  checkResult <$> kojiBuildBranch' True target pkg mref args
  where
    checkResult = either (\ task -> error' (displayID task +-+ "not completed")) (const ())

kojiBuildBranchNoWait ::String -> Package -> Maybe String -> [String] -> IO TaskID
kojiBuildBranchNoWait target pkg mref args = do
  Left task <- kojiBuildBranch' False target pkg mref args
  return task

-- remove once in koji-hs
kojiBuildTarget' :: String -- ^ hubUrl
                 -> String -- ^ target
                 -> IO (String, String) -- ^ (build-tag,dest-tag)
kojiBuildTarget' hub target = do
  mres <- kojiBuildTarget hub target
  case mres of
    Nothing -> error' $ "failed to get BuildTarget for" +-+ target
    Just res -> return res

-- FIXME should be NonEmpty
-- FIXME add back knowntag?
kojiWaitRepoNVRs :: Bool -> Bool -> String -> [NVR] -> IO ()
kojiWaitRepoNVRs dryrun quiet target nvrs = do
  hub <- getKojiProfileHub
  (buildtag,_desttag) <- kojiBuildTarget' hub target
  unless dryrun $ do
    unless quiet $ do
      tz <- getCurrentTimeZone
      logSay tz $ "Waiting for" +-+ buildtag +-+
        case nvrs of
          [] -> ""
          [nvr] -> "to have" +-+ showNVR nvr
          _ -> "to have builds"
    mprofile <- getKojiProfile
    -- FIXME use knowntag to quieten output: for override outputs, eg
    -- "nvr ghc-rpm-macros-2.7.5-1.fc41 is not current in tag f41-build
    --    latest build is ghc-rpm-macros-2.7.2-4.fc41"
    -- or "No clang18 builds in tag epel10.1-build"
    krbTicket
    void $ timeIO $ cmd "koji" (kojiProfileOpt mprofile ++ ["wait-repo", "--request", "--quiet"] ++ ["--build=" ++ showNVR nvr | nvr <- nvrs] ++ [buildtag])

kojiWaitRepoNVR :: Bool -> Bool -> String -> NVR -> IO ()
kojiWaitRepoNVR dryrun quiet target nvr =
  kojiWaitRepoNVRs dryrun quiet target [nvr]

-- FIXME display more status/age info
kojiWaitRepo :: Bool -> Bool -> String -> IO ()
kojiWaitRepo dryrun quiet target =
  kojiWaitRepoNVRs dryrun quiet target []

kojiTagArchs :: String -> IO [String]
kojiTagArchs tag = do
  hub <- getKojiProfileHub
  st <- Koji.getTag hub (Koji.InfoString tag) Nothing
  return $ maybe [] words $ lookupStruct "arches" st

kojiUserSideTags :: Maybe Branch -> IO [String]
kojiUserSideTags mbr = do
  hub <- getKojiProfileHub
  user <- fasIdFromKrb
  mapMaybe (headMay . words) <$>
    case mbr of
      Nothing -> do
        maybeTimeout 55 $ kojiListSideTags hub Nothing (Just user)
      Just br -> do
        mtags <- kojiBuildTarget hub (showBranch br)
        case mtags of
          Nothing -> return []
          Just (buildtag,_desttag) ->
            kojiListSideTags hub (Just buildtag) (Just user)

maybeTimeout :: Micro -> IO a -> IO a
maybeTimeout secs act = do
  mres <- timeout (fromEnum secs) act
  case mres of
    Nothing -> do
      warning "Connection timed out: retrying"
      maybeTimeout (secs + 5) act
    Just res -> return res

createKojiSidetag :: Bool -> Branch -> IO String
createKojiSidetag dryrun br = do
  hub <- getKojiProfileHub
  (buildtag,_desttag) <- kojiBuildTarget' hub (showBranch br)
  out <-
    if dryrun
    then return $ "Side tag '" ++ buildtag ++ "'"
    else do
      krbTicket
      ls <- lines <$> fedpkg "request-side-tag" ["--base-tag",  buildtag]
      case ls of
        [] -> error' "no output from request-side-tag"
        (l:_) -> return l
  if "Side tag '" `isPrefixOf` out
    then do
    putNewLn
    let sidetag =
          init . dropWhileEnd (/= '\'') $ dropPrefix "Side tag '" out
    putStrLn $ "Sidetag" +-+ sidetag +-+ "created"
    -- logMsg $ "Waiting for" +-+ sidetag +-+ "repo"
    -- unless dryrun $
    --   cmd_ koji ["wait-repo", sidetag]
    return sidetag
    else error' "'fedpkg request-side-tag' failed"

targetMaybeSidetag :: Bool -> Bool -> Bool -> Branch -> Maybe SideTagTarget
                   -> IO String
targetMaybeSidetag dryrun strict create br msidetagTarget =
  case msidetagTarget of
    Nothing -> return $ showBranch br
    Just (Target t) -> do
      disttag <- releaseDistTag <$> branchRelease br
      if t == "rawhide" && br == Rawhide
        then return disttag
        else do
        unless (disttag `isPrefixOf` t) $ do
          let msg = "Branch" +-+ showBranch br +-+ "does not match target" +-+ t in
            if strict
            then do
              ok <- yesNo $ msg ++ "! Are you sure?"
              unless ok $ error' "aborted"
            else
              whenM isPkgGitRepo $
              warning ("Warning:" +-+ msg)
        return t
    Just SideTag -> do
      tags <- kojiUserSideTags (Just br)
      case tags of
        [] ->
          if create
          then createKojiSidetag dryrun br
          else error' "no existing side-tag"
        [tag] -> return tag
        _ -> do
          putStrLn $ "User side-tags found for" +-+ showBranch br ++ ":"

          let num = length tags
              nums = map show [1..num]
              numtags = zip nums $ sort tags
          mapM_ (putStrLn . \(n,s) -> n ++ ":" +-+ s) numtags
          tag <- promptTags numtags $ "Choose" +-+ (if num == 2 then "1 or 2" else "1-" ++ show num) +-+ "or enter sidetag; or 0 to abort"
          putStrLn $ "will use" +-+ tag
          putNewLn
          return tag

logSay :: TimeZone -> String -> IO ()
logSay tz str = do
  now <- utcToLocalTime tz <$> getCurrentTime
  sayString $ formatTime defaultTimeLocale "%T" now +-+ str

promptTags :: [(String,String)] -> String -> IO String
promptTags numtags txt = do
  ans <- promptNonEmpty txt
  if ans `elem` map snd numtags
    then return ans
    else
    case lookup ans numtags of
      Just t -> return t
      Nothing ->
        if lower ans `elem` ["0", "q", "quit", "no"]
        then error' "aborting"
        else promptTags numtags txt

-- modified from koji-tool Utils
-- FIXME better to handle build and buildArch separately
-- FIXME in theory could read tagId for buildArch
kojiTaskRequestNVRTag :: Struct -> Either String NVR
kojiTaskRequestNVRTag task =
  case lookupStruct "request" task of
    Just req@(source:rest) ->
      case getString source of
        -- non-build task
        Nothing -> Left $ unwords $ map showValue $ take 2 req
        Just src ->
          case maybeNVRA src of
            Just nvra | not (".git#" `isInfixOf` src) ->
                        Right $ dropArch nvra
            _ -> let base = takeBaseName src
                     mtag = headMay rest >>= getString
                 in
              Left $
              -- fedora-ci_fc87dcd7db694cdfac703cf5af128a72_c1f53c859616fdbf40811058b60bebe2930e3271_0;forks:petersen:rpms:grub2.f38.src.rpm
              (if "fedora-ci_" `isPrefixOf` base
               then takeWhileEnd (/= ':') $ dropPrefix ";" $ dropWhile (/= ';') base
               else base) +-+ fromMaybe "" mtag
    _ -> error' "could not determine package from build request"
  where
    showValue :: Value -> String
    showValue (ValueString cs) = cs
    showValue (ValueInt i) = show i
    showValue val = show val

parens :: String -> String
parens str =
  '(' : str ++ ")"