dnf-repo-0.7: src/Main.hs
{-# LANGUAGE CPP, LambdaCase #-}
-- SPDX-License-Identifier: BSD-3-Clause
module Main (main) where
import Control.Monad.Extra
import Data.Bifunctor (bimap)
import Data.Char (isDigit)
import Data.List.Extra
import Data.Maybe (isJust, mapMaybe, maybeToList)
import Data.Tuple.Extra (fst3)
import Network.Curl (curlGetString, CurlCode(CurlOK))
import Safe (lastMay)
import SimpleCmd (cmdFull, cmdMaybe, error', warning, (+-+),
#if MIN_VERSION_simple_cmd(0,2,4)
filesWithExtension
#endif
)
import SimpleCmdArgs
import SimplePrompt (yesNo)
import System.Directory (doesDirectoryExist, doesFileExist, findExecutable,
withCurrentDirectory,
#if !MIN_VERSION_simple_cmd(0,2,4)
listDirectory
#endif
)
import System.Environment (lookupEnv)
import System.FilePath
import System.IO (hSetBuffering, stdout, BufferMode(NoBuffering))
import System.IO.Extra (withTempDir)
import Text.EditDistance
import CoprRepo
import ExpireRepos
import KojiRepo
import Paths_dnf_repo (version)
import State
import Sudo
import TimeStamp
import Updates
import YumRepoFile
main :: IO ()
main = do
checkSudo
simpleCmdArgs' (Just version)
"DNF wrapper repo tool"
"see https://github.com/juhp/dnf-repo#readme ; 'updates' to show updates info" $
runMain
<$> switchWith 'n' "dryrun" "Dry run"
<*> switchWith 'q' "quiet" "Suppress necessary output"
<*> switchWith 'D' "debug" "Debug output"
<*> switchWith 'l' "list" "List all repos"
<*> switchWith 's' "save" "Save the repo enable/disable state"
<*> switchWith '4' "dnf4" "Use older dnf-3 (if available)"
<*> optional (flagWith' True 'w' "weak-deps" "Use weak dependencies" <|>
flagWith' False 'W' "no-weak-deps" "Disable weak dependencies")
<*> switchLongWith "exact" "Match repo names exactly"
<*> many modeOpt
<*> many (strArg "DNFARGS")
where
repoOptionWith =
(fmap . fmap . fmap . fmap) cleanupReponame . strOptionWith
where
cleanupReponame =
-- FIXME handle copr url too
serverAliases . replace "@" "group_" . replace "/" ":" . dropWhileEnd (== '/') . dropWhile (== '/') . handleCoprUrl
serverAliases ('r':'e':'d':'h':'a':'t':':':copr) =
"copr.devel.redhat.com:" ++ copr
serverAliases copr = copr
handleCoprUrl url =
if "https:" `isPrefixOf` url
then replace "coprs/" "" $ dropPrefix "https:" url
else url
modeOpt =
DisableRepo <$> repoOptionWith 'd' "disable" "REPOPAT" "Disable repos" <|>
EnableRepo <$> repoOptionWith 'e' "enable" "REPOPAT" "Enable repos" <|>
OnlyRepo <$> repoOptionWith 'o' "only" "REPOPAT" "Only use matching repos" <|>
ExpireRepo <$> repoOptionWith 'x' "expire" "REPOPAT" "Expire repo cache (dnf4)" <|>
flagWith' ClearExpires 'X' "clear-expires" "Undo cache expirations (dnf4)" <|>
DeleteRepo <$> repoOptionWith 'E' "delete-repofile" "REPOPAT" "Remove unwanted .repo file" <|>
TimeStampRepo <$> repoOptionWith 'z' "timestamp" "REPOPAT" "Show repodata timestamps" <|>
flagWith' (Specific EnableTesting) 't' "enable-testing" "Enable testing repos" <|>
flagWith' (Specific DisableTesting) 'T' "disable-testing" "Disable testing repos" <|>
flagWith' (Specific EnableModular) 'm' "enable-modular" "Enable modular repos" <|>
flagWith' (Specific DisableModular) 'M' "disable-modular" "Disable modular repos" <|>
flagLongWith' (Specific EnableDebuginfo) "enable-debuginfo" "Enable debuginfo repos" <|>
flagLongWith' (Specific DisableDebuginfo) "disable-debuginfo" "Disable debuginfo repos" <|>
flagLongWith' (Specific EnableSource) "enable-source" "Enable source repos" <|>
flagLongWith' (Specific DisableSource) "disable-source" "Disable source repos" <|>
AddCopr
<$> repoOptionWith 'c' "add-copr" "[SERVER/]COPR/PROJECT|URL" "Install copr repo file (defaults to fedora server)"
<*> optional (strOptionLongWith "osname" "OSNAME" "Specify OS Name to override (eg epel)")
<*> optional (strOptionLongWith "copr-releasever" "RELEASEVER" "Specify OS Release Version to override (eg rawhide)") <|>
AddKoji
<$> repoOptionWith 'k' "add-koji" "REPO" "Create repo file for a Fedora koji repo (f40-build, rawhide, epel9-build, etc)" <|>
AddRepo
<$> strOptionWith 'r' "add-repofile" "REPOFILEURL" "Install repo file"
<*> optional (strOptionLongWith "repo-releasever" "RELEASEVER" "Specify OS Release Version to override (eg rawhide)") <|>
RepoURL
<$> strOptionWith 'u' "repourl" "URL" "Use temporary repo from a baseurl"
yumReposD :: String
yumReposD = "/etc/yum.repos.d"
-- dnf5 repos.d order: the first definition of a repo id wins
dnf5RepoDirs :: [FilePath]
dnf5RepoDirs =
[yumReposD, "/etc/distro.repos.d", "/usr/share/dnf5/repos.d"]
-- FIXME both enabling and disabled at the same time
-- FIXME confirm repos if many
-- FIXME --disable-non-cores (modular,testing,cisco, etc)
runMain :: Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Maybe Bool -> Bool
-> [Mode] -> [String] -> IO ()
runMain dryrun quiet debug listrepos save dnf4 mweakdeps exact modes args = do
hSetBuffering stdout NoBuffering
mpkgmgr <- detectPkgMgr quiet dnf4
let repoDirs = if mpkgmgr == Just Dnf5 then dnf5RepoDirs else [yumReposD]
unlessM (anyM doesDirectoryExist repoDirs) $
error' $ yumReposD +-+ "not found!"
(nameStates,actions) <-
withCurrentDirectory yumReposD $ do
forM_ modes $
\case
AddCopr copr mosname mrelease ->
addCoprRepo dryrun debug mosname mrelease copr
AddKoji repo ->
addKojiRepo dryrun debug repo
AddRepo repo mrelease ->
addRepoFile dryrun debug mrelease repo
_ -> return ()
existing <- filterM doesDirectoryExist repoDirs
loaded <- concatMapM readRepoDir existing
when debug $ print modes
unique <- dropDuplicateRepos loaded
overridden <-
if mpkgmgr == Just Dnf5
then applyOverrideFiles unique
else return unique
let nameStates = sort overridden
when debug $ mapM_ printRepo nameStates
let actions = selectRepo exact nameStates modes
moreoutput = not (null args) || null actions || listrepos
when (debug && not (null modes)) $
if null actions
then putStrLn "no actions"
else print actions
unless (null actions || quiet) $ do
mapM_ putStrLn $ reduceOutput $ mapMaybe (printAction save) actions
when moreoutput $
warning ""
outputs <-
forM actions $
\case
Expire repo _ -> do
expireRepo dryrun debug repo
return True
UnExpire -> do
clearExpired dryrun debug
return True
Delete repofile True -> do
deleteRepo dryrun debug repofile
return True
TimeStamp _repo mbaseurl True -> do
whenJust mbaseurl $ timestampRepo "rawhide"
return True
_ -> return False
when (or outputs && (save || moreoutput) && not quiet) $
warning ""
return (nameStates,actions)
when save $
if null actions
then putStrLn "no changes to save"
else do
let named = mapMaybe saveAction actions
confirmSave n =
yesNo $ "Save changed repo" +-+ "enabled state" ++ ['s' | length n > 1]
case mpkgmgr of
Just Dnf5 ->
let optvals = mapMaybe setoptVal named in
unless (null optvals) $
whenM (confirmSave optvals) $
doSudo dryrun debug (pkgMgrCmd Dnf5) $
["config-manager", "setopt", "--create-missing-dir"] ++ optvals
_ -> do
let (etcrepos, outside) =
partition (repoInYumReposD nameStates . fst) named
unless (null outside) $
warning $ "not saving" +-+ unwords (map fst outside) ++
": defined outside" +-+ yumReposD
let changes = concatMap (saveRepo (mpkgmgr == Just Dnf4) . snd) etcrepos
unless (null changes) $
whenM (confirmSave changes) $
if mpkgmgr == Just Dnf4
then doSudo dryrun debug (pkgMgrCmd Dnf4) $ "config-manager" : changes
else doSudo dryrun debug "sh" ["-c", unwords $ "sed" : "-i" : map show changes ++ ["/etc/yum.repos.d/*.repo"]]
case args of
[] ->
when (null actions || listrepos) $ do
when save $ putStrLn ""
listRepos $ map (updateState actions) nameStates
(c:as) -> do
when save $ putStrLn ""
case mpkgmgr of
-- FIXME rpm-ostree install supports --enablerepo
Nothing -> error' "missing dnf (rpm-ostree is not supported)"
Just dnf ->
let repoargs = mapMaybe changeRepo actions
weakdeps = maybe [] (\w -> ["--setopt=install_weak_deps=" ++ show w]) mweakdeps
quietopt = if quiet then ("-q" :) else id
cachedir = ["--setopt=cachedir=/var/cache/dnf" </> relver | relver <- maybeToList (maybeReleaseVer args)]
extraargs =
-- special case for "dnf-repo [-c owner/project|-e repo] install"
case actions of
[action] ->
case action of
Enable repo True | args == ["install"] ->
[takeWhileEnd (/= ':') repo]
_ -> []
_ -> []
in
if c `elem` ["updates","advisories","updateinfos"] && dnf == Dnf5
then
printUpdates as
else do
args' <-
if null as && dnf == Dnf5
then do
if c `elem` dnfCommands
then return args
else
let close = filter (\c' -> levenshteinDistance defaultEditCosts c c' < 3) dnfCommands in
if null close
then do
installed <- isInstalled c
if installed
then error' $ c +-+ "is already installed: missing command"
else do
ok <- yesNo $ "Do you want to install" +-+ c
if ok
then return $ "install" : args
else return args
else do
warning $ "Did you mean:" +-+ unwords close +-+ "?"
return args
else return args
doSudo dryrun debug (pkgMgrCmd dnf) $
quietopt repoargs ++ cachedir ++ weakdeps ++ map mungeArg args'
++ extraargs
where
mungeArg :: String -> String
mungeArg "distrosync" = "distro-sync"
mungeArg arg =
-- expand "libNAME.so.X" to "libNAME.so.X()(64bit)" etc
if "lib" `isPrefixOf` arg && ".so." `isInfixOf` arg && lastMay arg /= Just ')'
then arg ++ "()(64bit)"
else arg
-- FIXME maybe handle string (vscode) or local file?
addRepoFile :: Bool -> Bool -> Maybe String -> String -> IO ()
addRepoFile dryrun debug mrelease url = do
unless (".repo" `isExtensionOf` url) $
error' $ url +-+ "does not appear to be a .repo file"
let repofile = takeFileName url
exists <- doesFileExist repofile
if exists
then warning $ "repo file already exists:" +-+ repofile
else do
(curlres,curlcontent) <- curlGetString url []
unless (curlres == CurlOK) $
error' $ "downloading failed of" +-+ url
putStrLn $ "Setting up" +-+ repofile
withTempDir $ \ tmpdir -> do
let tmpfile = tmpdir </> repofile
unless dryrun $ writeFile tmpfile $
maybe id (replace "$releasever") mrelease $
maybe id (replace "$releasever") mrelease $
replace "enabled=1" "enabled=0" curlcontent
doSudo dryrun debug "cp" [tmpfile, repofile]
putStrLn ""
listRepos :: [RepoState] -> IO ()
listRepos repoStates = do
let (on,off) =
-- can't this be simplified?
bimap (map repoName) (map repoName) $ partition (fst3 . repoState) repoStates
putStrLn "Enabled:"
mapM_ putStrLn on
putStrLn ""
putStrLn "Disabled:"
mapM_ putStrLn off
deleteRepo :: Bool -> Bool -> FilePath -> IO ()
deleteRepo dryrun debug repofile = do
mowned <- cmdMaybe "rpm" ["-qf", repofile]
case mowned of
Just owner -> warning $ repofile +-+ "owned by" +-+ owner
Nothing -> do
ok <- yesNo $ "Remove" +-+ takeFileName repofile
when ok $ do
doSudo dryrun debug "rm" [repofile]
-- FIXME should default to system default
detectPkgMgr :: Bool -> Bool -> IO (Maybe PkgMgr)
detectPkgMgr quiet dnf4 =
if dnf4
then do
mdnf3 <- checkSystemPathFile "dnf-3"
case mdnf3 of
Just _ -> return $ Just Dnf4
Nothing -> error' "dnf-3 not found"
else do
mdnf5 <- checkSystemPathFile "dnf5"
case mdnf5 of
Just _ -> return $ Just Dnf5
Nothing -> do
mdnf3 <- checkSystemPathFile "dnf-3"
case mdnf3 of
Just _ -> return $ Just Dnf4
Nothing -> return Nothing
where
checkSystemPathFile :: String -> IO (Maybe String)
checkSystemPathFile prog = do
mpath <- findExecutable prog
case mpath of
Nothing -> return Nothing
Just path -> do
let syspath = "/usr/bin" </> prog
unless (path == syspath) $ do
exists <- doesFileExist syspath
when exists $
unless quiet $
warning $ path +-+ "overrides" +-+ syspath
return $ Just path
readRepoDir :: FilePath -> IO [RepoState]
readRepoDir dir = do
files <- filesWithExtension dir "repo"
concatMapM (readRepos . (dir </>)) files
-- /etc file masks the same filename under /usr; then alphabetical order.
overrideDirs :: [FilePath]
overrideDirs =
["/usr/share/dnf5/repos.override.d", "/etc/dnf/repos.override.d"]
applyOverrideFiles :: [RepoState] -> IO [RepoState]
applyOverrideFiles states = do
files <- overrideFiles
ovs <- concatMapM readOverrides files
return $ applyOverrides ovs states
overrideFiles :: IO [FilePath]
overrideFiles = do
named <- concatMapM listRepoFiles overrideDirs
return $ map snd $ sortOn fst $ foldl' mask [] named
where
-- later dir (/etc) replaces an earlier file of the same name
mask acc (name, path) =
(name, path) : filter ((/= name) . fst) acc
listRepoFiles :: FilePath -> IO [(FilePath, FilePath)]
listRepoFiles dir = do
exists <- doesDirectoryExist dir
if exists
then map (\f -> (f, dir </> f)) <$> filesWithExtension dir "repo"
else return []
-- First definition of a repo id wins, matching dnf5 reposdir order.
dropDuplicateRepos :: [RepoState] -> IO [RepoState]
dropDuplicateRepos = go []
where
go _ [] = return []
go seen (rs@(RepoState name (_,file,_)):rest) =
case lookup name seen of
Just kept -> do
warning $ name +-+ "already defined in" +-+ kept ++
", ignoring" +-+ file
go seen rest
Nothing -> (rs :) <$> go ((name,file):seen) rest
saveAction :: ChangeEnable -> Maybe (String, ChangeEnable)
saveAction a@(Enable r True) = Just (r, a)
saveAction a@(Disable r True) = Just (r, a)
saveAction _ = Nothing
setoptVal :: (String, ChangeEnable) -> Maybe String
setoptVal (r, Disable _ _) = Just $ r ++ ".enabled=0"
setoptVal (r, Enable _ _) = Just $ r ++ ".enabled=1"
setoptVal (_, _) = Nothing
repoInYumReposD :: [RepoState] -> String -> Bool
repoInYumReposD states name =
case lookupRepo name states of
Just (_, file, _) -> takeDirectory file == yumReposD
Nothing -> False
#if !MIN_VERSION_simple_cmd(0,2,4)
filesWithExtension :: FilePath -> String -> IO [FilePath]
filesWithExtension dir ext =
filter (ext `isExtensionOf`) <$> listDirectory dir
#endif
maybeReleaseVer :: [String] -> Maybe String
maybeReleaseVer args =
let releaseverOpt = "--releasever"
in
case findIndices (releaseverOpt `isPrefixOf`) args of
[] -> Nothing
is -> let lst = last is
opt = args !! lst
relver =
case dropPrefix releaseverOpt opt of
"" ->
if lst == length args
then error' $ releaseverOpt +-+ "without version"
else args !! (lst + 1)
'=':rv -> rv
_ -> error' $ "could not parse" +-+ opt
-- still not sure if this fully makes sense
in if all isDigit relver || relver `elem` ["rawhide","eln"]
then Just relver
else error' $ "unknown releasever:" +-+ relver
checkSudo :: IO ()
checkSudo = do
-- previously checked for euid (no username for termux-fedora)
issudo <- isJust <$> lookupEnv "SUDO_USER"
when issudo $
warning "*No need to run dnf-repo directly with sudo*"
#if !MIN_VERSION_filepath(1,4,2)
isExtensionOf :: String -> FilePath -> Bool
isExtensionOf ext@('.':_) = isSuffixOf ext . takeExtensions
isExtensionOf ext = isSuffixOf ('.':ext) . takeExtensions
#endif
isInstalled :: String -> IO Bool
isInstalled pkg = do
(ok,_,_) <- cmdFull "rpm" ["-q", pkg] ""
return ok
-- generated by dnf5-commands.awk
-- FIXME could reverse map "onetwo" to "one-two" - would also allow dropping the hard coded "distrosync" hack
dnfCommands :: [String]
dnfCommands =
-- dnf5-5.4.5.0
["do","install","upgrade","remove","distro-sync","downgrade","reinstall","debuginfo-install","swap","mark","autoremove","provides","replay","check-upgrade","check","leaves","repoquery","search","list","info","status","group","environment","module","history","repo","advisory","versionlock","system-upgrade","offline-distrosync","offline-upgrade","offline","config-manager","check-update","dg","dsync","grp","if","in","ls","mc","rei","repoinfo","repolist","rm","rq","se","up","update","updateinfo","upgrade-minimal","clean","download","makecache","builddep","changelog","copr","needs-restarting","repoclosure","repomanage","reposync","build-dep"]