packages feed

handsy-0.0.13.1: src/System/Handsy/Remote.hs

{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}

module System.Handsy.Remote where

import           Prelude                hiding (appendFile, readFile, writeFile)

import           Control.Applicative
import           Control.Concurrent
import           Control.Error
import           Control.Monad
import           Control.Retry
import qualified Data.ByteString.Lazy   as B
import           Data.Default.Class
import           Data.Monoid
import           System.Exit
import           System.Handsy.Actions
import           System.Handsy.Internal
import           System.Handsy.Local

type Host = String
data SSHOptions =
  SSHOptions {
    -- | Path of `ssh` command
    sshPath        :: FilePath,

    -- | User
    sshUser        :: Maybe String,

    -- | Port to connect
    sshPort        :: Int,

    -- | Identity file to use
    identityFile   :: Maybe FilePath,

    -- | Connect timeout
    connectTimeout :: Int,

    {-| Whether to use control master for SSH connections.
        This significantly reduces execution time.
    -}
    controlMaster  :: Bool
  }

instance Default SSHOptions where
  def = SSHOptions "ssh" Nothing 22 Nothing 30 True

acquireCM :: Host -> SSHOptions -> Script FilePath
acquireCM host opts = do
  cm <- scriptIO (run def $ head . strLines . fst <$> command_ "mktemp" ["-u", "--suffix=.handsy"] def) >>= hoistEither

  let (ssh, params) = genSsh opts (Just cm)
  _ <- scriptIO . forkIO . void . run def . void $ command_ ssh (params ++ ["-M", "-N", host]) def
  scriptIO (waitForCM cm) >>= bool (left "Error establishing ControlMaster connection") (right ())

  return cm
  where
    checkCM :: FilePath -> IO Bool
    checkCM p = fmap (either (const False) id) . run def $ do
      let args = snd (genSsh opts Nothing) ++ ["-o", "ControlPath=" ++ p, "-O", "check"]
      command (sshPath opts) args def >>= return . \case
        (ExitSuccess, _, _) -> True
        _                   -> False
    waitForCM p = retrying (limitRetries 30) (\_ n -> return (not n)) (checkCM p)


releaseCM :: FilePath -> Script ()
releaseCM p = (scriptIO . run def{debug=False} $ void $ command_ "rm" ["-f", p] def) >>= hoistEither

genSsh :: SSHOptions -> Maybe FilePath -> (FilePath, [String])
genSsh opts cm = (sshPath opts,
                  [ "-p", show $ sshPort opts
                  , "-o", "ConnectTimeout=" ++ show (connectTimeout opts)]
                  <> maybe mempty (("-l":) . pure) (sshUser opts)
                  <> maybe mempty (("-i":) . pure) (identityFile opts)
                  <> maybe mempty (("-S":) . pure) cm
                 )

runSsh :: Host -> SSHOptions -> Maybe FilePath -> String -> B.ByteString
       -> Script (ExitCode, B.ByteString, B.ByteString)
runSsh host opts cm cmdline stdin' =
  let (ssh, params) = genSsh opts cm
  in  runS def{debug=False} $ command ssh (params ++ [host] ++ [cmdline]) def{stdin=stdin'}

-- | Executes the actions at a remote host
runRemote :: Options -> Host -> SSHOptions -> Handsy a -> IO (Either String a)
runRemote opts host sshOpts = runEitherT .
  case controlMaster sshOpts of
    False -> interpretSimple (runSsh host sshOpts Nothing) opts
    True -> interpret (acquireCM host sshOpts)
                       releaseCM
                       (runSsh host sshOpts . Just)
                       opts

-- | Copies a local file to remote host
pushFile :: FilePath -- ^ Local path of source
         -> FilePath -- ^ Remote path of destination
         -> Handsy ()
pushFile local remote = handsyIO (B.readFile local) >>= writeFile remote

-- | Fetches a file from remote host
pullFile :: FilePath -- ^ Remote path of source
         -> FilePath -- ^ Local path of destination
         -> Handsy ()
pullFile remote local = readFile remote >>= handsyIO . B.writeFile local