packages feed

pushme-1.4.0: Main.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

module Main where

import           Control.Concurrent.ParallelIO ( stopGlobalPool, parallel )
import           Control.DeepSeq ( NFData(..) )
import           Control.Exception ( SomeException, Exception, catch )
import           Control.Lens
import           Control.Monad ( void, liftM2, (>=>) )
import           Data.Aeson ( ToJSON(..), FromJSON(..) )
import           Data.Aeson.TH ( deriveJSON )
import qualified Data.ByteString.Char8 as BC ( writeFile, readFile )
import           Data.Char ( isDigit )
import           Data.Data ( Data )
import           Data.Foldable ( for_ )
import           Data.Function ( on )
import           Data.Function.Pointless ( (.:) )
import           Data.List
import qualified Data.Map as M ( Map, fromList, lookup )
import           Data.Maybe ( fromMaybe, isNothing, isJust, fromJust )
import           Data.Monoid ( Monoid(mconcat), (<>) )
import qualified Data.Text.Format
#if MIN_VERSION_shelly(1, 0, 0)
import           Data.Text (Text)
import qualified Data.Text as T
import           Data.Text.Lazy (toStrict)
#else
import           Data.Text.Lazy (Text)
import qualified Data.Text.Lazy as T
#endif
import           Data.Time.Clock ( getCurrentTime )
import           Data.Time.Format ( readTime, formatTime )
import           Data.Time.LocalTime
import           Data.Typeable ( Typeable )
import           Data.Yaml ( encode, decode )
import           Filesystem ( isFile, getHomeDirectory )
import           GHC.Conc ( setNumCapabilities, getNumProcessors )
import           Options.Applicative
import           Prelude hiding (FilePath, catch)
import           Shelly hiding (find)
import           System.Environment ( getArgs, withArgs )
import           System.IO ( stderr )
import           System.IO.Storage ( withStore, putValue, getValue )
import           System.Locale ( defaultTimeLocale )
import           System.Log.Formatter ( tfLogFormatter )
import           System.Log.Handler ( setFormatter )
import           System.Log.Handler.Simple ( streamHandler )
import           System.Log.Logger
import           Text.Printf ( printf )
import           Text.Regex.Posix ( (=~) )

default (Integer, Text)

instance FromJSON LocalTime where
  parseJSON =
    parseJSON >=> return . readTime defaultTimeLocale "%Y%m%dT%H%M%S"

instance ToJSON LocalTime where
  toJSON = toJSON . formatTime defaultTimeLocale "%Y%m%dT%H%M%S"

instance FromJSON FilePath where
  parseJSON = parseJSON >=> return . fromText

instance ToJSON FilePath where
  toJSON = toJSON . toTextIgnore

format = (toStrict .) . Data.Text.Format.format

version :: String
version = "1.3.0"

copyright :: String
copyright = "2013"

pushmeSummary :: String
pushmeSummary = "pushme v" ++ version ++ ", (C) John Wiegley " ++ copyright

data PushmeOpts = PushmeOpts
    { jobs     :: Int
    , dryRun   :: Bool
    , noSync   :: Bool
    , copyAll  :: Bool
    , loadRevs :: Bool
    , stores   :: Bool
    , ssh      :: String
    , filesets :: String
    , classes  :: String
    , verbose  :: Bool
    , quiet    :: Bool
    , debug    :: Bool
    , cliArgs  :: [String]
    } deriving (Data, Typeable, Show, Eq)

pushmeOpts :: Parser PushmeOpts
pushmeOpts = PushmeOpts
    <$> option (short 'j' <> long "jobs" <> value 1 <>
                help "Run INT concurrent finds at once (default: 1)")
    <*> switch (short 'n' <> long "dry-run" <>
                help "Don't take any actions")
    <*> switch (short 'N' <> long "no-sync" <>
                help "Don't even attempt a dry-run sync")
    <*> switch (long "copy-all" <>
                help "For git-annex directories, copy all files")
    <*> switch (short 'L' <> long "load-revs" <>
                help "Load latest snapshot revs from disk")
    <*> switch (long "stores" <>
                help "Show all the stores know to pushme")
    <*> strOption (long "ssh" <> value "" <>
                   help "Use a specific ssh command")
    <*> strOption (short 'f' <> long "filesets" <> value "" <>
                   help "Synchronize the given fileset(s) (comma-sep)")
    <*> strOption (short 'c' <> long "classes" <> value "" <>
                   help "Filesets classes to synchronize (comma-sep)")
    <*> switch (short 'v' <> long "verbose" <> help "Report progress verbosely")
    <*> switch (short 'q' <> long "quiet" <> help "Be a little quieter")
    <*> switch (short 'D' <> long "debug" <> help "Report debug information")
    <*> arguments Just (metavar "ARGS")

-- | A 'Fileset' is a logical grouping of files, with an assigned class and
--   priority.  It may also have an associated filter.

data Fileset = Fileset
    { _filesetName          :: Text
    , _filesetClass         :: Text
    , _filesetPriority      :: Int
    , _filesetReportMissing :: Bool
    } deriving (Show, Eq)

