packages feed

oglyad-0.1.2.0: src/Oglyad/Source/Monorepo.hs

-- | reads a local checkout of terrapkg/packages
module Oglyad.Source.Monorepo
  ( PkgDir (..),
    SpecInfo (..),
    findPackageDirs,
    hclString,
    versionMacros,
    querySpec,
    lastCommits,
    headCommit,
    checkoutRef,
    pruneTrees,
    specFingerprint,
    macroFingerprint,
  )
where

import Control.Exception (IOException, SomeException, finally, try)
import Control.Monad (filterM, foldM, forM, forM_)
import Crypto.Hash.SHA256 qualified as SHA256
import Data.Aeson (FromJSON, ToJSON)
import Data.ByteString qualified as BS
import Data.ByteString.Base16 qualified as B16
import Data.Char (isAlphaNum, isSpace)
import Data.List (sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, listToMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Read qualified as TR
import Data.Time (UTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import GHC.Generics (Generic)
import Oglyad.Process (run)
import Oglyad.Source.Cache (cacheDir)
import Oglyad.Version (EVR, parseEVR)
import System.Directory (createDirectory, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getModificationTime, getSymbolicLinkTarget, listDirectory, pathIsSymbolicLink, removeFile, removePathForcibly, renameDirectory, setModificationTime)
import System.FilePath (takeDirectory, takeExtension, (</>))
import System.IO (hClose, openBinaryTempFile)

-- | a directory with an anda.hcl in it
data PkgDir = PkgDir
  { -- | relative to the monorepo root
    pdPath :: FilePath,
    -- | relative to the monorepo root
    pdSpec :: Maybe FilePath,
    -- | `subrepo = "extras"` from the labels block
    pdSubrepo :: Maybe Text,
    -- | file contents
    pdUpdateRhai :: Maybe Text,
    -- | macros the spec's Version: line uses, like ["tag"] for
    -- `Version: %(echo %tag | sed ...)`. Nothing when there's no spec or no
    -- Version: line to read
    pdVersionMacros :: Maybe [Text],
    -- | `oglyad_ignore = "check, other-check"` from the labels block
    pdIgnores :: [Text],
    -- | has a `nightly = N` or `quaterdaily = N` label, so it builds from a
    -- branch head on a schedule rather than from releases
    pdNightly :: Bool
  }
  deriving stock (Eq, Show)

data SpecInfo = SpecInfo
  { siName :: Text,
    siEVR :: EVR
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON)

-- | walk anda/ and return every project directory. a directory that can't be
-- read comes back as a Left with the reason, instead of ending the walk.
-- symlinked directories aren't followed, so a link loop can't hang the walk
findPackageDirs :: FilePath -> IO [Either (FilePath, Text) PkgDir]
findPackageDirs root = go "anda"
  where
    go rel = do
      let abs' = root </> rel
      listed <- try (listDirectory abs')
      case listed of
        Left (e :: IOException) -> pure [Left (rel, T.pack (show e))]
        Right entries -> do
          hasHcl <- doesFileExist (abs' </> "anda.hcl")
          here <- if hasHcl then pure <$> readPkgDir rel entries else pure []
          subdirs <- filterM (realDir . (abs' </>)) [e | e <- sort entries, take 1 e /= "."]
          nested <- concat <$> forM subdirs (go . (rel </>))
          pure (here <> nested)

    readPkgDir rel entries = do
      r <- try (readPkgDir' rel entries)
      pure (either (\(e :: IOException) -> Left (rel, T.pack (show e))) Right r)

    readPkgDir' rel entries = do
      let abs' = root </> rel
      hcl <- readText (abs' </> "anda.hcl")
      let fromHcl = T.unpack <$> hclString "spec" hcl
          fallback = listToMaybe [e | e <- entries, takeExtension e == ".spec"]
      hasRhai <- doesFileExist (abs' </> "update.rhai")
      rhai <- if hasRhai then Just <$> readText (abs' </> "update.rhai") else pure Nothing
      let spec = maybe fallback Just fromHcl
      specText <- case spec of
        Just f -> either (\(_ :: IOException) -> Nothing) Just <$> try (readText (abs' </> f))
        Nothing -> pure Nothing
      pure
        PkgDir
          { pdPath = rel,
            pdSpec = (rel </>) <$> spec,
            pdSubrepo = hclString "subrepo" hcl,
            pdUpdateRhai = rhai,
            pdVersionMacros = specText >>= versionMacros,
            pdIgnores = maybe [] (filter (not . T.null) . map T.strip . T.splitOn ",") (hclString "oglyad_ignore" hcl),
            pdNightly = any (isJust . (`hclString` hcl)) ["nightly", "quaterdaily"]
          }

-- | the macro names a spec's Version: line refers to, read from the raw
-- text: %name, %{name}, %{?name} and %{!?name:...} all count. rpmspec
-- would expand them away, and what we want is which ones an update.rhai
-- would have to set to change the version
versionMacros :: Text -> Maybe [Text]
versionMacros spec = case [T.strip v | l <- T.lines spec, Just v <- [stripTag l]] of
  v : _ -> Just (names v)
  [] -> Nothing
  where
    stripTag l = case T.breakOn ":" l of
      (tag, rest) | T.toLower (T.strip tag) == "version", not (T.null rest) -> Just (T.drop 1 rest)
      _ -> Nothing
    names t = case T.breakOn "%" t of
      (_, "") -> []
      (_, rest) ->
        let after = T.dropWhile (`elem` ("{?!" :: String)) (T.drop 1 rest)
            (name, more) = T.span (\c -> isAlphaNum c || c == '_') after
         in [name | not (T.null name)] <> names more

-- | the value of `key = "value"` (or a bare `key = 1`) anywhere in an
-- anda.hcl. good enough for the flat labels and rpm blocks anda uses
hclString :: Text -> Text -> Maybe Text
hclString key hcl =
  listToMaybe
    [ v
    | l <- map T.strip (T.lines hcl),
      Just rest <- [T.stripPrefix key l],
      Just afterEq <- [T.stripPrefix "=" (T.stripStart rest)],
      let raw = T.stripStart afterEq
          v = case T.stripPrefix "\"" raw of
            Just quoted -> T.takeWhile (/= '"') quoted
            Nothing -> T.takeWhile (\c -> not (isSpace c) && c /= '#') raw,
      not (T.null v)
    ]

-- | a directory that isn't a symlink
realDir :: FilePath -> IO Bool
realDir path = do
  isDir <- doesDirectoryExist path
  isLink <- either (\(_ :: IOException) -> False) id <$> try (pathIsSymbolicLink path)
  pure (isDir && not isLink)

-- | read a text file as utf-8 no matter the locale, replacing invalid bytes
-- instead of failing
readText :: FilePath -> IO Text
readText path = TE.decodeUtf8Lenient <$> BS.readFile path

-- | let rpm evaluate the spec for us. hand-parsing specs means reimplementing
-- the macro engine, which is a terrible idea
querySpec :: Int -> Text -> FilePath -> IO (Either Text SpecInfo)
querySpec release dist spec = do
  let qf = "%{NAME}\t%|EPOCH?{%{EPOCH}}:{0}|:%{VERSION}-%{RELEASE}\n"
  out <-
    run "rpmspec" $
      [ "-q",
        "--srpm",
        "--define",
        "dist " <> T.unpack dist,
        -- %fedora comes from the host otherwise, so -r 43 on an F44 box would
        -- take the wrong side of every %if 0%{?fedora} >= 44
        "--define",
        "fedora " <> show release,
        -- some specs load macro files from their own dir via %{_sourcedir}
        "--define",
        "_sourcedir " <> takeDirectory spec,
        "--qf",
        qf,
        spec
      ]
  pure $ do
    o <- out
    case T.splitOn "\t" <$> take 1 (T.lines o) of
      [[name, evr]] | Just e <- parseEVR evr -> Right (SpecInfo name e)
      _ -> Left ("unexpected rpmspec output: " <> o)

-- | time of the last commit touching each package dir, from a single
-- `git log` over the whole history.
--
-- asking git once per directory (`git log -1 -- dir`) makes it walk and diff
-- history from the top every time, which for 1300 dirs and 20k commits burned
-- ~25s of cpu. one pass listing every changed file per commit takes ~0.5s,
-- and since git lists newest commits first, the first time we see a path
-- under a dir is that dir's last commit
lastCommits :: FilePath -> Text -> [FilePath] -> IO (Map FilePath UTCTime)
lastCommits root ref dirs = do
  out <-
    run "git" $
      ["-C", root, "-c", "core.quotePath=false", "log", "--format=@%ct", "--name-only", "--no-renames", T.unpack ref, "--", "anda"]
  -- not a git checkout (or no git at all) just means no commit times
  pure (either (const Map.empty) (go Nothing Map.empty . T.lines) out)
  where
    wanted = Set.fromList (map T.pack dirs)
    total = Set.size wanted
    go _ acc _ | Map.size acc == total = acc -- every dir seen, stop reading
    go _ acc [] = acc
    go cur acc (l : ls) = case T.stripPrefix "@" l of
      -- unix seconds. iso dates would need care since git writes UTC as "Z",
      -- which the ZonedTime parser rejects
      Just stamp -> go (posixSecondsToUTCTime . fromInteger . fst <$> either (const Nothing) Just (TR.decimal stamp)) acc ls
      Nothing -> case cur of
        Just t | not (T.null l) -> go cur (foldl' (claim t) acc (ancestors l)) ls
        _ -> go cur acc ls
    -- a file counts for every package dir above it, like `git log -- dir` does
    claim t acc d
      | d `Set.member` wanted, not (Map.member (T.unpack d) acc) = Map.insert (T.unpack d) t acc
      | otherwise = acc
    ancestors f = case T.breakOnEnd "/" f of
      ("", _) -> []
      (parentSlash, _) -> let parent = T.dropEnd 1 parentSlash in parent : ancestors parent

-- | the commit HEAD points at, if this is a git checkout
headCommit :: FilePath -> IO (Maybe Text)
headCommit root = either (const Nothing) (Just . T.strip) <$> run "git" ["-C", root, "rev-parse", "HEAD"]

-- | the whole tree of a git ref, extracted under the cache dir and keyed by
-- commit, so each commit gets extracted once. the checkout itself is never
-- touched: no checkout, no worktree, no fetch. returns the directory and the
-- commit
checkoutRef :: FilePath -> Text -> IO (Either Text (FilePath, Text))
checkoutRef mono ref = do
  rev <- run "git" ["-C", mono, "rev-parse", "--verify", "--quiet", T.unpack ref <> "^{commit}"]
  case T.strip <$> rev of
    Left _ -> pure (Left ("no " <> ref <> " in " <> T.pack mono <> ", does it need a git fetch?"))
    Right sha -> do
      trees <- (</> "trees") <$> cacheDir
      let dir = trees </> T.unpack sha
      done <- doesDirectoryExist dir
      if done
        then do
          -- mark it used, so pruneTrees knows it's still wanted
          now <- getCurrentTime
          _ <- try (setModificationTime dir now) :: IO (Either IOException ())
          pure (Right (dir, sha))
        else do
          createDirectoryIfMissing True trees
          -- extract next to the final spot and rename, so a run that dies
          -- halfway never leaves a partial tree that looks finished
          (tar, h) <- openBinaryTempFile trees (T.unpack sha <> ".tar")
          hClose h
          let tmp = tar <> ".d"
          r <-
            ( do
                archived <- run "git" ["-C", mono, "archive", "--format=tar", "-o", tar, T.unpack sha]
                extracted <- case archived of
                  Left e -> pure (Left e)
                  Right _ -> createDirectory tmp >> run "tar" ["-xf", tar, "-C", tmp]
                case extracted of
                  Left e -> pure (Left ("can't extract " <> ref <> ": " <> e))
                  Right _ -> do
                    moved <- try (renameDirectory tmp dir)
                    -- another run may have finished the same commit first
                    case moved of
                      Left (_ :: IOException) -> removePathForcibly tmp
                      Right () -> pure ()
                    pure (Right (dir, sha))
            )
              `finally` (try' (removeFile tar) >> try' (removePathForcibly tmp))
          pure r
  where
    try' :: IO () -> IO ()
    try' act = () <$ (try act :: IO (Either SomeException ()))

-- | delete extracted trees nothing has used in a week, other than these.
-- a week rather than right away, so checking one release doesn't throw out
-- the trees the next full run needs
pruneTrees :: [Text] -> IO ()
pruneTrees keep = do
  trees <- (</> "trees") <$> cacheDir
  now <- getCurrentTime
  listed <- try (listDirectory trees)
  case listed of
    Left (_ :: IOException) -> pure ()
    Right names -> forM_ names $ \n -> do
      let path = trees </> n
      used <- try (getModificationTime path) :: IO (Either IOException UTCTime)
      case used of
        Right t
          | length n == 40,
            T.pack n `notElem` keep,
            diffUTCTime now t > 7 * nominalDay ->
              () <$ (try (removePathForcibly path) :: IO (Either IOException ()))
        _ -> pure ()

-- | a hash of everything that can change what rpmspec says about a package:
-- the files in its directory and any subdirectories like patches/ (but not
-- nested packages, which have their own fingerprint), the dist tag, and the
-- rpm macros in effect (see 'macroFingerprint')
specFingerprint :: Text -> Text -> FilePath -> FilePath -> IO Text
specFingerprint macros dist root rel = do
  files <- packageFiles (root </> rel)
  let header = TE.encodeUtf8 (macros <> "\0" <> dist <> "\0" <> T.pack rel <> "\0")
  -- feed each file to the hash as its own chunk instead of concatenating
  -- them, which would copy every byte of the package dir once more
  ctx <-
    foldM
      ( \c (f, entry) -> do
          body <- case entry of
            -- hash where a symlink points, never what's behind it
            Link target -> pure ("<link> " <> TE.encodeUtf8 (T.pack target))
            File ->
              either (\(e :: IOException) -> "<unreadable> " <> TE.encodeUtf8 (T.pack (show e))) id
                <$> try (BS.readFile (root </> rel </> f))
          pure $! SHA256.updates c [TE.encodeUtf8 (T.pack f), "\0", body, "\0"]
      )
      (SHA256.update SHA256.init header)
      files
  pure (TE.decodeUtf8 (B16.encode (SHA256.finalize ctx)))

data Entry = File | Link FilePath

-- | every file under a package dir, relative to it and sorted, skipping
-- subdirectories that are packages of their own. symlinks are recorded with
-- their target and never followed, so a dangling link or a link loop can't
-- crash or hang the walk
packageFiles :: FilePath -> IO [(FilePath, Entry)]
packageFiles dir = go ""
  where
    go sub = do
      listed <- try (listDirectory (dir </> sub))
      let entries = either (\(_ :: IOException) -> []) sort listed
      fmap concat . forM entries $ \e -> do
        let rel = if null sub then e else sub </> e
        link <- try (getSymbolicLinkTarget (dir </> rel))
        case link of
          Right target -> pure [(rel, Link target)]
          Left (_ :: IOException) -> do
            isDir <- doesDirectoryExist (dir </> rel)
            isPkg <- doesFileExist (dir </> rel </> "anda.hcl")
            case (isDir, isPkg) of
              (True, True) -> pure []
              (True, False) -> go rel
              _ -> pure [(rel, File)]

-- | fingerprint of the whole rpm macro state, so updating rpm,
-- redhat-rpm-config or anda-srpm-macros, or editing ~/.rpmmacros, invalidates
-- cached evaluations. `rpm --showrc` dumps every macro rpm loaded, from every
-- path in its search list, which is more complete than listing files by hand
macroFingerprint :: IO Text
macroFingerprint = do
  showrc <- either ("no rpm: " <>) id <$> run "rpm" ["--showrc"]
  version <- either ("no rpmspec: " <>) id <$> run "rpmspec" ["--version"]
  pure (TE.decodeUtf8 (B16.encode (SHA256.hash (TE.encodeUtf8 (version <> "\0" <> showrc)))))