packages feed

pushme-3.0.0: Main.hs

{-# 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/<name>.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