packages feed

koji-tool-1.3: src/Progress.hs

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

module Progress (
  progressCmd,
  TaskID(..)
  )
where

import Control.Applicative ((<|>))
import Control.Monad.Extra (forM, liftM2, unless, when)

import Formatting

#if !MIN_VERSION_http_directory(0,1,5)
import Network.HTTP.Client (Manager)
#endif
import Network.HTTP.Directory

import Control.Concurrent (threadDelay)

import Data.Fixed
import Data.Functor ((<&>))
import Data.Int (Int64)
import Data.List.Extra
import Data.Maybe
#if !MIN_VERSION_base(4,11,0)
import Data.Monoid ((<>))
#endif
import Data.RPM.NVR
import Data.Time (diffUTCTime, getCurrentTime, getCurrentTimeZone,
                  localTimeOfDay, NominalDiffTime, nominalDiffTimeToSeconds,
                  TimeZone, utcToLocalTime)
import Data.Text (Text)
import qualified Data.Text as T
import Distribution.Koji
import Safe (maximumMay)
import SimpleCmd

import Common (lookupArch)
import Tasks
import Time
import Utils

-- FIXME check parent status (not children) to drop
-- FIXME if failure and no more, then stop
-- FIXME catch HTTP exception for connection timeout
-- FIXME pick up new user builds (if none specified)
progressCmd :: Bool -> QueryOpts -> TaskReq -> IO ()
progressCmd modules queryopts@QueryOpts{..} taskreq = do
  tasks <- do
    tz <- getCurrentTimeZone
    let states =
          case taskreq of
            TaskQuery ->
              if null qStates
              then openTaskStates
              else qStates
            _ -> []
        mmethod =
          case taskreq of
            TaskQuery -> qmMethod <|> Just "build"
            _ -> Nothing
        muser =
          case taskreq of
            TaskQuery -> qmUserOpt
            _ -> Nothing
    ts <- getTasks tz fedoraKojiHub queryopts {qStates = states, qmMethod = mmethod, qmUserOpt = muser} taskreq
    if null ts
      then do
      tids <- kojiListBuildTasks $
              if modules
              then Just "mbs/mbs.fedoraproject.org"
              else Nothing
      forM tids $ \tid -> do
        mtaskinfo <- kojiGetTaskInfo fedoraKojiHub tid
        case mtaskinfo of
          Nothing -> error' $ "taskinfo not found for " ++ displayID tid
          Just taskinfo -> return taskinfo
      else do
      when modules $ error' "cannot combine --modules with tasks"
      return ts
  when (null tasks) $ error' "no build tasks found"
  btasks <- mapM initialBuildTask tasks
  tz <- getCurrentTimeZone
  loopBuildTasks qDebug tz btasks

data LogStatus = LogStatus
                 { logSize :: Int,
                   logTime :: UTCTime
                 }
  deriving Show

data TaskStatus = TaskStatus
                  { tstLog :: Maybe LogStatus,
                    tstState :: TaskState
                  }
  deriving Show

mkTaskStatus :: Maybe Int -> Maybe UTCTime -> Maybe TaskState -> Maybe TaskStatus
mkTaskStatus Nothing _ _ = Nothing
mkTaskStatus _ Nothing _ = Nothing
mkTaskStatus _ _ Nothing = Nothing
mkTaskStatus (Just size) (Just time) (Just state) =
  Just (TaskStatus (Just (LogStatus size time)) state)

-- FIXME change to (TaskID,Struct,Size,Time,State)
data TaskInfoStatus = TaskInfoStatus
                      { taskInfo :: Struct,
                        taskStatus :: Maybe TaskStatus}

data BuildTask =
  BuildTask TaskID UTCTime (Maybe UTCTime) (Maybe Int) [TaskInfoStatus]

initialBuildTask :: Struct -> IO BuildTask
initialBuildTask taskinfo = do
  let tid =
        case lookupStruct "id" taskinfo of
          Just tid' -> TaskId tid'
          Nothing -> error' $ "no taskid found for:" ++ show taskinfo
      parent =
        case lookupStruct "method" taskinfo :: Maybe String of
          Nothing -> error' $ "no method found for " ++ displayID tid
          Just method ->
            case method of
              "build" -> tid
              "buildArch" ->
                case lookupStruct "parent" taskinfo of
                  Nothing -> error' $ "no parent found for " ++ displayID tid
                  Just par -> TaskId par
              -- FIXME support newrepo
              -- https://koji.fedoraproject.org/koji/taskinfo?taskID=104446426
              -- https://kojipkgs.fedoraproject.org//work/tasks/6426/104446426/
              _ -> error' $ "unsupported method: " ++ method
  children <- sortOn lookupArch <$>
                      kojiGetTaskChildren fedoraKojiHub parent True
  let start =
        case lookupTime CreateEvent taskinfo of
          Nothing ->
            error' $ "task " ++ displayID tid ++ " has no create time"
          Just t -> t
      mend = lookupTime CompletionEvent taskinfo
  return $
    BuildTask parent start mend Nothing $
    map (`TaskInfoStatus` Nothing) children

