{-# LANGUAGE OverloadedStrings #-} -- | A module for easily creating reliable deploy processes for Haskell -- applications. module System.Hapistrano ( Config(..) , activateRelease , currentPath , defaultSuccessHandler , defaultErrorHandler , directoryExists , isReleaseString , pathToRelease , pushRelease , readCurrentLink , restartServerCommand , rollback , runRC , runBuild ) where import Control.Monad.Reader (ReaderT(..), ask) import System.Hapistrano.Types (Config(..), FailureResult, Hapistrano, Release, ReleaseFormat(..)) import Control.Monad (unless, void) import System.Exit (ExitCode(..), exitWith) import Control.Monad.IO.Class (MonadIO(liftIO)) import Control.Monad.Trans.Either ( left , right , eitherT ) import Data.Char (isNumber) import Data.List (intercalate, sortBy, isInfixOf) import Data.Time (getCurrentTime) import Data.Time.Format (formatTime) import Data.Time.Locale.Compat (defaultTimeLocale) import System.FilePath.Posix (joinPath, splitPath) import System.IO (hPutStrLn, stderr) import System.Process (readProcessWithExitCode) -- | Does basic project setup for a project, including making sure -- some directories exist, and pushing a new release directory with the -- SHA1 or branch specified in the configuration. pushRelease :: Hapistrano Release pushRelease = setupDirs >> ensureRepositoryPushed >> updateCacheRepo >> cleanReleases >> cloneToRelease >>= setReleaseRevision -- | Switches the current symlink to point to the release specified in -- the configuration. Maybe used in either deploy or rollback cases. activateRelease :: Release -> Hapistrano String activateRelease rel = removeCurrentSymlink >> symlinkCurrent rel -- | Runs the deploy, along with an optional success or failure function. runRC :: ((Int, String) -> ReaderT Config IO a) -- ^ Error handler -> (a -> ReaderT Config IO a) -- ^ Success handler -> Config -- ^ Hapistrano deployment configuration -> Hapistrano a -- ^ The remote command to run -> IO a runRC errorHandler successHandler config command = runReaderT (eitherT errorHandler successHandler command) config -- | Default method to run on deploy failure. Emits a failure message -- and exits with a status code of 1. defaultErrorHandler :: FailureResult -> ReaderT Config IO () defaultErrorHandler res = liftIO $ hPutStrLn stderr ("Deploy failed with (status, message): " ++ show res) >> exitWith (ExitFailure 1) -- | Default method to run on deploy success. defaultSuccessHandler :: a -> ReaderT Config IO () defaultSuccessHandler _ = liftIO $ putStrLn "Deploy completed successfully." -- | Creates necessary directories for the hapistrano project. Should -- only need to run the first time the project is deployed on a given -- system. setupDirs :: Hapistrano () setupDirs = do conf <- ask mapM_ (runCommand (host conf)) ["mkdir -p " ++ releasesPath conf, "mkdir -p " ++ cacheRepoPath conf] directoryExists :: Maybe String -> FilePath -> IO Bool directoryExists hst path = do let (command, args) = case hst of Just h -> ("ssh", [h, "ls", path]) Nothing -> ("ls", [path]) (code, _, _) <- readProcessWithExitCode command args "" return $ case code of ExitSuccess -> True ExitFailure _ -> False -- | Runs the given command either locally or on the local machine. runCommand :: Maybe String -- ^ The host on which to run the command -> String -- ^ The command to run, either on the local or remote host -> Hapistrano String runCommand Nothing command = execCommand command runCommand (Just server) command = execCommand $ unwords ["ssh", server, command] execCommand :: String -> Hapistrano String execCommand cmd = do let wds = words cmd (cmd', args) = (head wds, tail wds) liftIO $ putStrLn $ "Executing: " ++ cmd (code, stdout, err) <- liftIO $ readProcessWithExitCode cmd' args "" case code of ExitSuccess -> do unless (null stdout) (liftIO $ putStrLn $ "Output: " ++ stdout) right $ trim stdout ExitFailure int -> left (int, trim err) -- | Returns a timestamp in the default format for build directories. currentTimestamp :: ReleaseFormat -> IO String currentTimestamp format = do curTime <- getCurrentTime return $ formatTime defaultTimeLocale fstring curTime where fstring = case format of Short -> "%Y%m%d%H%M%S" Long -> "%Y%m%d%H%M%S%q" -- | Returns the FilePath pointed to by the current symlink. readCurrentLink :: Hapistrano FilePath -- ^ The target of the symlink in the Hapistrano monad readCurrentLink = do conf <- ask runCommand (host conf) $ "readlink " ++ currentPath (deployPath conf) -- ^ Trims any newlines from the given String trim :: String -- ^ String to have trailing newlines stripped -> String -- ^ String with trailing newlines removed trim = reverse . dropWhile (== '\n') . reverse -- | Ensure that the initial bare repo exists in the repo directory. Idempotent. ensureRepositoryPushed :: Hapistrano String ensureRepositoryPushed = do conf <- ask res <- liftIO $ directoryExists (host conf) $ joinPath [cacheRepoPath conf, "refs"] if res then right "Repo already existed" else createCacheRepo -- | Returns the full path of the folder containing all of the release builds. releasesPath :: Config -> FilePath releasesPath conf = joinPath [deployPath conf, "releases"] -- | Figures out the most recent release if possible. detectPrevious :: [String] -- ^ The releases in `releases` path -> Hapistrano String -- ^ The previous release in the Hapistrano monad detectPrevious rs = case biggest rs of Nothing -> left (1, "No previous releases detected!") Just rls -> right rls -- | Activates the previous detected release. rollback :: Hapistrano String -- ^ The current Release in the Hapistrano monad rollback = previousReleases >>= detectPrevious >>= activateRelease -- | Clones the repository to the next releasePath timestamp. Makes a new -- timestamp if one doesn't yet exist in the HapistranoState. Returns the -- timestamp of the release that we cloned to. cloneToRelease :: Hapistrano Release -- ^ The newly-cloned Release, in the Hapistrano monad cloneToRelease = do conf <- ask rls <- liftIO $ currentTimestamp (releaseFormat conf) void $ runCommand (host conf) $ "git clone " ++ cacheRepoPath conf ++ " " ++ joinPath [ releasesPath conf, rls ] return rls -- | Returns the full path to the git repo used for cache purposes on the -- target host filesystem. cacheRepoPath :: Config -- ^ The Hapistrano configuration -> FilePath -- ^ The full path to the git cache repo used for speeding up deploys cacheRepoPath conf = joinPath [deployPath conf, "repo"] -- | Returns the full path to the current symlink. currentPath :: FilePath -- ^ The full path of the deploy folder root -> FilePath -- ^ The full path to the `current` symlink currentPath depPath = joinPath [depPath, "current"] -- | Take the release timestamp from the end of a filepath. pathToRelease :: FilePath -- ^ The entire FilePath to a Release directory -> Release -- ^ The Release number. pathToRelease = last . splitPath -- | Returns a list of Strings representing the currently deployed releases. releases :: Hapistrano [Release] -- ^ A list of all found Releases on the target host releases = do conf <- ask res <- runCommand (host conf) $ "find " ++ releasesPath conf ++ " -type d -maxdepth 1" right $ filter (isReleaseString (releaseFormat conf)) . map pathToRelease $ lines res previousReleases :: Hapistrano [Release] -- ^ All non-current releases on the target host previousReleases = do rls <- releases currentRelease <- readCurrentLink let currentRel = (head . lines . pathToRelease) currentRelease return $ filter (< currentRel) rls releasePath :: Config -> Release -> FilePath releasePath conf rls = joinPath [releasesPath conf, rls] -- | Given a list of release strings, takes the last four in the sequence. -- Assumes a list of folders that has been determined to be a proper release -- path. oldReleases :: Config -> [Release] -> [FilePath] oldReleases conf rs = map mergePath toDelete where sorted = sortBy (flip compare) rs toDelete = drop 4 sorted mergePath = releasePath conf -- | Removes releases older than the last five to avoid filling up the target -- host filesystem. cleanReleases :: Hapistrano [String] -- ^ Deleted Release directories cleanReleases = do conf <- ask allReleases <- releases let deletable = oldReleases conf allReleases if null deletable then do liftIO $ putStrLn "There are no old releases to prune." return [] else do _ <- runCommand (host conf) $ "rm -rf -- " ++ unwords deletable return deletable -- | Returns a Bool indicating if the given String is in the proper release -- format. isReleaseString :: ReleaseFormat -- ^ Format of Release directories -> String -- ^ String to check against Release format -> Bool -- ^ Whether the given String adheres to the specified Release format isReleaseString format s = all isNumber s && length s == releaseLength where releaseLength = case format of Short -> 14 Long -> 26 -- | Creates the git repository that is used on the target host for -- cache purposes. createCacheRepo :: Hapistrano String -- ^ Output of the git command used to create the bare cache repo createCacheRepo = do conf <- ask runCommand (host conf) $ "git clone --bare " ++ repository conf ++ " " ++ cacheRepoPath conf -- | Returns the full path of the symlink pointing to the current -- release. currentSymlinkPath :: Config -> FilePath currentSymlinkPath conf = joinPath [deployPath conf, "current"] currentTempSymlinkPath :: Config -> FilePath currentTempSymlinkPath conf = joinPath [deployPath conf, "current_tmp"] -- | Removes the current symlink in preparation for a new release being -- activated. removeCurrentSymlink :: Hapistrano () removeCurrentSymlink = do conf <- ask void $ runCommand (host conf) $ "rm -rf " ++ currentSymlinkPath conf -- | Determines whether the target host OS is Linux targetIsLinux :: Hapistrano Bool targetIsLinux = do conf <- ask res <- runCommand (host conf) "uname" right $ "Linux" `isInfixOf` res -- | Runs a command to restart a server if a command is provided. restartServerCommand :: Hapistrano String restartServerCommand = do conf <- ask case restartCommand conf of Nothing -> return "No command given for restart action." Just cmd -> runCommand (host conf) cmd -- | Runs a build script if one is provided. runBuild :: Release -> Hapistrano Release runBuild rel = do conf <- ask case buildScript conf of Nothing -> liftIO $ putStrLn "No build script specified, skipping build step." Just scr -> do fl <- liftIO $ readFile scr buildRelease rel $ lines fl right rel -- | Returns the best 'mv' command for a symlink given the target platform. mvCommand :: Bool -- ^ Whether the target host is Linux -> String -- ^ The best mv command for a symlink on the platform mvCommand True = "mv -Tf" mvCommand False = "mv -f" -- | Creates a symlink to the current release. lnCommand :: String -- ^ The path of the new release -> String -- ^ The temporary symlink target for the release -> String -- ^ A command to create the temporary symlink lnCommand rlsPath symlinkPath = unwords ["ln -s", rlsPath, symlinkPath] -- | Creates a symlink to the directory indicated by the release timestamp. -- hapistrano does this by creating a temporary symlink and doing an atomic -- mv (1) operation to activate the new release. symlinkCurrent :: Release -> Hapistrano String symlinkCurrent rel = do conf <- ask isLnx <- targetIsLinux let tmpLnCmd = lnCommand (releasePath conf rel) (currentTempSymlinkPath conf) _ <- runCommand (host conf) tmpLnCmd runCommand (host conf) $ unwords [ mvCommand isLnx , currentTempSymlinkPath conf , currentSymlinkPath conf ] -- | Updates the git repo used as a cache in the target host filesystem. updateCacheRepo :: Hapistrano () updateCacheRepo = do conf <- ask void $ runCommand (host conf) $ intercalate " && " [ "cd " ++ cacheRepoPath conf , "git fetch origin +refs/heads/*:refs/heads/*" ] -- | Sets the release to the correct revision by resetting the -- head of the git repo. setReleaseRevision :: Release -> Hapistrano Release setReleaseRevision rel = do conf <- ask liftIO $ putStrLn "Setting revision in release path." void $ runCommand (host conf) $ intercalate " && " [ "cd " ++ releasePath conf rel , "git fetch --all" , "git reset --hard " ++ revision conf ] return rel -- | Returns a command that builds this application. Sets the context -- of the build by switching to the release directory before running -- the script. buildRelease :: Release -- ^ The Release to build -> [String] -- ^ Commands to be run. List intercalated -- with "&&" so that failure aborts the -- sequence. -> Hapistrano () buildRelease rel commands = do conf <- ask let cdCmd = "cd " ++ releasePath conf rel void $ runCommand (host conf) $ intercalate " && " $ cdCmd : commands -- | A safe version of the `maximum` function in Data.List. biggest :: Ord a => [a] -> Maybe a biggest rls = case sortBy (flip compare) rls of [] -> Nothing r:_ -> Just r