oglyad-0.1.2.0: src/Oglyad/Collect.hs
-- | the imperative shell: gather every data source into one 'Snapshot'
module Oglyad.Collect
( buildSnapshot,
)
where
import Control.Concurrent.Async (concurrently)
import Control.Monad (forM, when)
import Control.Monad.Reader (ask, asks, liftIO, runReaderT)
import Data.Either (partitionEithers)
import Data.List (nub, partition)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime, diffUTCTime, getCurrentTime, nominalDay)
import Oglyad.App (App, Env (..), HttpError (..), forConcurrentlyN, note)
import Oglyad.Source.Anda (projectNames, updateProject, withScratchCopy)
import Oglyad.Source.Cache (CacheEntry (..), UpstreamCache, loadJson, lookupFresh, saveJson)
import Oglyad.Source.Monorepo
import Oglyad.Source.Repodata (fetchRepo)
import Oglyad.Source.Rhai (parseUpdateRhaiWith, tokenize)
import Oglyad.Source.RhaiEval (Simulated (..), simulateVersion)
import Oglyad.Source.Upstream (latestVersion)
import Oglyad.Types
import System.Directory (findExecutable, makeAbsolute)
import System.FilePath ((</>))
-- | collect everything. problems along the way become findings instead of
-- crashes, so one broken spec doesn't take down the whole run.
--
-- the monorepo work (rpmspec, git) is all local cpu and the repodata work is
-- all network, so the two halves run at the same time
buildSnapshot :: App (Snapshot, [Finding])
buildSnapshot = do
cfg@Config {..} <- asks envConfig
now <- liftIO getCurrentTime
-- specs come from a release branch extracted into the cache, or straight
-- from the working tree with --worktree
tree <- liftIO $ case cfgSpecRef of
Nothing -> Right . (cfgMonorepo,"HEAD",) <$> headCommit cfgMonorepo
Just ref -> fmap (\(dir, sha) -> (dir, sha, Just sha)) <$> checkoutRef cfgMonorepo ref
let (root, logRef, hd, treeErrs) = case tree of
Right (r, l, h) -> (Just r, l, h, [])
Left err -> (Nothing, "HEAD", Nothing, [collectErr (maybe "monorepo" id cfgSpecRef) err])
(dirErrs, dirs) <- partitionEithers <$> liftIO (maybe (pure []) findPackageDirs root)
-- the main repo, every subrepo some package asked for, and Fedora
let terraRepos =
(Nothing, cfgTerraRepo)
: [(Just s, cfgTerraRepo <> "-" <> s) | s <- nub (mapMaybe pdSubrepo dirs)]
env <- ask
((specErrs, pkgs0), (terra, fedora)) <-
liftIO $
concurrently
(runReaderT (readPackages (maybe cfgMonorepo id root) logRef hd dirs) env)
(runReaderT (fetchRepos terraRepos cfgFedoraRepos) env)
let (repoErrs, repos) = partitionEithers terra
fetched = Map.fromList repos
pkgs1 =
[ p
{ pkgRepoBuild = Map.lookup (pkgSubrepo p) fetched >>= Map.lookup (pkgName p),
pkgRepoChecked = Map.member (pkgSubrepo p) fetched
}
| p <- pkgs0
]
(upErrs, pkgs2) <- if cfgUpstream then withUpstreams now pkgs1 else pure ([], pkgs1)
(andaErrs, pkgs) <- case root of
Just r | cfgUpstream -> withAnda r now pkgs2
_ -> pure ([], pkgs2)
let snap =
Snapshot
{ snapPackages = Map.fromList [(pkgPath p, p) | p <- pkgs],
snapFedora = foldMap Map.keysSet fedora,
snapNow = now,
snapConfig = cfg,
snapHead = hd,
snapIgnoreLabels = Map.fromList [(pdPath d, pdIgnores d) | d <- dirs, not (null (pdIgnores d))]
}
unreadable =
[ (collectErr (T.pack path) ("can't read package dir: " <> err)) {fPath = Just (T.pack path)}
| (path, err) <- dirErrs
]
pure (snap, treeErrs <> unreadable <> specErrs <> repoErrs <> upErrs <> andaErrs)
-- | every repo at once. Terra failures become findings, Fedora ones just mean
-- no overlap data
fetchRepos ::
[(Maybe Text, Text)] ->
[Text] ->
App ([Either Finding (Maybe Text, Map Text RepoBuild)], [Map Text RepoBuild])
fetchRepos terraRepos fedoraRepos = do
let jobs = map Left terraRepos <> map Right fedoraRepos
results <- forConcurrentlyN (length jobs) jobs $ \case
Left (sub, url) -> Left . either (Left . collectErr url . T.pack) (\b -> Right (sub, b)) <$> fetchRepo url
Right url -> Right . either (const Map.empty) id <$> fetchRepo url
pure (partitionEithers results)
-- | evaluate every spec under root, reusing cached evaluations for unchanged
-- packages. logRef and hd say which commit's history to read commit times from
readPackages :: FilePath -> Text -> Maybe Text -> [PkgDir] -> App ([Finding], [Package])
readPackages root0 logRef hd dirs = do
Config {..} <- asks envConfig
root <- liftIO (makeAbsolute root0)
-- one cache file per dist, so cron jobs checking different releases don't
-- keep evicting each other's entries
let cacheFile = "specs" <> T.unpack cfgDist <> ".json"
(commits, (macros, cache)) <-
liftIO $
concurrently
(commitTimes cfgMonorepo logRef hd cfgDist (map pdPath dirs))
((,) <$> macroFingerprint <*> (fromMaybe Map.empty <$> loadJson cacheFile))
keyed <- forConcurrentlyN cfgConcurrency dirs $ \d -> liftIO $ do
key <- specFingerprint macros cfgDist root (pdPath d)
pure (key, d)
let misses = length [() | (k, _) <- keyed, isNothing (Map.lookup k (cache :: Map Text (Either Text SpecInfo)))]
note ("evaluating " <> show (length dirs) <> " specs, " <> show (length dirs - misses) <> " unchanged since last run")
-- without rpmspec, cached evaluations still work. everything else is
-- skipped and reported once, rather than as one error per package
hasRpmspec <- liftIO (isJust <$> findExecutable "rpmspec")
results <- forConcurrentlyN cfgConcurrency keyed $ \(key, d) -> liftIO $ do
case (Map.lookup key cache, pdSpec d) of
(Just cached, _) -> pure (Evaluated key d cached)
(Nothing, Just spec)
| hasRpmspec -> Evaluated key d <$> querySpec cfgRelease cfgDist (root </> spec)
| otherwise -> pure Skipped
(Nothing, Nothing) -> pure (NoSpec d)
-- keep only entries for packages that still exist, so the file can't grow forever
liftIO (saveJson cacheFile (Map.fromList [(k, i) | Evaluated k _ i <- results]))
let skipped = length [() | Skipped <- results]
missing =
[ collectErr "rpmspec" ("rpmspec not found, install rpm-build. skipped " <> T.pack (show skipped) <> " specs with no cached result")
| skipped > 0
]
(errs, pkgs) =
partitionEithers $
[toPackage commits k d (Just i) | Evaluated k d i <- results]
<> [toPackage commits "" d Nothing | NoSpec d <- results]
pure (missing <> errs, pkgs)
-- | what happened to one package dir's spec
data SpecResult
= Evaluated Text PkgDir (Either Text SpecInfo)
| NoSpec PkgDir
| Skipped
-- | last commit per package dir, reused as long as the commit hasn't moved.
-- uncommitted changes can't change any "last commit", so the commit is the
-- whole key. one cache file per dist, since each release reads its own branch
commitTimes :: FilePath -> Text -> Maybe Text -> Text -> [FilePath] -> IO (Map FilePath UTCTime)
commitTimes mono ref hd dist dirs = do
let file = "commits" <> T.unpack dist <> ".json"
cached <- loadJson file
case (hd, cached) of
(Just h, Just (h', m)) | h == h', all (`Map.member` m) dirs -> pure m
_ -> do
m <- lastCommits mono ref dirs
mapM_ (\h -> saveJson file (h, m)) hd
pure m
toPackage :: Map FilePath UTCTime -> Text -> PkgDir -> Maybe (Either Text SpecInfo) -> Either Finding Package
toPackage commits key PkgDir {..} = \case
Nothing -> Left (pathErr "no spec file found")
Just (Left err) -> Left (pathErr ("rpmspec failed: " <> firstLine err))
Just (Right SpecInfo {..}) ->
Right
Package
{ pkgName = siName,
pkgPath = pdPath,
pkgSubrepo = pdSubrepo,
pkgNightly = pdNightly,
pkgSpecPath = pdSpec,
pkgFingerprint = key,
pkgSpecEVR = Just siEVR,
pkgRepoBuild = Nothing,
pkgLastCommit = Map.lookup pdPath commits,
-- an update.rhai with nothing but comments in it updates nothing
pkgUpstream = parseUpdateRhaiWith pdVersionMacros <$> (pdUpdateRhai >>= nonEmpty),
pkgRhai = pdUpdateRhai,
pkgUpstreamVer = Nothing,
pkgUpstreamErr = Nothing,
pkgRepoChecked = False
}
where
firstLine t = let l = T.takeWhile (/= '\n') t in if T.null l then "(no output)" else l
nonEmpty script = case tokenize script of
Just [] -> Nothing
_ -> Just script
pathErr msg = (collectErr (T.pack pdPath) msg) {fPath = Just (T.pack pdPath)}
-- | fill in upstream versions, from the cache when it's fresh enough.
--
-- crates.io allows one request a second, so its lookups run in their own
-- serial queue next to everything else. inside the shared job pool, a cluster
-- of crates packages would hold every slot while waiting their turn
withUpstreams :: UTCTime -> [Package] -> App ([Finding], [Package])
withUpstreams now pkgs = do
Config {..} <- asks envConfig
when (isNothing cfgGitHubToken) $
note "no GITHUB_TOKEN (or `gh auth token`), GitHub lookups will hit the 60/hour limit fast"
cache <- liftIO (fromMaybe Map.empty <$> loadJson "upstream.json") :: App UpstreamCache
let wanted = [p | p <- pkgs, Just src <- [pkgUpstream p], lookable src]
-- anything fetched since this run started is fresh, so checking several
-- releases asks each upstream once even with --cache-hours 0
maxAge = max cfgCacheFor (diffUTCTime now cfgRunStart)
fresh p = pkgUpstream p >>= \src -> lookupFresh maxAge now src cache
(crates, others) = partition (isCrates . pkgUpstream) wanted
lookup1 p = case (fresh p, pkgUpstream p) of
(Just e, _) -> pure (pkgPath p, either (Left . HttpFailed) Right (ceResult e))
(Nothing, Just src) -> (pkgPath p,) <$> latestVersion src
(Nothing, Nothing) -> pure (pkgPath p, Left (HttpFailed "no source"))
note ("querying upstreams for " <> show (length wanted) <> " packages, " <> show (length (filter (isJust . fresh) wanted)) <> " cached")
env <- ask
(r1, r2) <-
liftIO $
concurrently
(runReaderT (forConcurrentlyN cfgConcurrency others lookup1) env)
(runReaderT (forM crates lookup1) env)
-- keyed by path, not name: two dirs can share a package name
let byPath = Map.fromList (r1 <> r2)
-- only cache successes. errors are often transient (rate limits, timeouts)
new =
Map.fromList
[ (T.pack (show src), CacheEntry now (Right v))
| p <- wanted,
isNothing (fresh p),
Just src <- [pkgUpstream p],
Just (Right v) <- [Map.lookup (pkgPath p) byPath]
]
-- forget answers nobody has asked for in a month
recent = Map.filter (\e -> diffUTCTime now (ceFetched e) < 30 * nominalDay) cache
limited = [why | Left (RateLimited why) <- Map.elems byPath]
rateErr = case limited of
[] -> []
why : _ ->
[ (collectErr "upstream" (why <> ", skipped " <> T.pack (show (length limited)) <> " lookups. set GITHUB_TOKEN, or try again later"))
{ fSeverity = Warn
}
]
liftIO (saveJson "upstream.json" (new <> recent))
pure
( rateErr,
[ case Map.lookup (pkgPath p) byPath of
Just (Right v) -> p {pkgUpstreamVer = replay p v}
Just (Left (HttpFailed e)) -> p {pkgUpstreamErr = Just e}
-- rate limits are reported once above, not as a broken upstream
Just (Left (RateLimited _)) -> p
Nothing -> p
| p <- pkgs
]
)
where
-- run the script's guards and edits over the raw value, so a tag the
-- script would skip (or crop) doesn't show up as drift
replay p v = case (pkgUpstream p, pkgRhai p) of
(Just src, Just script) -> case simulateVersion src v script of
Sets v' -> Just v'
NoUpdate -> Nothing
CantTell -> Just v
_ -> Just v
lookable = \case
CommitTracked -> False
NoVersion -> False
Derived _ -> False
Unknown _ -> False
_ -> True
isCrates = \case
Just (Crates _ _) -> True
_ -> False
-- | run the real anda for scripts oglyad couldn't follow, on a throwaway
-- copy of the tree, and read the Version it leaves in each spec. results are
-- cached like upstream answers, keyed by the package's fingerprint
withAnda :: FilePath -> UTCTime -> [Package] -> App ([Finding], [Package])
withAnda root now pkgs = do
Config {..} <- asks envConfig
let wanted = [p | p@Package {pkgUpstream = Just (Unknown _), pkgSpecPath = Just _} <- pkgs]
cacheFile = "anda" <> T.unpack cfgDist <> ".json"
maxAge = max cfgCacheFor (diffUTCTime now cfgRunStart)
cache <- liftIO (fromMaybe Map.empty <$> loadJson cacheFile) :: App (Map Text CacheEntry)
let fresh p = case Map.lookup (pkgFingerprint p) cache of
Just e | diffUTCTime now (ceFetched e) <= maxAge -> Just e
_ -> Nothing
misses = [p | p <- wanted, isNothing (fresh p)]
hasAnda <- liftIO (isJust <$> findExecutable "anda")
(problems, ran) <-
if
| null misses -> pure ([], Map.empty)
| not hasAnda -> do
note ("anda not found, so " <> show (length misses) <> " update.rhai scripts oglyad can't follow are skipped")
pure ([], Map.empty)
| otherwise -> do
note ("running anda update for " <> show (length wanted) <> " scripts oglyad can't follow, " <> show (length wanted - length misses) <> " cached")
env <- ask
r <- liftIO $ withScratchCopy root $ \copy ->
projectNames copy >>= \case
Left e -> pure (Left e)
Right names -> fmap Right . flip runReaderT env . forConcurrentlyN cfgConcurrency misses $ \p ->
liftIO $
(pkgPath p,) <$> case (Map.lookup (pkgPath p) names, pkgSpecPath p) of
(Just name, Just spec) ->
updateProject copy cfgGitHubToken cfgBranch name >>= \case
Left e -> pure (Left e)
Right () ->
either (Left . ("rpmspec failed on the updated spec: " <>)) (Right . evrVersion . siEVR)
<$> querySpec cfgRelease cfgDist (copy </> spec)
_ -> pure (Left "anda doesn't list this project")
pure $ case join' r of
Left e -> ([(collectErr "anda" e) {fSeverity = Warn}], Map.empty)
Right xs -> ([], Map.fromList xs)
let answer p = maybe (Map.lookup (pkgPath p) ran) (Just . ceResult) (fresh p)
new =
Map.fromList
[ (pkgFingerprint p, CacheEntry now (Right v))
| p <- misses,
Just (Right v) <- [Map.lookup (pkgPath p) ran]
]
recent = Map.filter (\e -> diffUTCTime now (ceFetched e) < 30 * nominalDay) cache
liftIO (saveJson cacheFile (new <> recent))
pure
( problems,
[ case answer p of
Just (Right v) -> p {pkgUpstreamVer = Just v}
Just (Left e) -> p {pkgUpstreamErr = Just ("update.rhai failed under anda: " <> e)}
Nothing -> p
| p <- pkgs
]
)
where
join' = either Left id
collectErr :: Text -> Text -> Finding
collectErr what msg = Finding {fCheck = "collect", fPackage = what, fPath = Nothing, fSeverity = Error, fMessage = msg, fReleases = []}