type TaskInfoStatuses = (Struct,
                         (Maybe Int, Maybe UTCTime),
                         Maybe TaskStatus)

loopBuildTasks :: Bool -> TimeZone -> [BuildTask] -> IO ()
loopBuildTasks _ _ [] = return ()
loopBuildTasks debug tz bts = do
  curs <- filter tasksOpen <$> mapM runProgress bts
  unless (null curs) $ do
    news <- mapM updateBuildTask curs
    loopBuildTasks debug tz news
  where
    tasksOpen :: BuildTask -> Bool
    tasksOpen (BuildTask _ _ _ _ ts) = not (null ts)

    updateBuildTask :: BuildTask -> IO BuildTask
    updateBuildTask (BuildTask tid start mend msize ts) = do
      news <- mapM updateTask ts
      return (BuildTask tid start mend msize news)

    updateTask :: TaskInfoStatus -> IO TaskInfoStatus
    updateTask (TaskInfoStatus task mstatus) = do
      let tid = fromJust (readID task)
      mnew <- kojiGetTaskInfo fedoraKojiHub tid
      case mnew of
        Nothing -> error' $ "TaskInfo not found for " ++ displayID tid
        Just new -> return (TaskInfoStatus new mstatus)

    -- FIXME use last-modified to predict next update
    runProgress :: BuildTask -> IO BuildTask
    runProgress (BuildTask tid start mend msize tasks) =
      case tasks of
        [] -> do
          state <- kojiGetTaskState fedoraKojiHub tid
          if state `elem` map Just openTaskStates
            then do
            threadDelaySeconds 61
            mtaskinfo <- kojiGetTaskInfo fedoraKojiHub tid
            case mtaskinfo of
              Nothing -> error' $ "no taskinfo for " ++ show tid
              Just taskinfo -> initialBuildTask taskinfo
            else return $ BuildTask tid start Nothing msize []
        ((TaskInfoStatus task _):_) -> do
          when debug $ print task
          statuses <- mapM (buildlogSize debug 0) tasks
          end <- maybe getCurrentTime return mend
          let header =
                let pkg = either id showNVR $ kojiTaskRequestNVR task
                    duration = diffUTCTime end start
                in
                  -- FIXME mostly redundant for a single build task
                  logMsg $ pkg +-+ "(" ++ displayID tid ++ ")" +-+ maybe "" (\s -> show (s `div` 1000) ++ "kB,") msize +-+ renderDuration True duration
          printLogStatuses header tz statuses
          let news = map (\(task',(s,t),_) -> TaskInfoStatus task' (mkTaskStatus s t (getTaskState task'))) statuses
              (open,closed) = partition (\tis -> getTaskState (taskInfo tis) `elem` map Just openTaskStates) news
              closedbuilds = filter (\tis -> lookupStruct "method" (taskInfo tis) == Just ("buildArch" :: String)) closed
              mbiggest = maximumMay $ maybeToList msize ++ mapMaybe (\t -> taskStatus t >>= tstLog <&> logSize) closedbuilds
          if null open
            then runProgress (BuildTask tid start mend mbiggest [])
            else return $ BuildTask tid start mend mbiggest open

    -- getTaskState' task =
    --   case getTaskState task of
    --     Nothing -> error' $ "undefined task state: " ++ show task
    --     Just st -> st

buildlogSize :: Bool -> Int -> TaskInfoStatus -> IO TaskInfoStatuses
buildlogSize debug n (TaskInfoStatus task moldstatus) = do
  let tid = fromJust (lookupStruct "id" task)
      buildlog = buildlogUrlfromTaskId tid
  when debug $ putStrLn buildlog
  exists <- if isJust moldstatus
            then return True
            else httpExists' buildlog
  when (debug && n>0) $ putChar '.'
  waitDelay
  (msize,mtime) <- if exists
                   then httpFileSizeTime' buildlog
                   else return (Nothing,Nothing)
  when debug $ print (msize,mtime,moldstatus)
  if (mtime == fmap logTime moldlog || isNothing mtime) && n < 5
    then buildlogSize debug (n+1) (TaskInfoStatus task moldstatus)
    else
    return (task,
            (fromInteger <$> msize, mtime),
            moldstatus)
  where
    moldlog = moldstatus >>= tstLog :: Maybe LogStatus

    waitDelay ::  IO ()
    waitDelay = do
      case logTime <$> moldlog of
        Nothing -> when (n>0) $ threadDelaySeconds n
        Just ot -> do
          cur <- getCurrentTime
          let delay = delayTime (diffUTCTime cur ot)
          when debug $ print delay
          threadDelaySeconds delay

    delayTime :: NominalDiffTime -> Int
    delayTime dt =
      -- FIXME vary by small amounts
      let expect = 132 :: Pico
          lag = nominalDiffTimeToSeconds dt
      in if lag > expect
         then n
         else fromEnum (expect - lag ) `div` trillion

million, trillion :: Int
million = 1000000
trillion = million * million

secDuration :: NominalDiffTime -> Int
secDuration dt =
  let lag = nominalDiffTimeToSeconds dt
  in fromEnum lag `div` trillion

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


data TaskOutput = TaskOut {_outArch :: String,
                           moutSize :: Maybe Int,
                           moutSizeStep :: Maybe Int,
                           outSizeChanged :: Bool,
                           _moutTime :: Maybe UTCTime,
                           _moutTimeStep :: Maybe Int,
                           outTimeChanged :: Bool,
                           _outState :: Text,
                           outStateChanged :: Bool,
                           _method :: Text,
                           _mduration :: Maybe NominalDiffTime}

printLogStatuses :: IO () -> TimeZone -> [TaskInfoStatuses] -> IO ()
printLogStatuses header tz tss =
  let (mxsi, mxsp, taskoutputs) = (formatSize . map taskOutput) tss
  in
    unless (null taskoutputs) $
    when (any (\t -> outTimeChanged t || outSizeChanged t || outStateChanged t) taskoutputs) $ do
    header
    mapM_ (printTaskOut mxsi mxsp) taskoutputs
    putChar '\n'
  where
    printTaskOut :: Int64 -> Int64 -> TaskOutput -> IO ()
    printTaskOut maxsize maxspd (TaskOut arch msize msizediff _sizechanged mtime mtimediff _timechanged state _statechanged mthd mduration) =
      fprintLn (rpadded 8 ' ' string %
                lpadded (max 6 (maxsize+2)) ' ' (optioned commas) % "kB" %
                " " %
                optioned (parenthesised string % " ") %
                optioned ("[" % lpadded maxspd ' ' commas % " B/s] ") %
                optioned (parenthesised (shown % "s") % " ") %
                lpadded 8 ' ' (optioned (string % " ")) %
                stext % " " %
                stext)
      arch
      ((`div` 1000) <$> msize)
      (show . localTimeOfDay . utcToLocalTime tz <$> mtime)
      (if msizediff == Just 0
       then Just 0
       else liftM2 div msizediff mtimediff)
      mtimediff
      (renderDuration True <$> mduration)
      state
      (abridgeMethod mthd)

    formatSize :: [TaskOutput] -> (Int64, Int64,[TaskOutput])
    formatSize ts =
      let maxsi = maximum $ 0 : mapMaybe (fmap (`div` 1000) . moutSize) ts
          maxsp = maximum $ 0 : mapMaybe moutSizeStep ts
      in (decimalLength maxsi, decimalLength maxsp, ts)
      where
        decimalLength = fromIntegral . length . show

    abridgeMethod :: Text -> Text
    abridgeMethod mth =
      case mth of
        "buildArch" -> ""
        "buildSRPMFromSCM" -> "SRPM"
        _ -> mth

    taskOutput :: TaskInfoStatuses -> TaskOutput
    taskOutput (task, (size,time), moldstatus) =
      let moldlog = moldstatus >>= tstLog
          oldtime = logTime <$> moldlog
          oldsize = logSize <$> moldlog
          oldstate = tstState <$> moldstatus
          mstate = getTaskState task
      in
        let method = maybeVal "method not found" (lookupStruct "method") task :: Text
            arch = maybeVal "arch not found" lookupArch task
            sizediff = liftM2 (-) size oldsize
            timediff = if time == oldtime
                       then Nothing
                       else secDuration <$> liftM2 diffUTCTime time oldtime
            state' =
              case mstate of
                Nothing -> error' "No state found"
                Just s ->
                  if s == TaskOpen
                  then ""
                  else T.pack $ show s
        in TaskOut arch size sizediff (size /= oldsize) time timediff (time /= oldtime) state' (mstate /= oldstate) method (durationOfTask task)

kojiListBuildTasks :: Maybe String -> IO [TaskID]
kojiListBuildTasks muser = do
  user <- case muser of
            Just user -> return user
            Nothing -> do
              -- FIXME test for klist
              mfasid <- (removeSuffix "@FEDORAPROJECT.ORG" <$>) . find ("@FEDORAPROJECT.ORG" `isSuffixOf`) . words <$> cmd "klist" ["-l"]
              case mfasid of
                Just fas -> return fas
                Nothing -> error' "Could not determine FAS id from klist"
  mowner <- kojiGetUserID fedoraKojiHub user
  case mowner of
    Nothing -> error "No owner found"
    Just owner ->
      kojiListTaskIDs fedoraKojiHub
      [("method", ValueString "build"), ("owner", ValueInt (getID owner)), ("state", openTaskValues)]
      [("limit", ValueInt 10)]

maybeVal :: Show a => String -> (a -> Maybe b) -> a -> b
maybeVal err f v = fromMaybe (error (err ++ ": " ++ show v)) $ f v