{-# 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.ParallelIO (parallel_, stopGlobalPool) import Control.Concurrent.QSem (newQSem, signalQSem, waitQSem) import Control.Exception (bracket_, 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 Data.Function (on) import Data.List (foldl', 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.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) import System.IO.Temp (withSystemTempFile) import System.Process hiding (env) import Text.Printf (printf) import Text.Regex.Posix ((=~)) import Text.Show.Pretty (ppShow) 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 -> (path,) <$> ( RsyncOptions <$> parseM (m ^. at "Filters") <*> (fromMaybe False <$> parseM (m ^. at "NoBasicOptions")) <*> (fromMaybe False <$> parseM (m ^. at "NoDelete")) <*> (fromMaybe False <$> parseM (m ^. at "PreserveAttrs")) <*> (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 data Binding = Binding { _bindingFileset :: Fileset, _bindingSourceHost :: Host, _bindingSourcePath :: FilePath, _bindingTargetHost :: Host, _bindingTargetPath :: FilePath, _bindingRsyncOpts :: RsyncOptions } deriving (Show, Eq) makeLenses ''Binding isLocal :: Binding -> Bool isLocal bnd = bnd ^. bindingSourceHost == bnd ^. bindingTargetHost remoteHost :: Binding -> Maybe Host remoteHost bnd | isLocal bnd = Nothing | otherwise = Just (bnd ^. bindingTargetHost) type App a = ReaderT Options IO a main :: IO () main = withStdoutLogging do cmdLineOpts <- getOptions configOpts <- readYaml =<< expandPath (cmdLineOpts ^. optsConfigDir "config.yaml") let opts = 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 let here = parseHost (pack host) bindings = relevantBindings opts here fsets (map (parseHost . pack) hosts) debug' $ "Local host " <> here ^. hostName <> " with " <> tshow (here ^. hostMaxJobs) <> " sending jobs" hereSlots <- newQSem (here ^. hostMaxJobs) thereSlotsAll <- forM (M.keys bindings) $ \there -> do debug' $ "Remote host " <> there ^. hostName <> " with " <> tshow (there ^. hostMaxJobs) <> " receiving jobs" newQSem (there ^. hostMaxJobs) 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 parallel_ (map (go opts p q) bnds) -- After all bindings for this host are done, print completion message when (not (null bnds)) $ do let msg = there ^. hostName <> " done" log' $ if opts ^. optsNoColor then msg else "\ESC[32m" <> msg <> "\ESC[0m" go opts p q bnd = bracket_ (waitQSem p) (signalQSem p) $ bracket_ (waitQSem q) (signalQSem q) $ runReaderT (applyBinding bnd) opts applyBinding :: Binding -> App () applyBinding bnd = do log' $ "Sending " <> (bnd ^. bindingFileset . filesetName) <> " → " <> (bnd ^. bindingTargetHost . hostName) debug' $ pack (ppShow bnd) syncStores bnd (bnd ^. bindingSourcePath) (bnd ^. bindingTargetPath) (bnd ^. bindingRsyncOpts) relevantBindings :: Options -> Host -> Map Text Fileset -> [Host] -> Map Host [Binding] relevantBindings opts here fsets hosts = M.map (sortOn (^. bindingFileset . filesetPriority)) (collect (^. bindingTargetHost) bindings) where bindings :: [Binding] bindings = do fset <- M.elems fsets there <- hosts maybeToList do (src, _) <- fset ^. filesetStores . at (here ^. hostName) (dest, destOpts) <- fset ^. filesetStores . at (there ^. hostName) guard $ not ( maybe False (not . (here ^. hostName `elem`)) (destOpts ^. rsyncReceiveFrom) ) let binding = Binding { _bindingFileset = fset, _bindingSourceHost = here, _bindingSourcePath = src, _bindingTargetHost = there, _bindingTargetPath = dest, _bindingRsyncOpts = case opts ^. optsRsyncOpts <> fset ^. filesetCommon of Nothing -> destOpts Just common -> common <> destOpts } guard $ isMatching binding guard $ destOpts ^. rsyncActive pure binding isMatching :: Binding -> Bool isMatching bnd = (null fss || any id (matchText (fs ^. filesetName) <$> fss)) && (null cls || any id (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/.yaml" directoryContents confD >>= mapM readYaml . filter (\n -> takeExtension n == ".yaml") <&> M.fromList . map ((^. filesetName) &&& id) 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 () syncStores bnd src dest roDest = do exists <- (&&) <$> checkDirectory bnd l False <*> checkDirectory bnd r True if exists then invokeRsync bnd l roDest (remoteHost bnd) r else liftIO do warn $ "Either local directory missing: " <> pack l warn $ "OR remote directory missing: " <> pack r where (asDirectory -> l) = src (asDirectory -> r) = dest invokeRsync :: Binding -> FilePath -> RsyncOptions -> Maybe Host -> FilePath -> App () invokeRsync bnd src roDest host dest = do opts <- ask withProtected $ \args1 -> withFilters "Filters" (roDest ^. rsyncFilters) $ \args2 -> doRsync ( bnd ^. bindingTargetHost . hostName <> "/" <> 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)] <> ["--delete" | not (roDest ^. rsyncNoDelete)] <> ["-AXUNHE" | roDest ^. rsyncPreserveAttrs] <> ["-n" | opts ^. optsDryRun] <> ( if opts ^. optsVerbose then ["-v"] else ["--stats"] ) <> args <> fromMaybe [] (roDest ^. rsyncOptions) <> [ pack src, case host ^? _Just . hostName of Nothing -> pack dest Just h -> h <> ":" <> T.intercalate "\\ " (T.words (pack dest)) ] doRsync :: Text -> [Text] -> App () doRsync label args = do opts <- ask (ec, diff, output) <- execute Nothing "rsync" (map unpack args) 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]") 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 (readProcessWithExitCode 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' unless (ec == ExitSuccess) $ errorL $ "Error running command: " <> pack cmdName <> " " <> pack (ppShow args) <> ": " <> pack err pure (ec, diff, out) where 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