packages feed

fbrnch-1.8.1: src/RpmBuild.hs

module RpmBuild (
  builtRpms,
  buildRPMs,
  buildRPMsNoBranch,
  needToBuildRPMS,
  installDeps,
  buildRequires,
  getSources,
  getSourcesMacros,
  getDynSourcesMacros,
  generateSrpm,
  generateSrpm',
  generateSrpmNoDist,
  BCond(..),
  ForceShort(..),
  isShortCircuit,
  checkSourcesMatch,
  notInstalled,
  nvraInstalled,
  rpmEval,
  distRpmOptions
  )
where

import Control.Exception (uninterruptibleMask_)
import qualified Data.ByteString.Lazy.Char8 as B
import Data.Char (isDigit)
import Data.Either (partitionEithers)
import Data.RPM
import Distribution.Fedora.Branch (branchDistTag, branchRelease)
import Distribution.Fedora.Release (releaseVersion)
import Network.HTTP.Directory (httpExists')
import Safe (lastMay, headMay)
import SimpleCmd.Rpm
import SimplePrompt (promptEnter, yesNo)
import System.Console.Pretty
import System.IO.Extra (withTempDir)
import System.Posix.Files
import System.Process.Typed (proc, readProcessInterleaved)

import Branches
import Cmd.Update (updateSourcesPkg)
import Common
import Common.System
import Git
import Package

distOpt :: Branch -> IO [String]
distOpt br = do
  disttag <- branchDistTag br
  return ["--define", "dist" +-+ "%{?distprefix}" ++ disttag]

distOptAny :: AnyBranch -> IO [String]
distOptAny b =
  anyBranchToRelease b >>= distOpt

-- FIXME hardcoding
distRpmOptions :: Branch -> IO [String]
distRpmOptions br =
  map ("--define=" ++) <$> do
  case br of
    Rawhide -> do
      ver <- releaseVersion <$> branchRelease br
      return ["fedora" +-+ ver, "fc" ++ ver +-+ "1"]
    Fedora n ->
      return ["fedora" +-+ show n, "fc" ++ show n +-+ "1"]
    EPEL n ->
      return ["rhel" +-+ show n, "el" ++ show n +-+ "1"]
    EPELNext n ->
      return ["rhel" +-+ show n, "el" ++ show n +-+ "1"]
    EPELMinor n _ ->
      return ["rhel" +-+ show n, "el" ++ show n +-+ "1"]

-- branchToRelease :: AnyBranch -> IO Release
-- branchToRelease (RelBranch br) = branchRelease br
-- branchToRelease (OtherBranch b) = error' $ "no release for branch" +-+ b

builtRpms :: AnyBranch -> FilePath -> IO [FilePath]
builtRpms br spec = do
  distopt <- distOptAny br
  rpmdir <- fromMaybe "" <$> rpmEval "%{_rpmdir}"
  autoreleaseOpt <- getAutoReleaseOptions spec
  rpms <- rpmspec ("--builtrpms" : distopt ++ autoreleaseOpt) (Just (rpmdir </>  "%{arch}/%{name}-%{version}-%{release}.%{arch}.rpm")) spec
  if null rpms
    then error' $ spec +-+ "does not seem to create any rpms"
    else return rpms

rpmEval :: String -> IO (Maybe String)
rpmEval s = do
  res <- cmd "rpm" ["--eval", s]
  return $ if null res || res == s then Nothing else Just res

-- rpmEval' :: String -> IO String
-- rpmEval' s = do
--   mres <- rpmEval s
--   fromMaybe (error' (show s +-+ "undefined!")) mres

getSources :: FilePath -> IO [FilePath]
getSources spec = do
  -- FIXME fallback to ~/rpmbuild/SOURCES?
  msrcdir <- do
    cwd <- getCurrentDirectory
    msourcedir <- rpmEval "%{_sourcedir}"
    case msourcedir of
      Nothing -> return Nothing
      Just srcdir -> do
        canon <- canonicalizePath srcdir
        if canon == cwd
          then return Nothing
          else do
          dir <- doesDirectoryExist srcdir
          if dir
            then return msourcedir
            else return Nothing
  isPkgGit <- isPkgGitRepo
  (patches,srcs) <- partitionEithers . map sourcePatchFile
                    <$> cmdLines "spectool" ["-a", spec]
  forM_ srcs $ \ src -> do
    exists <- checkCompression src
    inSrcdir <- doesSourceDirFileExist msrcdir src
    unless exists $ do
      if inSrcdir
        then maybeSourceDir createLink msrcdir src
        else do
        uploaded <-
          if isPkgGit then do
            have_sources <- doesFileExist "sources"
            if have_sources
              then grep_ src "sources"
              else return False
          else return False
        mfedpkg <- findExecutable "fedpkg"
        if uploaded && isJust mfedpkg
          then cmd_ "fedpkg" ["sources"]
          else do
          cmd_ "spectool" ["-g", "-S", spec]
          unlessM (doesFileExist src) $
            error' $ "download failed:" +-+ src
    unless inSrcdir $
      whenJust msrcdir $ \srcdir ->
      createLink src (srcdir </> src)
  forM_ patches $ \patch ->
    unlessM (doesFileExist patch) $ do
    inSrcdir <- doesSourceDirFileExist msrcdir patch
    if inSrcdir
      then maybeSourceDir copyFile msrcdir patch
      else do
      cmd_ "spectool" ["-g", "-P", spec]
      unlessM (doesFileExist patch) $
        error' $ "missing patch:" +-+ patch
  return $ srcs ++ patches
  where
    sourcePatchFile :: String -> Either FilePath FilePath
    sourcePatchFile field =
      case word1 field of
        (f,v) ->
          -- rpmdevtools 9.3 (spectool always lists --all)
          -- "Source0:" or "Patch1:"
          (case lower (dropWhileEnd isDigit (init f)) of
             "source" -> Right
             "patch" -> Left
             _ -> error' $! "illegal field:" +-+ f)
          -- either url or local file (cannot really have subdir/file)
          $ takeFileName v

    checkCompression :: FilePath -> IO Bool
    checkCompression file =
      case
        case takeExtension file of
          ".gz" -> Just "gzip"
          ".tgz" -> Just "gzip"
          ".bz2" -> Just "bzip2"
          ".xz" -> Just "xz"
          ".lz" -> Just "lzip"
          ".zstd" -> Just "zstd"
          _ -> Nothing
      of
        Just prog -> do
          have <- findExecutable prog
          when (isNothing have) $ do
            putStrLn $ "Running 'dnf install'" +-+ prog
            cmd_ "/usr/bin/sudo" $ "/usr/bin/dnf":"install": ["--assumeyes", prog]
          exists <- doesFileExist file
          if exists
            then cmdBool prog ["-t", file]
            else return False
        Nothing -> doesFileExist file

    maybeSourceDir :: (FilePath -> FilePath -> IO ())
                   -> Maybe FilePath -> FilePath -> IO ()
    maybeSourceDir act mdir file =
      whenJust mdir $ \dir ->
      act (dir </> file) file

    doesSourceDirFileExist :: Maybe FilePath -> FilePath -> IO Bool
    doesSourceDirFileExist msrcdir file =
      doesFileExist (fromMaybe "" msrcdir </> file)

generateSrpm :: Maybe AnyBranch -> FilePath -> IO FilePath
generateSrpm = generateSrpm' False

generateSrpm' :: Bool -> Maybe AnyBranch -> FilePath -> IO FilePath
generateSrpm' = generateSrpmNoDist False

generateSrpmNoDist :: Bool -> Bool -> Maybe AnyBranch -> FilePath -> IO FilePath
generateSrpmNoDist nodist force mbr spec = do
  srcs <- getSources spec
  distopt <-
    if nodist
    then return ["--define", "dist %{nil}"]
    else
      case mbr of
        Nothing -> return []
        Just br -> do
          rbr <- anyBranchToRelease br
          distOpt rbr
  msrcrpmdir <- rpmEval "%{_srcrpmdir}"
  autoreleaseOpt <- getAutoReleaseOptions spec
  srpmfile <- cmd "rpmspec" $ ["-q", "--srpm"] ++ distopt ++ autoreleaseOpt ++ ["--qf", fromMaybe "" msrcrpmdir </> "%{name}-%{version}-%{release}.src.rpm", spec]
  sourcediropt <- sourceDirCwdOpt
  let srcrpmdiropt = maybe [] (\dir -> ["--define", "_srcrpmdir" +-+ dir])
                     msrcrpmdir
      opts = distopt ++ sourcediropt ++ srcrpmdiropt
  if force then
    buildSrpm srpmfile opts
    else do
    exists <- doesFileExist srpmfile
    if not exists
      then buildSrpm srpmfile opts
      else do
      srpmTime <- getModificationTime srpmfile
      -- FIXME: cp -p means modified patch may look older: maybe should cache/consider file hash
      fileTimes <- mapM getModificationTime (spec:srcs)
      if any (srpmTime <) fileTimes
        then buildSrpm srpmfile opts
        else do
        -- pretty print with ~/
        putStrLn $ srpmfile +-+ "is up to date"
        return srpmfile
  where
    buildSrpm predicted opts = do
      autospec <- isRpmAutospec spec
      outs <-
        -- prevent Ctrl-c from truncating srpm to corrupted file
        fmap words . uninterruptibleMask_ $
        if autospec
        then cmd "fedpkg" $ "srpm" : opts
        else cmd "rpmbuild" (opts ++ ["-bs", spec])
      case filter ("src.rpm" `isExtensionOf`) outs of
        [srpm] -> do
          putStrLn $ "Created" +-+ takeFileName srpm
          unless (predicted == srpm) $
            warning $ "different to predicted NVR:" +-+ predicted
          return srpm
        srpms -> error' $ "could not determined generated srpm filename" +-+ unwords srpms

isRpmAutospec :: FilePath -> IO Bool
isRpmAutospec spec = do
  isAutoChangelog spec ||^ isAutoRelease spec

data BCond = BuildWith String | BuildWithout String

instance Show BCond where
  show (BuildWith s) = "--with=" ++ s
  show (BuildWithout s) = "--without=" ++ s

getAutoReleaseOptions :: FilePath -> IO [String]
getAutoReleaseOptions spec = do
      autorelease <- isAutoRelease spec
      if autorelease
        then do
        calculated <- calculateRelease spec
        return ["--define", "_rpmautospec_release_number" +-+ calculated]
        else return []

data ForceShort = ForceBuild | ShortCompile | ShortInstall
  deriving Eq

isShortCircuit :: Maybe ForceShort -> Bool
isShortCircuit ms =
  case ms of
    Just s -> s /= ForceBuild
    Nothing -> False

needToBuildRPMS :: Bool -> Maybe ForceShort -> [FilePath] -> FilePath -> IO Bool
needToBuildRPMS confirm mforceshort rpms spec =
    if isJust mforceshort
    then return True
    else do
      allexist <- and <$> mapM doesFileExist rpms
      if not allexist
        then return True
        else do
        specTime <- getModificationTime spec
        rpmTimes <- sort <$> mapM getModificationTime rpms
        case headMay rpmTimes of
          Nothing -> error' "rpms have no modification time!"
          Just rpmtime ->
            if specTime > rpmtime
            then
              if confirm
                then yesNo "Rebuild with newer spec file?"
                else return True
            else return False

-- FIXME create build.log
-- Note does not check if bcond changed
-- FIXME check tarball timestamp
-- FIXME handle prep (-bp) too?
buildRPMs :: Bool -> Bool -> Bool -> Maybe Natural -> Maybe ForceShort
          -> [BCond] -> [FilePath] -> AnyBranch -> FilePath -> IO Bool
buildRPMs quiet debug noclean mjobs mforceshort bconds rpms br spec = do
  needBuild <- needToBuildRPMS False mforceshort rpms spec
  if not needBuild then
    putStrLn "Existing rpms are newer than spec file (use --force to rebuild)"
    else do
    installDeps True spec
    void $ getSources spec
    distopt <- distOptAny br
    autoreleaseOpt <- getAutoReleaseOptions spec
    sourcediropt <- sourceDirCwdOpt
    let buildopt =
          case mforceshort of
            Just ShortCompile -> ["-bc", "--short-circuit"]
            Just ShortInstall -> ["-bi", "--short-circuit"]
            _ -> "-bb" : ["--noclean" | noclean]
        jobs =
          case mjobs of
            Nothing -> []
            Just n -> ["--define", "_smp_ncpus_max" +-+ show n]
        args = sourcediropt ++ distopt ++
               buildopt ++ jobs ++ map show bconds ++ autoreleaseOpt ++ [spec]
    date <- cmd "date" ["+%T"]
    rbr <- anyBranchToRelease br
    nvr <- do
      mnvr <- pkgNameVerRelDist False (Just rbr) spec
      case mnvr of
        Just nvr' -> return nvr'
        Nothing -> pkgNameVerRelNodist spec
    putStr $ date +-+ "Building" +-+ showNVR nvr +-+ "locally... "
    ok <- do
      let buildlog = ".build-" ++ (showVerRel . nvrVerRel) nvr <.> "log"
      whenM (doesFileExist buildlog) $ do
        let backup = buildlog <.> "prev"
        whenM (doesFileExist backup) $ do
          prevsize <- getFileSize backup
          currsize <- getFileSize buildlog
          when (prevsize > currsize) $
            cmd_ "cp" ["-p", backup, backup <.> "prev"]
        cmd_ "cp" ["-p", buildlog, buildlog <.> "prev"]
      -- FIXME also backup successful buildtree (if log ends in "+ exit 0")
      timeIO $
        if not quiet || isShortCircuit mforceshort
        then do
          putNewLn
          -- FIXME would like to have pipeOutErr
          let buildcmd = "rpmbuild" : map quoteArg args
          when debug $ putStrLn $ unwords buildcmd
          cmdBool "script" ["-c", unwords buildcmd, buildlog]
        else do
          let buildcmd = unwords $ "rpmbuild" : map quoteArg args ++ [">&", buildlog]
          when debug $ putStrLn buildcmd
          res <- shellBool buildcmd
          if res
            then putStrLn "done"
            else cmd_ "tail" ["-n 100", buildlog]
          return res
    unless ok $
      error' $ showNVR nvr +-+ "failed to build"
  return needBuild

quoteArg :: String -> String
quoteArg cs =
  if ' ' `elem` cs then '\'' : cs ++ "'" else cs

buildRPMsNoBranch :: Bool -> Bool -> Bool -> Maybe Natural -> Maybe ForceShort
                  -> [BCond] -> FilePath -> IO Bool
buildRPMsNoBranch quiet debug noclean mjobs mforceshort bconds spec = do
  installDeps True spec
  void $ getSources spec
  sourcediropt <- sourceDirCwdOpt
  let distopt = ["--define", "dist" +-+ "%{?distprefix}"]
      buildopt =
        case mforceshort of
          Just ShortCompile -> ["-bc", "--short-circuit"]
          Just ShortInstall -> ["-bi", "--short-circuit"]
          _ -> "-bb" : ["--noclean" | noclean]
      jobs =
        case mjobs of
          Nothing -> []
          Just n -> ["--define", "_smp_ncpus_max" +-+ show n]
      args = sourcediropt ++ distopt ++
             buildopt ++ jobs ++ map show bconds ++ [spec]
  date <- cmd "date" ["+%T"]
  nvr <- pkgNameVerRelNodist spec
  let buildlog = ".build-" ++ (showVerRel . nvrVerRel) nvr ++ "-HEAD" <.> "log"
  whenM (doesFileExist buildlog) $ do
    let backup = buildlog <.> "prev"
    whenM (doesFileExist backup) $ do
      prevsize <- getFileSize backup
      currsize <- getFileSize buildlog
      when (prevsize > currsize) $
        copyFile backup (backup <.> "prev")
    copyFile buildlog (buildlog <.> "prev")
  putStr $ date +-+ "Building" +-+ showNVR nvr +-+ "locally... "
  ok <- do
    timeIO $
      if not quiet || isShortCircuit mforceshort
      then do
        putNewLn
        -- FIXME would like to have pipeOutErr
        let buildcmd = "rpmbuild" : map quoteArg args
        when debug $ putStrLn $ unwords buildcmd
        cmdBool "script" ["-c", unwords buildcmd, buildlog]
      else do
        let buildcmd = unwords $ "rpmbuild" : map quoteArg args ++ [">&", buildlog]
        when debug $ putStrLn buildcmd
        res <- shellBool buildcmd
        if res
          then putStrLn "done"
          else error' "failed to build"
        return res
  unless ok $
    error' $ showNVR nvr +-+ "failed to build"
  return True

-- FIXME print unavailable deps
installDeps :: Bool -> FilePath -> IO ()
installDeps strict spec = do
  missingdeps <- nub <$> (buildRequires spec >>= filterM notInstalled)
  unless (null missingdeps) $ do
    putStrLn $ "Running 'dnf install'" +-+ unwords missingdeps
    cmd_ "/usr/bin/sudo" $ "/usr/bin/dnf":"install": ["--skip-broken" | not strict] ++ ["--assumeyes"] ++ missingdeps

installMissingMacros :: FilePath -> IO ()
installMissingMacros spec = do
  macros <- mapMaybeM needSrpmMacro srpmMacros
  missing <- filterM notInstalled macros
  unless (null missing) $
    cmd_ "/usr/bin/sudo" $ ["/usr/bin/dnf", "install", "--assumeyes"] ++ missing
  where
    srpmMacros :: [(String,String)]
    srpmMacros =
      [("%gometa", "go-rpm-macros"),
       ("fontpkgname", "fonts-rpm-macros"),
       ("%cargo_prep", "rust-packaging")]

    needSrpmMacro :: (String,String) -> IO (Maybe String)
    needSrpmMacro (meta, macros) = do
      contents <- readFile spec
      return $ if meta `isInfixOf` contents then Just macros else Nothing

-- from fedora-haskell-tools
buildRequires :: FilePath -> IO [String]
buildRequires spec = do
  autorelease <- isAutoRelease spec
  dynbr <- grep_ "^%generate_buildrequires" spec
  brs <- mapMaybe primary <$>
    if dynbr
    then do
      installMissingMacros spec
      void $ getSources spec
      withTempDir $ \tmpdir -> do
        let rpmbuildArgs = ["-br", "--nodeps", spec] ++
                           ["--define", "_srcrpmdir" +-+ tmpdir]
        -- errors for missing deps
        (_ret, out) <- readProcessInterleaved $ proc "rpmbuild" rpmbuildArgs
        -- Wrote: /current/dir/SRPMS/name-version-release.buildreqs.nosrc.rpm
        case lastMay (B.words out) of
          Nothing -> error' $ spec +-+ "could not generate source rpm for dynamic buildrequires"
          Just srpmbs -> do
            let srpm = B.unpack srpmbs
            unless ("buildreqs.nosrc.rpm" `isSuffixOf` srpm) $ do
              B.putStrLn out
              error' $ "failed to generated buildreqs.nosrc.rpm for" +-+ spec
            exists <- doesFileExist srpm
            if exists
              then cmdLines "rpm" ["-qp", "--requires", srpm]
              else error' $ srpm +-+ "does not exist!"
    else
      -- FIXME should resolve meta
      rpmspec ["--buildrequires"] Nothing spec
  return $ brs ++ ["rpmautospec" | autorelease]
  where
    primary dep =
      case words dep of
        [] -> error' "empty dep" -- corner case
        (('(':h):_) -> Just h
        (d:_) -> if "rpmlib(" `isPrefixOf` d
             then Nothing
             else Just d

checkSourcesMatch :: Package -> AnyBranch -> FilePath -> IO ()
checkSourcesMatch pkg br spec = do
  (sources,missing) <- doCheckSources
  if null missing
    then mapM_ checkLookasideCache sources
    else do
    -- FIXME maybe change to yesNo
    promptEnter $ color Red $ unwords missing +-+ "not in" +-+ unPackage pkg +-+ "'sources', press Enter to fix"
    (sources',missing') <- doCheckSources
    unless (null missing') $
      updateSourcesPkg False False True Nothing pkg br
    git_ "status" ["--short"]
    ok <- yesNo "Amend commit"
    when ok $ git_ "commit" ["--amend"]
    unlessM isGitDirClean $
      error' "local changes remain (dirty)"
    checkOnBranch
    checkSourcesMatch pkg br spec
    mapM_ checkLookasideCache sources'
  where
    doCheckSources = do
      -- "^[Ss]ource[0-9]*:"
      sourcefiles <- map (takeFileName . last . words) <$> cmdLines "spectool" [spec]
      sources <- do
        exists <- doesFileExist "sources"
        if exists
          then lines <$> readFile "sources"
          else return []
      gitfiles <- gitLines "ls-files" ["--no-cached"]
      -- FIXME warn about non-sha512 sources
      let missing = filter (\src -> isNothing (find (\s -> ('(' : src ++ ")") `isInfixOf` s || (' ' : src) `isSuffixOf` s) sources) && src `notElem` gitfiles)
                    sourcefiles
      return (sources,missing)

    checkLookasideCache :: String -> IO ()
    checkLookasideCache source = do
      let lookaside = "https://src.fedoraproject.org/lookaside/pkgs" +/+ unPackage pkg
          (file,url) =
            case words source of
              ("SHA512":('(':fileparen):"=":[hash]) ->
                let file' = dropSuffix ")" fileparen
                in (file', lookaside +/+ file' +/+ "sha512" +/+ hash +/+ file')
              [hash,file'] ->
                (file', lookaside +/+ file' +/+ "md5" +/+ hash +/+ file')
              _ -> error' $ "invalid/unknown source:\n" ++ source
      unlessM (httpExists' url) $ do
        putStrLn $ url +-+ "not found"
        putStrLn $ "uploading" +-+ file +-+ "to lookaside source repo"
        fedpkg_ "upload" [file]

notInstalled :: String -> IO Bool
notInstalled pkg =
  not <$> cmdBool "rpm" ["--quiet", "-q", "--whatprovides", pkg]

getSourcesMacros :: FilePath -> IO ()
getSourcesMacros spec = do
  void $ getSources spec
  installMissingMacros spec

getDynSourcesMacros :: FilePath -> IO ()
getDynSourcesMacros spec =
  whenM (grep_ "^%generate_buildrequires" spec) $
   getSourcesMacros spec

nvraInstalled :: NVRA -> IO Bool
nvraInstalled rpm =
  cmdBool "rpm" ["--quiet", "-q", showNVRA rpm]