pushme-3.1.0: Main.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternGuards #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE ViewPatterns #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Main where
import Control.Applicative ((<|>))
import Control.Arrow ((&&&))
import Control.Concurrent (forkIO)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.ParallelIO (parallel, parallel_, stopGlobalPool)
import Control.Concurrent.QSem (QSem, newQSem, signalQSem, waitQSem)
import Control.Exception (finally)
import Control.Lens
import Control.Logging
import Control.Monad (guard, unless, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Data.Aeson hiding (Options)
import Data.Aeson.Types (Parser)
import qualified Data.ByteString as BS
import Data.Function (on)
import Data.List (isInfixOf, isSuffixOf, sortOn, (\\))
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe (
fromJust,
fromMaybe,
maybeToList,
)
import Data.Text (Text, pack, unpack)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import qualified Data.Text.IO as T
import Data.Time (NominalDiffTime, diffUTCTime, getCurrentTime)
import Data.Traversable (forM)
import Data.Yaml (decodeFileEither, prettyPrintParseException)
import Pushme.Options
import System.Directory (
doesDirectoryExist,
getHomeDirectory,
listDirectory,
)
import System.Exit (ExitCode (..))
import System.FilePath.Posix (
takeExtension,
(</>),
)
import System.IO (hClose, hPutStr, hSetBinaryMode)
import System.IO.Temp (withSystemTempFile)
import System.Process hiding (env)
import Text.Printf (printf)
import Text.Regex.Posix ((=~))
import Text.Show.Pretty (ppShow)
data TransferStatus = TransferSuccess | TransferWarning | TransferError
deriving (Show, Eq, Ord)
data SyncDirection = Push | Pull
deriving (Show, Eq, Ord)
data Fileset = Fileset
{ _filesetName :: Text
, _filesetClasses :: Maybe [Text]
, _filesetPriority :: Int
, _filesetStores :: Map Text (FilePath, RsyncOptions)
, _filesetCommon :: Maybe RsyncOptions
}
deriving (Show, Eq)
makeLenses ''Fileset
decodeEnrichedOptions :: Map Text Value -> Parser (FilePath, RsyncOptions)
decodeEnrichedOptions m =
parseM (m ^. at "Path") >>= \case
Nothing -> fail "Missing value for Path"
Just path -> do
preserveAll <- parseM (m ^. at "PreserveAttrs")
(path,)
<$> ( RsyncOptions
<$> parseM (m ^. at "Filters")
<*> parseM (m ^. at "ExtraFilters")
<*> (fromMaybe False <$> parseM (m ^. at "NoBasicOptions"))
<*> (fromMaybe False <$> parseM (m ^. at "NoDelete"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveACLs"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveXattrs"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveAtimes"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveCrtimes"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveHardLinks"))
<*> ((<|> preserveAll) <$> parseM (m ^. at "PreserveExecutability"))
<*> (fromMaybe False <$> parseM (m ^. at "ProtectTopLevel"))
<*> parseM (m ^. at "Options")
<*> parseM (m ^. at "ReceiveFrom")
<*> (fromMaybe True <$> parseM (m ^. at "Active"))
)
where
parseM :: (FromJSON a) => Maybe Value -> Parser (Maybe a)
parseM Nothing = pure Nothing
parseM (Just v) = parseJSON v
instance FromJSON Fileset where
parseJSON (Object v) =
Fileset
<$> v .: "Name"
<*> v .:? "Classes"
<*> v .:? "Priority" .!= 1000
<*> (v .: "Stores" >>= traverse decodeEnrichedOptions)
<*> v .:? "Common"
parseJSON _ = errorL "Error parsing Fileset"
data Host = Host
{ _hostName :: Text
, _hostMaxJobs :: Int
}
deriving (Show, Eq, Ord)
makeLenses ''Host
parseHost :: Text -> Host
parseHost name = case T.split (== '@') name of
[n] -> Host n 1
[n, j] -> Host n (read (unpack j))
_ -> errorL $ "Cannot parse hostname: " <> name
-- | Extract Host from HostRef for compatibility
hostFromRef :: HostRef -> Host
hostFromRef ref =
let (name, jobs) = ref ^. hostRefActualHost
in Host name jobs
{- | Parse a host reference from CLI argument, checking aliases first.
Supports @N suffix for overriding MaxJobs on both raw hostnames and aliases.
Examples: "hera@24", "tank@8" (where tank is an alias)
-}
parseHostRef :: Options -> Text -> HostRef
parseHostRef opts name =
let (baseName, overrideJobs) = case T.split (== '@') name of
[n] -> (n, Nothing)
[n, j] -> (n, Just (read (unpack j)))
_ -> errorL $ "Cannot parse host reference: " <> name
in case opts ^. optsAliases . at baseName of
Just alias ->
let (actualName, actualJobs) = case alias ^. aliasHost of
h
| "@" `T.isInfixOf` h ->
let [n, j] = T.split (== '@') h
in (n, read (unpack j))
h -> (h, fromMaybe 1 (alias ^. aliasMaxJobs))
-- Use override from CLI if provided, otherwise use alias config
finalJobs = fromMaybe actualJobs overrideJobs
in HostRef
{ _hostRefLogicalName = baseName
, _hostRefActualHost = (actualName, finalJobs)
, _hostRefVariables = alias ^. aliasVariables
, _hostRefOptions = alias ^. aliasOptions
}
Nothing ->
let host = parseHost baseName
finalJobs = fromMaybe (host ^. hostMaxJobs) overrideJobs
in HostRef
{ _hostRefLogicalName = baseName
, _hostRefActualHost = (baseName, finalJobs)
, _hostRefVariables = M.empty
, _hostRefOptions = Nothing
}
{- | Interpolate all $variable references in a path using the provided variable map.
Variable names must match pattern: $[a-zA-Z_][a-zA-Z0-9_]*
Throws error if a variable is referenced but not defined in the map.
-}
interpolatePath :: Map Text Text -> FilePath -> FilePath
interpolatePath variables path
| "$" `isInfixOf` path = interpolateText variables path
| otherwise = path
where
-- Regex pattern for variable names: $[a-zA-Z_][a-zA-Z0-9_]*
varPattern :: String
varPattern = "\\$[a-zA-Z_][a-zA-Z0-9_]*"
-- Find all variable references and replace them
interpolateText :: Map Text Text -> String -> String
interpolateText vars txt =
let matches = txt =~ varPattern :: [[String]]
varRefs = [m | (m : _) <- matches] -- Safe head extraction with pattern match
in if null varRefs
then txt
else foldl (replaceVar vars) txt varRefs
-- Replace a single variable reference with its value
replaceVar :: Map Text Text -> String -> String -> String
replaceVar vars txt varRef =
let varName = pack $ drop 1 varRef -- Remove leading $ and convert to Text
in case M.lookup varName vars of
Nothing ->
error $
"Path contains undefined variable "
<> varRef
<> " in path: "
<> path
<> "\nAvailable variables: "
<> show (M.keys vars)
Just value ->
let result = T.replace (pack varRef) value (pack txt)
in unpack result
data Binding = Binding
{ _bindingFileset :: Fileset
, _bindingSourceHost :: HostRef
, _bindingSourcePath :: FilePath
, _bindingTargetHost :: HostRef
, _bindingTargetPath :: FilePath
, _bindingRsyncOpts :: RsyncOptions
, _bindingDirection :: SyncDirection
}
deriving (Show, Eq)
makeLenses ''Binding
bindingRemoteHost :: Binding -> HostRef
bindingRemoteHost bnd = case bnd ^. bindingDirection of
Push -> bnd ^. bindingTargetHost
Pull -> bnd ^. bindingSourceHost
isLocal :: Binding -> Bool
isLocal bnd =
fst (bnd ^. bindingSourceHost . hostRefActualHost)
== fst (bnd ^. bindingTargetHost . hostRefActualHost)
remoteHost :: Binding -> Maybe Host
remoteHost bnd
| isLocal bnd = Nothing
| otherwise = case bnd ^. bindingDirection of
Push -> Just (hostFromRef (bnd ^. bindingTargetHost))
Pull -> Just (hostFromRef (bnd ^. bindingSourceHost))
type App a = ReaderT Options IO a
main :: IO ()
main = withStdoutLogging do
cmdLineOpts <- getOptions
configOpts <-
readYaml
=<< expandPath (cmdLineOpts ^. optsConfigDir </> "config.yaml")
let opts = resolveOptionsFilterReferences (configOpts <> cmdLineOpts)
setLogLevel $ if opts ^. optsVerbose then LevelDebug else LevelInfo
setLogTimeFormat "%H:%M:%S"
when (opts ^. optsDryRun) $
warn' "`--dryrun' specified, no changes will be made!"
debug' $ "Command-line options: " <> pack (ppShow cmdLineOpts)
debug' $ "Config file options: " <> pack (ppShow configOpts)
debug' $ "Initial composite options: " <> pack (ppShow opts)
runReaderT processBindings opts `finally` stopGlobalPool
processBindings :: App ()
processBindings = do
opts <- ask
case opts ^. optsCliArgs of
host : hosts@(_ : _) -> liftIO do
fsets <- traverse expandFilesetPaths =<< readFilesets opts
-- Determine direction: --reverse flag or auto-detect from hostname
localHostname <-
T.toLower . T.takeWhile (/= '.') . T.strip . pack
<$> readProcess "hostname" ["-s"] ""
debug' $ "Local hostname: " <> localHostname
let hereRef = parseHostRef opts (pack host)
allHostRefs = map (parseHostRef opts . pack) hosts
resolvedName ref =
T.toLower $ T.takeWhile (/= '.') $ fst (ref ^. hostRefActualHost)
matchesLocal ref =
resolvedName ref == localHostname
|| T.toLower (ref ^. hostRefLogicalName) == localHostname
-- Auto-detect: if first arg is not local, find the local host
-- among the remaining args and treat as reverse (pull) mode
(here, remoteRefs, pullMode)
| opts ^. optsReverse = (hereRef, allHostRefs, True)
| not (matchesLocal hereRef) =
case filter matchesLocal allHostRefs of
(localRef : _) ->
(localRef, hereRef : filter (/= localRef) allHostRefs, True)
[] -> (hereRef, allHostRefs, False)
| otherwise = (hereRef, allHostRefs, False)
bindings =
relevantBindings opts here fsets remoteRefs pullMode
when pullMode $
debug' "Reverse mode: pulling from remote hosts"
debug' $
"Local host "
<> fst (here ^. hostRefActualHost)
<> " with "
<> tshow (snd (here ^. hostRefActualHost))
<> (if pullMode then " receiving" else " sending")
<> " jobs"
hereSlots <- newQSem (snd (here ^. hostRefActualHost))
thereSlotsAll <- forM (M.keys bindings) $ \there -> do
debug' $
"Remote host "
<> fst (there ^. hostRefActualHost)
<> " with "
<> tshow (snd (there ^. hostRefActualHost))
<> " receiving jobs"
newQSem (snd (there ^. hostRefActualHost))
parallel_ do
(bnds, thereSlots) <- zip (M.toList bindings) thereSlotsAll
pure (goHost opts hereSlots thereSlots bnds)
_ -> log' "Usage: pushme FROM TO..."
where
-- Process all bindings for a single destination host
goHost opts p q (there, bnds) = do
-- Process all bindings for this host in parallel and collect statuses
statuses <- parallel (map (go opts p q) bnds)
-- After all bindings for this host are done, print completion message
unless (null bnds) $ do
let overallStatus = maximum statuses -- TransferError > TransferWarning > TransferSuccess
suffix = case overallStatus of
TransferSuccess -> ""
TransferWarning -> " (with warnings)"
TransferError -> " (with errors)"
msg = (there ^. hostRefLogicalName) <> " done" <> suffix
coloredMsg =
if opts ^. optsNoColor
then msg
else case overallStatus of
TransferSuccess -> "\ESC[32m" <> msg <> "\ESC[0m"
TransferWarning -> "\ESC[33m" <> msg <> "\ESC[0m"
TransferError -> "\ESC[31m" <> msg <> "\ESC[0m"
-- Use log' for completion messages since they're informational
-- (all transfers done), with color/suffix indicating status
log' coloredMsg
go :: Options -> QSem -> QSem -> Binding -> IO TransferStatus
go opts p q bnd = do
waitQSem p
(waitQSem q >> runReaderT (applyBinding bnd) opts)
`finally` (signalQSem q >> signalQSem p)
applyBinding :: Binding -> App TransferStatus
applyBinding bnd = do
log' $ case bnd ^. bindingDirection of
Push ->
"Sending "
<> (bnd ^. bindingFileset . filesetName)
<> " → "
<> (bnd ^. bindingTargetHost . hostRefLogicalName)
Pull ->
"Receiving "
<> (bnd ^. bindingFileset . filesetName)
<> " ← "
<> (bnd ^. bindingSourceHost . hostRefLogicalName)
debug' $ pack (ppShow bnd)
syncStores
bnd
(bnd ^. bindingSourcePath)
(bnd ^. bindingTargetPath)
(bnd ^. bindingRsyncOpts)
relevantBindings ::
Options ->
HostRef ->
Map Text Fileset ->
[HostRef] ->
Bool ->
Map HostRef [Binding]
relevantBindings opts here fsets hosts pullMode =
M.map
(sortOn (^. bindingFileset . filesetPriority))
(collect bindingRemoteHost bindings)
where
bindings :: [Binding]
bindings = do
fset <- M.elems fsets
there <- hosts
maybeToList $
if pullMode
then buildPullBinding fset there
else buildPushBinding fset there
buildPushBinding :: Fileset -> HostRef -> Maybe Binding
buildPushBinding fset there = do
(src, _) <- fset ^. filesetStores . at (here ^. hostRefLogicalName)
(dest, destOpts) <- fset ^. filesetStores . at (there ^. hostRefLogicalName)
guard $
not
( maybe
False
(not . (here ^. hostRefLogicalName `elem`))
(destOpts ^. rsyncReceiveFrom)
)
let !srcPath = interpolatePath (here ^. hostRefVariables) src
!destPath = interpolatePath (there ^. hostRefVariables) dest
binding =
Binding
{ _bindingFileset = fset
, _bindingSourceHost = here
, _bindingSourcePath = srcPath
, _bindingTargetHost = there
, _bindingTargetPath = destPath
, _bindingRsyncOpts =
case opts ^. optsRsyncOpts <> fset ^. filesetCommon of
Nothing -> destOpts
Just common -> common <> destOpts
, _bindingDirection = Push
}
guard $ isMatching binding
guard $ destOpts ^. rsyncActive
pure binding
buildPullBinding :: Fileset -> HostRef -> Maybe Binding
buildPullBinding fset there = do
(srcPath0, srcOpts) <- fset ^. filesetStores . at (there ^. hostRefLogicalName)
(destPath0, destOpts) <- fset ^. filesetStores . at (here ^. hostRefLogicalName)
-- ReceiveFrom: does local store allow receiving from remote?
guard $
not
( maybe
False
(not . (there ^. hostRefLogicalName `elem`))
(destOpts ^. rsyncReceiveFrom)
)
let !srcPath = interpolatePath (there ^. hostRefVariables) srcPath0
!destPath = interpolatePath (here ^. hostRefVariables) destPath0
binding =
Binding
{ _bindingFileset = fset
, _bindingSourceHost = there
, _bindingSourcePath = srcPath
, _bindingTargetHost = here
, _bindingTargetPath = destPath
, _bindingRsyncOpts =
case opts ^. optsRsyncOpts <> fset ^. filesetCommon of
Nothing -> destOpts
Just common -> common <> destOpts
, _bindingDirection = Pull
}
guard $ isMatching binding
guard $ srcOpts ^. rsyncActive
guard $ destOpts ^. rsyncActive
pure binding
isMatching :: Binding -> Bool
isMatching bnd =
(null fss || any (matchText (fs ^. filesetName)) fss)
&& (null cls || or (matchText <$> cs <*> cls))
where
fs = bnd ^. bindingFileset
cs = fromMaybe [] (fs ^. filesetClasses)
fss = fromMaybe [] (opts ^. optsFilesets)
cls = fromMaybe [] (opts ^. optsClasses)
readFilesets :: Options -> IO (Map Text Fileset)
readFilesets opts = do
confD <- expandPath (opts ^. optsConfigDir </> "filesets")
exists <- doesDirectoryExist confD
unless exists $
errorL $
"Please define filesets, "
<> "using files named "
<> pack (opts ^. optsConfigDir)
<> "filesets/<name>.yaml"
directoryContents confD
>>= mapM (fmap (resolveFilesetFilterReferences opts) . readYaml)
. filter (\n -> takeExtension n == ".yaml")
<&> M.fromList . map ((^. filesetName) &&& id)
resolveFilesetFilterReferences :: Options -> Fileset -> Fileset
resolveFilesetFilterReferences opts fs =
let filterSets = opts ^. optsFilterSets
in fs
& filesetStores . traverse . _2 %~ resolveRsyncFilterReferences filterSets
& filesetCommon . _Just %~ resolveRsyncFilterReferences filterSets
checkDirectory :: Binding -> FilePath -> Bool -> App Bool
checkDirectory _ path False =
liftIO $ doesDirectoryExist path
checkDirectory (isLocal -> True) path True =
liftIO $ doesDirectoryExist path
checkDirectory bnd path True =
(ExitSuccess ==) . fstOf3
<$> execute
(remoteHost bnd)
"test"
["-d", unpack (escape (pack path))]
where
escape :: Text -> Text
escape x
| "\"" `T.isInfixOf` x || " " `T.isInfixOf` x =
"'" <> T.replace "\"" "\\\"" x <> "'"
| otherwise = x
syncStores :: Binding -> FilePath -> FilePath -> RsyncOptions -> App TransferStatus
syncStores bnd src dest roDest = do
exists <- case bnd ^. bindingDirection of
Push ->
(&&)
<$> checkDirectory bnd l False
<*> checkDirectory bnd r True
Pull ->
(&&)
<$> checkDirectory bnd l True
<*> checkDirectory bnd r False
if exists
then invokeRsync bnd l roDest (remoteHost bnd) r
else liftIO do
let (localDir, remoteDir) = case bnd ^. bindingDirection of
Push -> (l, r)
Pull -> (r, l)
warn $ "Either local directory missing: " <> pack localDir
warn $ "OR remote directory missing: " <> pack remoteDir
pure TransferError
where
(asDirectory -> l) = src
(asDirectory -> r) = dest
invokeRsync ::
Binding ->
FilePath ->
RsyncOptions ->
Maybe Host ->
FilePath ->
App TransferStatus
invokeRsync bnd src roDest host dest = do
opts <- ask
withProtected $ \args1 ->
withFilters "Filters" (combineFilters (roDest ^. rsyncFilters) (roDest ^. rsyncExtraFilters)) $ \args2 ->
doRsync
( bindingRemoteHost bnd ^. hostRefLogicalName
<> "/"
<> bnd ^. bindingFileset . filesetName
)
(rsyncArguments opts (args1 ++ args2))
where
withProtected k
| roDest ^. rsyncProtectTopLevel =
k ["--filter", "P /*"]
| otherwise = k []
withFilters label fs k = case fs of
Nothing -> k []
Just filters -> withSystemTempFile "filters" $ \fpath h -> do
liftIO do
T.hPutStr h filters
hClose h
debug' $ label <> ":\n" <> filters
k ["--include-from", pack fpath]
rsyncArguments :: Options -> [Text] -> [Text]
rsyncArguments opts args =
["-a" | not (roDest ^. rsyncNoBasicOptions)]
<> ["-s"]
<> ["--delete" | not (roDest ^. rsyncNoDelete)]
<> ["-A" | roDest ^. rsyncPreserveACLs == Just True]
<> ["-X" | roDest ^. rsyncPreserveXattrs == Just True]
<> ["-U" | roDest ^. rsyncPreserveAtimes == Just True]
<> ["-N" | roDest ^. rsyncPreserveCrtimes == Just True]
<> ["-H" | roDest ^. rsyncPreserveHardLinks == Just True]
<> ["-E" | roDest ^. rsyncPreserveExecutability == Just True]
<> ["-n" | opts ^. optsDryRun]
<> ( if opts ^. optsVerbose
then ["-v"]
else ["--stats"]
)
<> args
<> fromMaybe [] (roDest ^. rsyncOptions)
<> fromMaybe [] (bindingRemoteHost bnd ^. hostRefOptions)
<> case bnd ^. bindingDirection of
Push ->
[ pack src
, case host ^? _Just . hostName of
Nothing -> pack dest
Just h -> h <> ":" <> pack dest
]
Pull ->
[ case host ^? _Just . hostName of
Nothing -> pack src
Just h -> h <> ":" <> pack src
, pack dest
]
doRsync :: Text -> [Text] -> App TransferStatus
doRsync label args = do
opts <- ask
(ec, diff, output) <- execute Nothing "rsync" (map unpack args)
let status = case ec of
ExitSuccess -> TransferSuccess
ExitFailure 23 -> TransferWarning -- Partial transfer
ExitFailure 24 -> TransferWarning -- Vanished source files
_ -> TransferError
when (ec == ExitSuccess && not (opts ^. optsDryRun)) $
if opts ^. optsVerbose
then liftIO $ putStr output
else do
let stats =
M.fromList
$ map
( fmap (T.filter (/= ',') . (!! 1) . T.words)
. T.breakOn ": "
)
$ filter (": " `T.isInfixOf`)
$ map pack
$ lines output
files = field "Number of files" stats
sent =
field "Number of regular files transferred" stats
<|> field "Number of files transferred" stats
total = field "Total file size" stats
xfer = field "Total transferred file size" stats
den = (\x -> if x then 1000 else 1024) $ opts ^. optsSiUnits
log' $
label
<> ": "
<> purple
(opts ^. optsNoColor)
(humanReadable den (fromMaybe 0 xfer))
<> cyan
(opts ^. optsNoColor)
(" in " <> commaSep (fromIntegral (fromMaybe 0 sent)))
<> " ("
<> humanReadable den (fromMaybe 0 total)
<> " in "
<> commaSep (fromIntegral (fromMaybe 0 files))
<> ") "
<> green
(opts ^. optsNoColor)
("[" <> tshow (round diff :: Int) <> "s]")
pure status
where
field :: Text -> M.Map Text Text -> Maybe Integer
field x = fmap (read . unpack) . M.lookup x
colored True _ s = s
colored False n s = "\ESC[" <> tshow (n :: Int) <> "m" <> s <> "\ESC[0m"
purple b = colored b 35
cyan b = colored b 36
green b = colored b 32
commaSep :: Int -> Text
commaSep =
fst
. T.foldr
( \x (xs, num :: Int) ->
if num /= 0 && num `mod` 3 == 0
then (x `T.cons` ',' `T.cons` xs, num + 1)
else (x `T.cons` xs, num + 1)
)
("", 0)
. tshow
humanReadable :: Integer -> Integer -> Text
humanReadable den x =
pack $
fromJust $
f 0 "b"
<|> f 1 "K"
<|> f 2 "M"
<|> f 3 "G"
<|> f 4 "T"
<|> f 5 "P"
<|> f 6 "X"
<|> Just (printf "%db" x)
where
f :: Integer -> String -> Maybe String
f n s
| x < (den ^ succ n) =
Just $
if n == 0
then printf ("%d" ++ s) x
else
printf
("%." ++ show (min 3 (pred n)) ++ "f" ++ s)
(fromIntegral x / (fromIntegral den ^ n :: Double))
f _ _ = Nothing
execute ::
Maybe Host ->
FilePath ->
[String] ->
App (ExitCode, NominalDiffTime, String)
execute mhost cmdName args = do
opts <- ask
let (name', args') = case mhost of
Nothing -> (cmdName, args)
Just h -> remote h (cmdName, args)
runner p xs =
liftIO $
timeFunction (readProcessWithExitCodeLenient p xs "")
debug' $ pack name' <> " " <> T.intercalate " " (map tshow args')
(diff, (ec, out, err)) <-
if opts ^. optsDryRun
then pure (0, (ExitSuccess, "", ""))
else runner name' args'
when (ec /= ExitSuccess) $ do
let errLines = lines err
numLines = length errLines
truncatedErr =
if numLines > 10
then
unlines (take 5 errLines)
<> "... ("
<> show (numLines - 10)
<> " more lines) ...\n"
<> unlines (drop (numLines - 5) errLines)
else err
-- Note: Using warn' instead of errorL' because we want to log the error
-- but not throw an exception - the transfer status is tracked separately
warn' $
"Command failed: "
<> pack cmdName
<> " "
<> pack (ppShow args)
<> ": "
<> pack truncatedErr
pure (ec, diff, out)
where
-- Use binary I/O and lenient UTF-8 decoding to handle arbitrary byte sequences
-- from rsync (e.g., filenames with non-UTF-8 characters)
readProcessWithExitCodeLenient ::
FilePath -> [String] -> String -> IO (ExitCode, String, String)
readProcessWithExitCodeLenient cmd cmdArgs stdin = do
let cp =
(proc cmd cmdArgs)
{ std_in = CreatePipe
, std_out = CreatePipe
, std_err = CreatePipe
}
(Just hIn, Just hOut, Just hErr, ph) <- createProcess cp
-- Set binary mode to avoid encoding issues
hSetBinaryMode hOut True
hSetBinaryMode hErr True
hSetBinaryMode hIn True
-- Write stdin and close
hPutStr hIn stdin
hClose hIn
-- Read stdout and stderr concurrently to avoid deadlock
outMVar <- newEmptyMVar
errMVar <- newEmptyMVar
_ <- forkIO $ do
outBytes <- BS.hGetContents hOut
let outStr = unpack $ TE.decodeUtf8With TEE.lenientDecode outBytes
putMVar outMVar outStr
_ <- forkIO $ do
errBytes <- BS.hGetContents hErr
let errStr = unpack $ TE.decodeUtf8With TEE.lenientDecode errBytes
putMVar errMVar errStr
-- Wait for both threads to finish
outStr <- takeMVar outMVar
errStr <- takeMVar errMVar
exitCode <- waitForProcess ph
pure (exitCode, outStr, errStr)
timeFunction :: IO a -> IO (NominalDiffTime, a)
timeFunction function = do
startTime <- getCurrentTime
a <- function
endTime <- getCurrentTime
pure (diffUTCTime endTime startTime, a)
remote :: Host -> (FilePath, [String]) -> (FilePath, [String])
remote host (p, xs) =
( "ssh"
, unpack (host ^. hostName) : p : xs
)
-- Utility functions
readYaml :: (FromJSON a) => FilePath -> IO a
readYaml p =
decodeFileEither p >>= \case
Left err -> errorL $ pack $ p <> ": " <> prettyPrintParseException err
Right d -> pure d
expandPath :: FilePath -> IO FilePath
expandPath ['~'] = getHomeDirectory
expandPath ('~' : '/' : p) = (</> p) <$> getHomeDirectory
expandPath p = pure p
expandFilesetPaths :: Fileset -> IO Fileset
expandFilesetPaths fs =
fs & filesetStores %%~ traverse (\(a, b) -> (,) <$> expandPath a <*> pure b)
directoryContents :: FilePath -> IO [FilePath]
directoryContents p = map (p </>) <$> listDirectory p
lsDirectory :: Maybe Host -> FilePath -> App [FilePath]
lsDirectory mhost path = do
(_ec, _secs, output) <- execute mhost "ls" ["-1ap", path]
pure $ lines output \\ ["./", "../"]
asDirectory :: FilePath -> FilePath
asDirectory fp
| "/" `isSuffixOf` fp = fp
| otherwise = fp <> "/"
collect :: (Ord b) => (a -> b) -> [a] -> Map b [a]
collect f =
foldl'
( \m x ->
m
& at (f x) %~ \case
Nothing -> Just [x]
Just xs -> Just (x : xs)
)
mempty
fstOf3 :: (a, b, c) -> a
fstOf3 (a, _, _) = a
matchText :: Text -> Text -> Bool
matchText = (=~) `on` unpack
tshow :: (Show a) => a -> Text
tshow = pack . show