makeLenses ''Fileset

$(deriveJSON (drop 8) ''Fileset)

-- | A 'Container' is a physical grouping of files, reflecting an instance of
--   a 'Fileset' somewhere on a storage medium.  For every 'Fileset', there
--   are many 'Container' instances.  When a fileset is synchronized, it means
--   copying it by some means between different containers.

data Container = Container
    { _containerFileset  :: Text
    , _containerStore    :: Text
    , _containerPath     :: FilePath
    , _containerPoolPath :: Maybe FilePath
    , _containerRecurse  :: Bool
    , _containerIsAnnex  :: Bool
    , _containerLastRev  :: Maybe Int
    , _containerLastSync :: Maybe LocalTime
    } deriving (Show, Eq)

makeLenses ''Container

$(deriveJSON (drop 10) ''Container)

instance NFData Container where
  rnf a = a `seq` ()

-- | A 'Store' is a repository that holds 'Container' instances in some form
--   or another.  Synchronization of a remote ZFS container from a local ZFS
--   container is done by copying over incremental snapshots.  Note that
--   'storeHostName' refers to name used by SSH.

data Store = Store
    { _storeName       :: Text
    , _storeHostRe     :: Text
    , _storeSelfRe     :: Text
    , _storeUserName   :: Text
    , _storeIsPrimary  :: Bool
    , _storeZfsPool    :: Maybe Text
    , _storeZfsPath    :: Maybe FilePath
    -- 'storeTargets' is a list of (StoreName, [FilesetName]), where the
    -- containers involved are looked up from the Container list by the
    -- Store/Fileset name pair for both source and target.
    , _storeTargets    :: [(Text, [Text])]
    , _storeAnnexName  :: Text
    , _storeAnnexFlags :: [(Text, [(Text, [Text])])]
    } deriving (Show, Eq)

makeLenses ''Store

$(deriveJSON (drop 6) ''Store)

instance NFData Store where
  rnf a = a `seq` ()

data Info = Info
    { _infoHostName  :: Text
    , _infoStore     :: Store
    , _infoContainer :: Container
    } deriving (Show, Eq)

makeLenses ''Info

data Binding = Binding
    { _bindingThis    :: Info
    , _bindingThat    :: Info
    , _bindingFileset :: Fileset
    } deriving (Show, Eq)

makeLenses ''Binding

main :: IO ()
main = execParser opts >>= \o -> do
    _ <- GHC.Conc.setNumCapabilities (jobs o)
    runPushme o
    stopGlobalPool
  where
    opts = info (helper <*> pushmeOpts)
                (fullDesc <> progDesc "" <> header pushmeSummary)

runPushme :: PushmeOpts -> IO ()
runPushme opts = do
  let level | debug opts   = DEBUG
            | verbose opts = INFO
            | otherwise    = NOTICE
  h <- (`setFormatter` tfLogFormatter "%H:%M:%S" "$time - [$prio] $msg")
       <$> streamHandler System.IO.stderr level
  removeAllHandlers
  updateGlobalLogger "pushme" (setLevel level)
  updateGlobalLogger "pushme" (addHandler h)

  withStore "main" $ do
    putValue "main" "opts" opts

    when (dryRun opts || noSync opts) $
      warningM "pushme" "`--dryrun' specified, no changes will be made!"

    sts   <- readDataFile ".pushme/stores.yml"     :: IO [Store]
    fsets <- readDataFile ".pushme/filesets.yml"   :: IO [Fileset]
    cts   <- readDataFile ".pushme/containers.yml" :: IO [Container]

    pushmeCommand opts sts fsets cts

pushmeCommand :: PushmeOpts -> [Store] -> [Fileset] -> [Container] -> IO ()
pushmeCommand opts sts fsets cts
  | stores opts =
    let fss    = T.pack $ filesets opts
        cls    = T.pack $ classes opts
        sorted =
          sortBy (compare `on` (^._2.filesetPriority)) $
          filter
            (\(_, fs) ->
                (T.null fss || matchText fss (fs^.filesetName))
              && (T.null cls || matchText cls (fs^.filesetClass))) $
          map (\x -> (x, findFileset (x^.containerFileset) fsets)) $
          case cliArgs opts of
            [] -> cts
            xs -> nub $ mconcat $
                 map (\n -> containersForStore (findStore (T.pack n) sts)
                                              cts) xs

    in for_ sorted $ \(ct, _) ->
         putStrLn $
           printf "%-12s %-12s %-28.38s   %19s %5d"
                  (T.unpack (ct^.containerStore))
                  (T.unpack (ct^.containerFileset))
                  (T.unpack (toTextIgnore (ct^.containerPath)))
                  (maybe "" (formatTime defaultTimeLocale "%Y/%m/%d %H:%M:%S")
                         (ct^.containerLastSync))
                  (fromMaybe 0 (ct^.containerLastRev))

  | otherwise = do
    here <- shelly $ silently $ cmd "hostname"
    let this = findStore here sts

    shelly $ debugL $ here <> " -> " <> T.pack (show this)

    cts' <-
      foldl'
        (\acc host -> do
            innerCts   <- acc
            updatedCts <- shelly $
              processHost (here,this) sts fsets innerCts (T.pack host)
            -- This can get expensive fast, but the number of containers
            -- involved should never be large.  The idea is to gather the most
            -- recent data from all updated containers, until we're back to a
            -- fully up-to-date master list.  We can't do it "as we go"
            -- because of the parallel processing.
            return $
              map (\ct -> foldl' mergeContainers ct updatedCts) innerCts)
        (return cts) (cliArgs opts)

    unless (dryRun opts || noSync opts) $
      writeDataFile ".pushme/containers.yml" cts'

processHost :: (Text, Store) -> [Store] -> [Fileset] -> [Container] -> Text
            -> Sh [Container]
processHost defaultSelf@(here, _) sts fsets conts thereRaw
  | T.head thereRaw == '@' = do
    let there = T.drop 1 thereRaw
        that  = findStore there sts
    bindings <-
      traverse (\x -> createBinding defaultSelf (there,that) fsets conts
                                   (x^.containerFileset))
               (containersForStore that conts)
    sorted   <- filterAndSortBindings bindings
    mconcat <$>
      (sequence $ flip map sorted $ \bnd -> do
          let info = bnd^.bindingThat
          createSnapshot (storeIsLocal here (info^.infoStore)) info)

  | otherwise = do
    let (self@(_, this), there) =
          if "/" `T.isInfixOf` thereRaw
          then let [b, e] = T.splitOn "/" thereRaw
               in ((b, findStore b sts), e)
          else (defaultSelf, thereRaw)
        that = findStore there sts

    debugL $ (this^.storeHostRe) <> " -> " <> T.pack (show this)
    debugL $ there <> " -> " <> T.pack (show that)

    noticeL $ format "\ESC[31mSynchronizing {} -> {}\ESC[0m"
                     [this^.storeName, that^.storeName]

    xs <- case lookup (that^.storeName) (this^.storeTargets) of
      Nothing -> warningL "Nothing to do" >> return []

      Just xs -> do
        bindings <- traverse (createBinding self (there,that) fsets conts) xs
                    >>= filterAndSortBindings
        for_ bindings $ \bnd ->
          when (bnd^.bindingFileset.filesetReportMissing) $
            reportMissingFiles (bnd^.bindingFileset.filesetName)
                               (bnd^.bindingThis.infoContainer)
        return . mconcat =<<
          (liftIO . parallel . map (shelly . syncContainers) $ bindings)

    noticeL $ format "\ESC[32mDone synchronizing {} -> {}\ESC[0m"
                     [this^.storeName , that^.storeName]
    return xs

  where
    filterAndSortBindings bindings = do
      fss <- T.pack <$> getOption filesets
      cls <- T.pack <$> getOption classes
      return
        $ sortBy (compare `on` (^.bindingFileset.filesetPriority))
        $ filter
          (\x -> (T.null fss ||
                  matchText fss (x^.bindingFileset.filesetName))
                 && (T.null cls ||
                     matchText cls (x^.bindingFileset.filesetClass)))
          bindings

zfsPoolPath :: Info -> FilePath
zfsPoolPath info =
      fromMaybe (error $ "storeZfsPool is Nothing: " ++ show info)
                (info^.infoStore.storeZfsPool)
  </> fromMaybe (error $ "containerPoolPath is Nothing: " ++ show info)
                (info^.infoContainer.containerPoolPath)

zfsFilePath :: Info -> IO FilePath
zfsFilePath info = do
  Pathname p <-
    getPathname $
          fromMaybe (error $ "storeZfsPath is Nothing: " ++ show info)
                    (info^.infoStore.storeZfsPath)
      </> fromMaybe (error $ "containerPoolPath is Nothing: " ++ show info)
                    (info^.infoContainer.containerPoolPath)
  return p

getZfsPath :: Info -> Bool -> IO ContainerPath
getZfsPath info bothZfs
  | bothZfs    = return $ ZfsFilesystem $ toTextIgnore $ zfsPoolPath info
  | isZfs info = Pathname <$> zfsFilePath info
  | otherwise  = getPathname (info^.infoContainer.containerPath)

createSnapshot :: Bool -> Info -> Sh [Container]
createSnapshot local info
  | isZfs info = do
    let cont         = info^.infoContainer
        lastRev      = cont^.containerLastRev
        nextRev      = case lastRev of Nothing -> 1; Just x -> succ x
        poolName     = zfsPoolPath info
        thatSnapshot = toTextIgnore poolName <> "@" <> intToText nextRev
        snapCmd      = ["zfs", "snapshot"]
                    <> ["-r" | cont^.containerRecurse]
                    <> [thatSnapshot]

    noticeL $ format "Creating snapshot {}" [thatSnapshot]

    if local
      then
        vrun_ "sudo" snapCmd
      else do
        let u = info^.infoStore.storeUserName
            h = info^.infoHostName
        remote vrun_ u h snapCmd

    return [containerLastRev .~ Just nextRev $ cont]

  | otherwise = return []

reportMissingFiles :: Text -> Container -> Sh ()
reportMissingFiles label cont = do
  (Pathname contPath) <- liftIO $ getPathname (cont^.containerPath)

  files    <- map (T.drop (T.length (toTextIgnore contPath))
                     . toTextIgnore)
              <$> ls contPath
  optsFile <- liftIO $ getHomePath (".pushme/filters/"
                                    <> cont^.containerFileset)
  exists   <- liftIO $ isFile optsFile

  when exists $ do
    optsText <- readfile optsFile
    let stringify    = (\x -> if T.head x == '/' then T.tail x else x)
                       . (\x -> if T.index x (T.length x - 1) == '/'
                                then T.init x else x)
                       . T.drop 2
        regexToGlob  = T.replace "].*" "]*"
                       . T.replace "*" ".*"
                       . T.replace "?" "."
                       . T.replace "." "\\."
        patterns     = map regexToGlob
                       . filter (`notElem` ["*", "*/", ".*", ".*/"])
                       . map stringify . T.lines $ optsText
        patMatch f p = T.unpack f =~ T.unpack p
        files'       =
          foldl' (\acc x ->
                     if any (patMatch x) patterns
                     then acc
                     else x:acc) []
                 (filter (`notElem` [ ".DS_Store", ".localized" ]) files)

    for_ files' $ \f ->
      warningL $ format "{}: unknown: \"{}\"" [label, f]

createBinding :: (Text, Store) -> (Text, Store) -> [Fileset] -> [Container]
              -> Text -> Sh Binding
createBinding (here,this) (there,that) fsets conts n = do
  let bnd =
        case find (\x -> x^.filesetName == n) fsets of
          Nothing ->
            error $ T.unpack  $ "Could not find fileset: " <> n
          Just fs ->
            Binding {
                _bindingThis =
                   Info { _infoHostName  = here
                        , _infoStore     = this
                        , _infoContainer = findContainer fs this conts }
              , _bindingThat =
                   Info { _infoHostName  = there
                        , _infoStore     = that
                        , _infoContainer = findContainer fs that conts }
              , _bindingFileset = fs }
  load <- getOption loadRevs
  if load
    then do
      let l = infoContainer.containerLastRev -- this one is a mutator
          m = infoContainer.containerLastRev -- this one is an accessor
      thisContRev <- if isZfs (bnd^.bindingThis)
                    then containerRev (bnd^.bindingThis)
                    else return $ bnd^.bindingThis.m
      thatContRev <- if isZfs (bnd^.bindingThat)
                    then containerRev (bnd^.bindingThat)
                    else return $ bnd^.bindingThat.m
      return $ bindingThis.l .~ thisContRev
             $ bindingThat.l .~ thatContRev $ bnd
    else return bnd

  where
    containerRev info = silently $ do
      p <- liftIO $ zfsFilePath info
      let p' = toTextIgnore $ p </> fromText ".zfs" </> fromText "snapshot"
          u  = info^.infoStore.storeUserName
          h  = info^.infoHostName
      listing <- sort <$> map (read . T.unpack) <$>
                filter (T.all isDigit) <$> T.lines <$>
                if storeIsLocal here (info^.infoStore)
                then vrun "ls" ["-1", p']
                else remote vrun u h ["ls", "-1", p']
      if null listing
        then return Nothing
        else return . Just . last $ listing

containersForStore :: Store -> [Container] -> [Container]
containersForStore store =
  filter (\x ->
             let stName = x^.containerStore
                 names  = if "," `T.isInfixOf` stName
                          then T.splitOn "," stName
                          else [stName]
             in (store^.storeName) `elem` names)

findContainer :: Fileset -> Store -> [Container] -> Container
findContainer fileset store conts =
  fromMaybe
    (error $ T.unpack $
     format "Could not find container for Store {} + Fileset {}"
     [ store^.storeName, fileset^.filesetName ]) $
    find (\x -> (x^.containerFileset) == (fileset^.filesetName))
           (containersForStore store conts)

mergeContainers :: Container -> Container -> Container
x `mergeContainers` y
  |   (x^.containerFileset)  == (y^.containerFileset)
    && (x^.containerStore)    == (y^.containerStore)
    && (x^.containerPath)     == (y^.containerPath)
    && (x^.containerPoolPath) == (y^.containerPoolPath) =
    containerLastRev .~
      (if isNothing (y^.containerLastRev)
       then x^.containerLastRev
       else if   isNothing (x^.containerLastRev)
               || fromJust (x^.containerLastRev) <
                 fromJust (y^.containerLastRev)
            then y^.containerLastRev
            else x^.containerLastRev) $
    containerLastSync .~
      (if isNothing (y^.containerLastSync)
       then x^.containerLastSync
       else if   isNothing (x^.containerLastSync)
               || fromJust (x^.containerLastSync) <
                 fromJust (y^.containerLastSync)
            then y^.containerLastSync
            else x^.containerLastSync) $ x
  | otherwise = x

updateContainers :: Maybe Int -> Binding -> Sh [Container]
updateContainers Nothing bnd = do
  now <- liftIO currentLocalTime
  let update = containerLastSync .~ Just now
  return [ update (bnd^.bindingThis.infoContainer)
         , update (bnd^.bindingThat.infoContainer) ]

updateContainers rev bnd = do
  now <- liftIO currentLocalTime
  let update = containerLastSync .~ Just now
  return [ update (bnd^.bindingThis.infoContainer)
         , containerLastRev .~ rev $
           update (bnd^.bindingThat.infoContainer) ]

findFileset :: Text -> [Fileset] -> Fileset
findFileset n fsets =
  fromMaybe
    (error $ T.unpack $ "Could not find fileset matching name " <> n) $
    find (\x -> n == x^.filesetName) fsets

findStore :: Text -> [Store] -> Store
findStore n sts =
  fromMaybe
    (error $ T.unpack $ "Could not find store matching hostname " <> n) $
    find (\x -> matchText (x^.storeHostRe) n) sts

storeIsLocal :: Text -> Store -> Bool
storeIsLocal host st = matchText (st^.storeSelfRe) host

isZfs :: Info -> Bool
isZfs info = isJust (info^.infoStore.storeZfsPool)
           && isJust (info^.infoContainer.containerPoolPath)

bothSidesZfs :: Info -> Info -> Bool
bothSidesZfs = (&&) `on` isZfs

data ContainerPath = NoPath
                   | Pathname FilePath
                   | ZfsFilesystem Text
                   | ZfsSnapshot Text Int
                   | ZfsSnapshotRange Text Int Int
                   deriving (Show, Eq)

data FullPath = LocalPath ContainerPath
              | RemotePath Text Text ContainerPath
              deriving (Show, Eq)

convertPath :: FilePath -> String
convertPath = T.unpack . toTextIgnore

getHomePath :: Text -> IO FilePath
getHomePath p = (</> fromText p) <$> getHomeDirectory

getPathname :: FilePath -> IO ContainerPath
getPathname fp = do
  let text = toTextIgnore fp
  p <- if T.head text == '~'
       then toTextIgnore <$> getHomePath (T.drop 2 text)
       else return text
  return $ Pathname $ fromText $ if T.last p /= '/'
                                 then T.append p "/"
                                 else p

sourcePath :: Binding -> IO FullPath
sourcePath bnd
  | bothSidesZfs this that =
    let poolPath = toTextIgnore $ zfsPoolPath this
    in case (thisCont^.containerLastRev, thatCont^.containerLastRev) of
         (Just thisRev, Just thatRev) ->
           return $ LocalPath $ if thisRev > thatRev
                                then ZfsSnapshotRange poolPath thatRev thisRev
                                else NoPath
         (Just thisRev, Nothing) ->
           return $ LocalPath $ ZfsSnapshot poolPath thisRev
         (Nothing, _) ->
           return $ LocalPath $ ZfsFilesystem poolPath

  | isZfs this =
    LocalPath <$> (Pathname <$> zfsFilePath this)

  | otherwise =
    LocalPath <$> getPathname (bnd^.bindingThis.infoContainer.containerPath)

  where this      = bnd^.bindingThis
        that      = bnd^.bindingThat
        thisCont  = this^.infoContainer
        thatCont  = that^.infoContainer

destinationPath :: Binding -> IO FullPath
destinationPath bnd
  | storeIsLocal (this^.infoHostName) (that^.infoStore) =
    LocalPath <$> getZfsPath that (bothSidesZfs this that)

  | otherwise = do
    fullpath <- getZfsPath that (bothSidesZfs this that)
    return $
      RemotePath (that^.infoStore.storeUserName)
                 (that^.infoHostName) fullpath

  where this = bnd^.bindingThis
        that = bnd^.bindingThat

syncContainers :: Binding -> Sh [Container]
syncContainers bnd = errExit False $ do
  verb  <- getOption verbose

  noticeL $ format "Sending {}/{} -> {}"
                   [ bnd^.bindingThis.infoStore.storeName
                   , bnd^.bindingFileset.filesetName
                   , bnd^.bindingThat.infoStore.storeName ]

  (sendCmd, recvCmd, updater) <- createSyncCommands bnd

  sendArgs <- sendCmd
  unless (null sendArgs) $ do
    noticeL $ T.intercalate " " sendArgs
    recvArgs <- recvCmd
    if null recvArgs
      then vrun_ (fromText (head sendArgs)) (tail sendArgs)
      else escaping False $
           vrun_ (fromText (head sendArgs)) $ tail sendArgs <> ["|"] <> recvArgs
  updater

createSyncCommands :: Binding -> Sh (Sh [Text], Sh [Text], Sh [Container])
createSyncCommands bnd = do
  src   <- liftIO $ sourcePath bnd
  dst   <- liftIO $ destinationPath bnd
  verb  <- getOption verbose
  deb   <- getOption debug
  cpAll <- getOption copyAll

  let thisCont     = bnd^.bindingThis.infoContainer
      thatCont     = bnd^.bindingThat.infoContainer
      recurseThis  = thisCont^.containerRecurse
      recurseThat  = thatCont^.containerRecurse

      defaultFlags = [ "--not", "--in"
                     , bnd^.bindingThat.infoStore.storeAnnexName ]
      storeAFlags  = lookup (bnd^.bindingThat.infoStore.storeName)
                            (bnd^.bindingThis.infoStore.storeAnnexFlags)
      annexFlags   =
          flip (maybe defaultFlags) storeAFlags $ \flags ->
              fromMaybe (fromMaybe defaultFlags $ lookup "<default>" flags)
                  $ lookup (bnd^.bindingFileset.filesetName) flags

      annexCmds isRemote path = do
          vrun_ "git-annex" $ ["-q" | not verb && not deb] <> ["add", "."]
          vrun_ "git-annex" $ ["-q" | not verb && not deb] <> ["sync"]
          vrun_ "git-annex" $ ["-q" | not verb && not deb]
                <> [ "--auto"
                   | not ((bnd^.bindingThat.infoStore.storeIsPrimary)
                          || cpAll) ]
                <> [ "copy" ]
                <> annexFlags
                <> [ "--to", bnd^.bindingThat.infoStore.storeAnnexName ]

          if isRemote
              then sub $ do
              let u = bnd^.bindingThat.infoStore.storeUserName
                  h = bnd^.bindingThat.infoHostName
              remote vrun_ u h
                  [ T.concat $
                    [ "\"cd '", toTextIgnore path, "'; git-annex" ]
                    <> [" -q" | not verb && not deb]
                    <> [" sync", "\""] ]

              else sub $ do
                   cd path
                   vrun_ "git-annex" $ ["-q" | not verb && not deb]
                                    <> ["sync"]

          noticeL $ format "{}: Git Annex synchronized"
                           [ bnd^.bindingFileset.filesetName ]

  case (src, dst) of
    (LocalPath NoPath, _) ->
      return ( infoL "No sync needed" >> return []
             , return []
             , return [] )

    (LocalPath (Pathname l), LocalPath (Pathname r)) ->
      if thisCont^.containerIsAnnex && thatCont^.containerIsAnnex
      then
      return ( (if verb then id else silently) $ chdir l $
                 annexCmds False r >> return []
             , return []
             , updateContainers Nothing bnd )
      else
      return ( systemVolCopy
                 ((bnd^.bindingThat.infoStore.storeUserName) /= "root")
                 (bnd^.bindingFileset.filesetName) l (toTextIgnore r)
             , return []
             , updateContainers Nothing bnd )

    (LocalPath (Pathname l), RemotePath u h (Pathname r)) ->
      if thisCont^.containerIsAnnex && thatCont^.containerIsAnnex
      then
      return ( escaping False $ (if verb then id else silently) $
               chdir l $ annexCmds True r >> return []
             , return []
             , updateContainers Nothing bnd )
      else
      return ( systemVolCopy
                 ((bnd^.bindingThat.infoStore.storeUserName) /= "root")
                 (bnd^.bindingFileset.filesetName) l
                 (format "{}@{}:{}" [u, h, escape (toTextIgnore r)])
             , return []
             , updateContainers Nothing bnd )

    (LocalPath (ZfsFilesystem l),
     LocalPath (ZfsFilesystem r)) ->
      return ( localSend l recurseThis
             , localReceive r recurseThat
             , updateContainers (thisCont^.containerLastRev) bnd )

    (LocalPath (ZfsFilesystem l),
     RemotePath u h (ZfsFilesystem r)) ->
      return ( localSend l recurseThis
             , remoteReceive u h r recurseThat
             , updateContainers (thisCont^.containerLastRev) bnd )

    (LocalPath (ZfsSnapshot l e),
     LocalPath (ZfsFilesystem r)) ->
      return ( localSendRev l recurseThis e
             , localReceive r recurseThat
             , updateContainers (Just e) bnd )

    (LocalPath (ZfsSnapshot l e),
     RemotePath u h (ZfsFilesystem r)) ->
      return ( localSendRev l recurseThis e
             , remoteReceive u h r recurseThat
             , updateContainers (Just e) bnd )

    (LocalPath (ZfsSnapshotRange l b e),
     LocalPath (ZfsFilesystem r)) ->
      return ( localSendTwoRevs l recurseThis b e
             , localReceive r recurseThat
             , updateContainers (Just e) bnd )

    (LocalPath (ZfsSnapshotRange l b e),
     RemotePath u h (ZfsFilesystem r)) ->
      return ( localSendTwoRevs l recurseThis b e
             , remoteReceive u h r recurseThat
             , updateContainers (Just e) bnd )

    (l, r) -> error $ T.unpack $
         format "Unexpected paths {} and {}"
                [ T.pack (show l), T.pack (show r) ]

  where
    escape x = if "\"" `T.isInfixOf` x || " " `T.isInfixOf` x
               then "'" <> T.replace "\"" "\\\"" x <> "'"
               else x

    localSend pool recurse =
      return $ ["sudo", "zfs", "send"]
               <> ["-R" | recurse]
               <> [pool]

    localSendRev pool recurse r1 = do
      verb <- getOption verbose
      return $ ["sudo", "zfs", "send"]
               <> ["-R" | recurse]
               <> ["-v" | verb]
               <> [ pool <> "@" <> intToText r1 ]

    localSendTwoRevs pool recurse r1 r2 = do
      verb <- getOption verbose
      return $ ["sudo", "zfs", "send"]
               <> ["-R" | recurse]
               <> ["-v" | verb]
               <> [ "-I"
                  , pool <> "@" <> intToText r1
                  , pool <> "@" <> intToText r2 ]

    localReceive pool recurse =
      return $ ["sudo", "zfs", "recv"]
               <> ["-d" | recurse]
               <> ["-F", pool]

    remoteReceive u h pool recurse =
      remote (\x xs -> return (toTextIgnore x:xs)) u h  $
             ["zfs", "recv"] <> ["-d" | recurse] <> ["-F", pool ]

systemVolCopy :: Bool -> Text -> FilePath -> Text -> Sh [Text]
systemVolCopy useSudo label src dest = do
  optsFile <- liftIO $ getHomePath (".pushme/filters/" <> label)
  exists   <- liftIO $ isFile optsFile
  let rsyncOptions = ["--include-from=" <> toTextIgnore optsFile | exists]
  volcopy label useSudo rsyncOptions (toTextIgnore src) dest

volcopy :: Text -> Bool -> [Text] -> Text -> Text -> Sh [Text]
volcopy label useSudo options src dest = do
  infoL $ format "{} -> {}" [src, dest]

  dry    <- getOption dryRun
  noSy   <- getOption noSync
  deb    <- getOption debug
  verb   <- getOption verbose
  sshCmd <- getOption ssh

  let shhh     = not deb && not verb
      toRemote = ":" `T.isInfixOf` dest
      options' =
        [ "-aHEy"               -- jww (2012-09-23): maybe -A too?
        -- , "--fileflags"
        , "--delete-after"
        , "--ignore-errors"
        , "--force-delete"

        , "--exclude=/.Caches/"
        , "--exclude=/.Spotlight-V100/"
        , "--exclude=/.TemporaryItems/"
        , "--exclude=/.Trash/"
        , "--exclude=/.Trashes/"
        , "--exclude=/.fseventsd/"
        , "--exclude=/.zfs/"
        , "--exclude=/Temporary Items/"
        , "--exclude=/Network Trash Folder/"

        , "--filter=-p .DS_Store"
        , "--filter=-p .localized"
        , "--filter=-p .AppleDouble/"
        , "--filter=-p .AppleDB/"
        , "--filter=-p .AppleDesktop/"
        , "--filter=-p .com.apple.timemachine.supported" ]

        <> (if not (null sshCmd)
            then ["--rsh", T.pack sshCmd]
            else [])
        <> ["-n" | dry]
        <> (if shhh
            then ["--stats"]
            else if verb
                 then ["-P"]
                 else if deb
                      then ["-v", "-v"]
                      else ["-v"])
        <> options
        <> [src, dest]

  rsync <- which "rsync"
  case rsync of
    Nothing -> error "Could not find rsync!"
    Just r  ->
      if shhh && not noSy
      then silently $ do
        output <- doCopy (drun False) r toRemote useSudo options'
        let stats = M.fromList $
                    map (liftM2 (,) head (head . T.words . last)
                           . T.splitOn ": ")
                    . filter (": " `T.isInfixOf`)
                    . T.lines $ output
            files = field "Number of files" stats
            sent  = field "Number of files transferred" stats
            total = field "Total file size" stats
            xfer  = field "Total transferred file size" stats
        noticeL $ format
            "{}: \ESC[34mSent \ESC[35m{}\ESC[0m\ESC[34m in {} files\ESC[0m (out of {} in {})"
            [ label
            , T.pack (humanReadable xfer),
              commaSep (fromIntegral sent)
            , T.pack (humanReadable total),
              commaSep (fromIntegral files) ]
      else
        doCopy (drun_ False) r toRemote useSudo options'

  return []

  where
    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)
                   . intToText

    field :: Text -> M.Map Text Text -> Integer
    field x stats = fromMaybe 0 $ read . T.unpack <$> M.lookup x stats

    doCopy f rsync False False os = f rsync os
    doCopy f rsync False True os  = sudo f rsync os
    doCopy f rsync True False os  = f rsync (remoteRsync False rsync:os)
    doCopy f rsync True True os   = asRoot f rsync (remoteRsync True rsync:os)

    remoteRsync useSudo' x = if useSudo'
                             then format "--rsync-path=sudo {}" [toTextIgnore x]
                             else format "--rsync-path={}" [toTextIgnore x]

readDataFile :: FromJSON a => Text -> IO [a]
readDataFile p = do
  p' <- getHomePath p
  d  <- Data.Yaml.decode <$> BC.readFile (convertPath p')
  case d of
    Nothing -> error $ T.unpack $ "Failed to read file " <> p
    Just d' -> return d'

writeDataFile :: ToJSON a => Text -> a -> IO ()
writeDataFile p xs =
  getHomePath p >>= flip BC.writeFile (Data.Yaml.encode xs) . convertPath

currentLocalTime :: IO LocalTime
currentLocalTime = do
  tz <- getCurrentTimeZone
  tm <- getCurrentTime
  return $ utcToLocalTime tz tm

getOption' :: Data a => (a -> b) -> IO b
getOption' option = do
  opts <- getValue "main" "opts"
  return $ fromJust $ option <$> opts

getOption :: Data a => (a -> b) -> Sh b
getOption = liftIO . getOption'

matchText :: Text -> Text -> Bool
matchText = flip ((=~) `on` T.unpack)

intToText :: Int -> Text
intToText = T.pack . show

remote :: (FilePath -> [Text] -> Sh a) -> Text -> Text -> [Text] -> Sh a
remote f user host xs = do
  sshCmd <- getOption ssh
  p <- if null sshCmd
      then do sshPath <- which "ssh"
              return (toTextIgnore <$> sshPath)
      else return . Just . T.pack $ sshCmd
  case p of
    Nothing -> error "Could not find ssh!"
    Just r  -> let ys = T.words r <> [format "{}@{}" [user, host]] <> xs
               in f (fromText (head ys)) (tail ys)

asRoot :: (FilePath -> [Text] -> Sh a) -> FilePath -> [Text] -> Sh a
asRoot f p xs =
  f "sudo" [ "su", "-", "root", "-c"
           , T.unwords (map (\x -> "\"" <> x <> "\"") xs') ]
  where xs' = toTextIgnore p:xs

sudo :: (FilePath -> [Text] -> Sh a) -> FilePath -> [Text] -> Sh a
sudo f p xs = f "sudo" (toTextIgnore p:xs)

doRun :: (FilePath -> [Text] -> Sh a)
      -> (Text -> Sh ())
      -> Sh a -> Bool -> Bool -> FilePath -> [Text]
      -> Sh a
doRun f g retval doLog heedDry n xs = do
  when doLog $
    g $ format "{} {}" [toTextIgnore n, T.unwords xs]

  dry   <- getOption dryRun
  drier <- getOption noSync
  if drier || (dry && heedDry)
    then retval
    else f n xs

drun :: Bool -> FilePath -> [Text] -> Sh Text
drun = doRun run debugL (return "") True

drun_ :: Bool -> FilePath -> [Text] -> Sh ()
drun_ x = void .: doRun run_ debugL (return ()) True x

vrun :: FilePath -> [Text] -> Sh Text
vrun = doRun run infoL (return "") True True

vrun_ :: FilePath -> [Text] -> Sh ()
vrun_ = void .: doRun run_ infoL (return ()) True True

srun :: FilePath -> [Text] -> Sh Text
srun = doRun run infoL (return "") False True

srun_ :: FilePath -> [Text] -> Sh ()
srun_ = void .: doRun run_ infoL (return ()) False True

debugL :: Text -> Sh ()
debugL = liftIO . debugM "pushme" . T.unpack

infoL :: Text -> Sh ()
infoL = liftIO . infoM "pushme" . T.unpack

noticeL :: Text -> Sh ()
noticeL = liftIO . noticeM "pushme" . T.unpack

warningL :: Text -> Sh ()
warningL = liftIO . warningM "pushme" . T.unpack

errorL :: Text -> Sh ()
errorL = liftIO . errorM "pushme" . T.unpack

criticalL :: Text -> Sh ()
criticalL = liftIO . criticalM "pushme" . T.unpack

humanReadable :: Integer -> String
humanReadable x
  | x < 1024   = printf "%db" x
  | x < (1024^(2 :: Integer)) =
    printf "%.0fK" (fromIntegral x / (1024 :: Double))
  | x < (1024^(3 :: Integer)) =
    printf "%.1fM" (fromIntegral x / (1024^(2 :: Integer) :: Double))
  | x < (1024^(4 :: Integer)) =
    printf "%.2fG" (fromIntegral x / (1024^(3 :: Integer) :: Double))
  | x < (1024^(5 :: Integer)) =
    printf "%.3fT" (fromIntegral x / (1024^(4 :: Integer) :: Double))
  | x < (1024^(6 :: Integer)) =
    printf "%.3fP" (fromIntegral x / (1024^(5 :: Integer) :: Double))
  | x < (1024^(7 :: Integer)) =
    printf "%.3fX" (fromIntegral x / (1024^(6 :: Integer) :: Double))
  | otherwise  = printf "%db" x

-- Main.hs (pushme) ends here