Refactoring and test additions

This commit is contained in:
Justin Leitgeb 2015-03-11 16:02:52 -05:00
parent d531e5dfd7
commit 2653d984bb
5 changed files with 425 additions and 147 deletions

View File

@ -1,9 +1,15 @@
name: hapistrano name: hapistrano
version: 0.1.0.2 version: 0.1.0.2
synopsis: A deployment library for Haskell applications synopsis: A deployment library for Haskell applications
description: Hapistrano makes it easy to reliably deploy Haskell description:
applications. .
Hapistrano makes it easy to reliably deploy Haskell applications.
.
Following popular libraries like Ruby's <http://capistranorb.com/ Capistrano>,
Hapistrano simply creates a new sandbox, installs dependencies, builds, and
then symlinks to the new build directory when installation is done.
.
This process makes deploy atomic, without allowing for dependency problems.
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Justin Leitgeb author: Justin Leitgeb
@ -22,28 +28,52 @@ executable hap
, process , process
, either , either
, transformers , transformers
, lens >= 4.1 , mtl
, filepath , filepath
, either
, base-compat , base-compat
default-language: Haskell2010 default-language: Haskell2010
ghc-options: -Wall
library library
exposed-modules: Hapistrano exposed-modules: System.Hapistrano
other-modules: System.Hapistrano.Types
build-depends: base >=4.5 && <4.8 build-depends: base >=4.5 && <4.8
, time , time
, old-locale , old-locale
, process , process
, either , either
, transformers , transformers
, lens >= 4.1 , mtl
, filepath , filepath
, either
, base-compat , base-compat
hs-source-dirs: src hs-source-dirs: src
default-language: Haskell2010 default-language: Haskell2010
ghc-options: -Wall
test-suite hapistrano-test
type: exitcode-stdio-1.0
hs-source-dirs: spec, src
main-is: Spec.hs
build-depends: base >=4.5 && <4.8
, time
, old-locale
, process
, either
, transformers
, mtl
, filepath
, base-compat
, hspec
, temporary
, directory
default-language: Haskell2010
ghc-options: -Wall
source-repository head source-repository head
type: git type: git

1
spec/Spec.hs Normal file
View File

@ -0,0 +1 @@
{-# OPTIONS_GHC -F -pgmF hspec-discover #-}

View File

@ -0,0 +1,141 @@
module System.HapistranoSpec (spec) where
import Test.Hspec (it, describe, shouldBe, Spec)
import System.IO.Temp (withSystemTempDirectory)
import System.Directory (getDirectoryContents)
import Control.Monad (void, replicateM_)
import System.FilePath.Posix (joinPath)
import qualified System.Hapistrano as Hap
import System.Hapistrano.Types
import Data.List (sort)
rollback :: HapistranoState -> IO ()
rollback cfg =
Hap.runRC errorHandler successHandler cfg $ do
_ <- Hap.rollback
void Hap.restartServerCommand
where
errorHandler = Hap.defaultErrorHandler
successHandler = Hap.defaultSuccessHandler
-- | Deploys the current release with Config options.
deployOnly :: HapistranoState -> IO ()
deployOnly cfg =
Hap.runRC errorHandler successHandler cfg $ void Hap.pushRelease
where
errorHandler = Hap.defaultErrorHandler
successHandler = Hap.defaultSuccessHandler
-- | Deploys the current release with Config options.
deployAndActivate :: HapistranoState -> IO ()
deployAndActivate cfg =
Hap.runRC errorHandler successHandler cfg $ do
_ <- Hap.pushRelease
_ <- Hap.runBuild
void Hap.activateRelease
where
errorHandler = Hap.defaultErrorHandler
successHandler = Hap.defaultSuccessHandler
defaultState :: FilePath -> HapistranoState
defaultState tmpDir = Hap.initialState
Hap.Config { Hap.deployPath = tmpDir
, Hap.host = Nothing
, Hap.repository =
"https://github.com/stackbuilders/atomic-write.git"
, Hap.releaseFormat = Long
, Hap.revision = "master"
, Hap.buildScript = Nothing
, Hap.restartCommand = Nothing
}
spec :: Spec
spec = describe "hapistrano" $ do
describe "readCurrentLink" $
it "trims trailing whitespace" $
withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
deployAndActivate (defaultState tmpDir)
ltarget <- Hap.readCurrentLink Nothing (Hap.currentPath tmpDir)
last ltarget /= '\n' `shouldBe` True
describe "deploying" $ do
it "a simple deploy" $
withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
deployOnly (defaultState tmpDir)
contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
length (filter (Hap.isReleaseString Long) contents) `shouldBe` 1
it "activates the release" $
withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
deployAndActivate (defaultState tmpDir)
contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
length (filter (Hap.isReleaseString Long) contents) `shouldBe` 1
it "cleans up old releases" $
withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
replicateM_ 7 $ deployAndActivate (defaultState tmpDir)
contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
length (filter (Hap.isReleaseString Long) contents) `shouldBe` 5
describe "rollback" $
it "rolls back to the previous release" $
withSystemTempDirectory "hapistranoDeployTest" $ \tmpDir -> do
let deployState = defaultState tmpDir
deployAndActivate deployState
-- current symlink should point to the last release directory
contents <- getDirectoryContents (joinPath [tmpDir, "releases"])
let firstRelease = head $ filter (Hap.isReleaseString Long) contents
firstReleaseLinkTarget <-
Hap.readCurrentLink Nothing (Hap.currentPath tmpDir)
putStrLn $ "the first: " ++ show firstReleaseLinkTarget
firstRelease `shouldBe` Hap.pathToRelease firstReleaseLinkTarget
-- deploy a second version
deployAndActivate deployState
-- current symlink should point to second release
conts <- getDirectoryContents (joinPath [tmpDir, "releases"])
let secondRelease =
sort (filter (Hap.isReleaseString Long) conts) !! 1
secondReleaseLinkTarget <-
Hap.readCurrentLink Nothing (Hap.currentPath tmpDir)
secondRelease `shouldBe` Hap.pathToRelease secondReleaseLinkTarget
-- roll back, and current symlink should point to first release again
rollback deployState
afterRollbackLinkTarget <-
Hap.readCurrentLink Nothing (Hap.currentPath tmpDir)
Hap.pathToRelease afterRollbackLinkTarget `shouldBe` firstRelease

View File

@ -1,70 +1,55 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
-- | A module for easily creating reliable deploy processes for Haskell -- | A module for easily creating reliable deploy processes for Haskell
-- applications. -- applications.
module Hapistrano module System.Hapistrano
( ( Config(..)
Config(..)
, initialState
, runRC
, activateRelease , activateRelease
, runBuild , currentPath
, defaultSuccessHandler , defaultSuccessHandler
, defaultErrorHandler , defaultErrorHandler
, directoryExists
, initialState
, isReleaseString
, pathToRelease
, pushRelease , pushRelease
, readCurrentLink
, restartServerCommand , restartServerCommand
, rollback , rollback
, runRC
, runBuild
) where ) where
import Control.Lens (makeLenses, use, (^.), (.=)) import System.Hapistrano.Types (
import Control.Monad (unless) Config(..), HapistranoState(..), RC, Release, ReleaseFormat(..))
import Control.Monad (unless, void)
import System.Exit (ExitCode(..), exitWith) import System.Exit (ExitCode(..), exitWith)
import Control.Monad.State.Lazy (gets, put)
import Control.Monad.IO.Class (MonadIO(liftIO)) import Control.Monad.IO.Class (MonadIO(liftIO))
import Control.Monad.Trans.State (StateT, evalStateT, get) import Control.Monad.Trans.State (evalStateT, get)
import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Either ( EitherT(..) import Control.Monad.Trans.Either ( left
, left
, right , right
, runEitherT
, eitherT ) , eitherT )
import Data.Char (isNumber) import Data.Char (isNumber)
import Data.List (intercalate, sortBy, sort, isInfixOf) import Data.List (intercalate, sortBy, isInfixOf)
import Data.Time (getCurrentTime) import Data.Time (getCurrentTime)
import Data.Time.Format (formatTime) import Data.Time.Format (formatTime)
import System.Exit (ExitCode(..))
import System.FilePath.Posix (joinPath, splitPath) import System.FilePath.Posix (joinPath, splitPath)
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
import System.Locale (defaultTimeLocale) import System.Locale (defaultTimeLocale)
import System.Process (readProcessWithExitCode) import System.Process (readProcessWithExitCode)
-- | Config stuff that will be replaced by config file reading
data Config = Config { _deployPath :: String
, _host :: String
, _repository :: String -- ^ The remote git repo
, _revision :: String -- ^ A SHA1 or branch to release
, _buildScript :: Maybe FilePath
, _restartCommand :: Maybe String
} deriving (Show)
makeLenses ''Config
data HapistranoState = HapistranoState { _config :: Config
, _timestamp :: Maybe String
}
makeLenses ''HapistranoState
type Release = String
type RC a = StateT HapistranoState (EitherT (Int, Maybe String) IO) a
-- | Does basic project setup for a project, including making sure -- | Does basic project setup for a project, including making sure
-- some directories exist, and pushing a new release directory with the -- some directories exist, and pushing a new release directory with the
-- SHA1 or branch specified in the configuration. -- SHA1 or branch specified in the configuration.
pushRelease :: RC (Maybe String) pushRelease :: RC ()
pushRelease = setupDirs >> ensureRepositoryPushed >> updateCacheRepo >> pushRelease = setupDirs >> ensureRepositoryPushed >> updateCacheRepo >>
cleanReleases >> cloneToRelease >> setReleaseRevision cleanReleases >> cloneToRelease >> setReleaseRevision
@ -73,11 +58,10 @@ pushRelease = setupDirs >> ensureRepositoryPushed >> updateCacheRepo >>
activateRelease :: RC (Maybe String) activateRelease :: RC (Maybe String)
activateRelease = removeCurrentSymlink >> symlinkCurrent activateRelease = removeCurrentSymlink >> symlinkCurrent
-- | Returns an initial state for the deploy. -- | Returns an initial state for the deploy.
initialState :: Config -> HapistranoState initialState :: Config -> HapistranoState
initialState cfg = HapistranoState { _config = cfg initialState cfg = HapistranoState { config = cfg
, _timestamp = Nothing , timestamp = Nothing
} }
-- | Given a pair of actions, one to perform in case of failure, and -- | Given a pair of actions, one to perform in case of failure, and
@ -93,10 +77,13 @@ runRC errorHandler successHandler initState remoteCmd =
successHandler successHandler
(evalStateT remoteCmd initState) (evalStateT remoteCmd initState)
-- | Default method to run on deploy failure. Emits a failure message
-- and exits with a status code of 1.
defaultErrorHandler :: (Int, Maybe String) -> IO () defaultErrorHandler :: (Int, Maybe String) -> IO ()
defaultErrorHandler _ = defaultErrorHandler _ =
hPutStrLn stderr "Deploy failed." >> exitWith (ExitFailure 1) hPutStrLn stderr "Deploy failed." >> exitWith (ExitFailure 1)
-- | Default method to run on deploy success.
defaultSuccessHandler :: a -> IO () defaultSuccessHandler :: a -> IO ()
defaultSuccessHandler _ = putStrLn "Deploy completed successfully." defaultSuccessHandler _ = putStrLn "Deploy completed successfully."
@ -104,19 +91,52 @@ defaultSuccessHandler _ = putStrLn "Deploy completed successfully."
-- | Creates necessary directories for the hapistrano project. Should -- | Creates necessary directories for the hapistrano project. Should
-- only need to run the first time the project is deployed on a given -- only need to run the first time the project is deployed on a given
-- system. -- system.
setupDirs :: RC (Maybe String) setupDirs :: RC ()
setupDirs = do setupDirs = do
conf <- use config conf <- gets config
remoteCommand $ intercalate " && " mapM_ (runCommand (host conf))
[ "mkdir -p " ++ releasesPath conf ["mkdir -p " ++ releasesPath conf, "mkdir -p " ++ cacheRepoPath conf]
, "mkdir -p " ++ cacheRepoPath conf
]
remoteCommand :: String -- ^ The command to run remotely directoryExists :: Maybe String -> FilePath -> IO Bool
-> RC (Maybe String) directoryExists hst path = do
remoteCommand command = do let (command, args) = case hst of
server <- use $ config . host 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
-> RC (Maybe String)
runCommand Nothing command = do
liftIO $ putStrLn $ "Going to execute " ++ command ++ " locally."
let (cmd, args) = (head (words command), tail (words command))
(code, stdout, err) <- liftIO $ readProcessWithExitCode cmd args ""
case code of
ExitSuccess -> do
liftIO $
putStrLn $ "Command '" ++ command ++ "' was successful on local host."
unless (null stdout) (liftIO $ putStrLn $ "Output:\n" ++ stdout)
lift $ right $ maybeString stdout
ExitFailure int -> do
let maybeError = maybeString err
liftIO $ printCommandError "localhost" command (int, maybeError)
lift $ left (int, maybeError)
runCommand (Just server) command = do
liftIO $ putStrLn $ "Going to execute " ++ command ++ " on host " ++ server liftIO $ putStrLn $ "Going to execute " ++ command ++ " on host " ++ server
++ "." ++ "."
@ -137,11 +157,16 @@ remoteCommand command = do
liftIO $ printCommandError server command (int, maybeError) liftIO $ printCommandError server command (int, maybeError)
lift $ left (int, maybeError) lift $ left (int, maybeError)
-- | Returns a timestamp in the default format for build directories. -- | Returns a timestamp in the default format for build directories.
currentTimestamp :: IO String currentTimestamp :: ReleaseFormat -> IO String
currentTimestamp = do currentTimestamp format = do
curTime <- getCurrentTime curTime <- getCurrentTime
return $ formatTime defaultTimeLocale "%Y%m%d%H%M%S" curTime 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"
echoMessage :: String -> RC (Maybe String) echoMessage :: String -> RC (Maybe String)
echoMessage msg = do echoMessage msg = do
@ -157,25 +182,30 @@ printCommandError server cmd (errCode, Just errMsg) =
server ++ "' with error code " ++ show errCode ++ " and message '" ++ server ++ "' with error code " ++ show errCode ++ " and message '" ++
errMsg ++ "'." errMsg ++ "'."
directoryExists :: FilePath -> RC (Maybe String)
directoryExists path =
remoteCommand $ "ls " ++ path
-- | Returns the FilePath pointed to by the current symlink. -- | Returns the FilePath pointed to by the current symlink.
readCurrentLink :: RC (Maybe FilePath) readCurrentLink :: Maybe String -> FilePath -> IO FilePath
readCurrentLink = do readCurrentLink hst path = do
conf <- use config let (command, args) = case hst of
remoteCommand $ "readlink " ++ currentPath conf Just h -> ("ssh", [h, "readlink", path])
Nothing -> ("readlink", [path])
(code, stdout, _) <- readProcessWithExitCode command args ""
case (code, stdout) of
(ExitSuccess, out) -> return $ trim out
(ExitFailure _, _) -> error "Unable to read current symlink"
where trim = reverse . dropWhile (=='\n') . reverse
-- | Ensure that the initial bare repo exists in the repo directory. Idempotent. -- | Ensure that the initial bare repo exists in the repo directory. Idempotent.
ensureRepositoryPushed :: RC (Maybe String) ensureRepositoryPushed :: RC (Maybe String)
ensureRepositoryPushed = do ensureRepositoryPushed = do
conf <- use config conf <- gets config
res <- directoryExists $ cacheRepoPath conf res <- liftIO $ directoryExists (host conf) $ joinPath [cacheRepoPath conf, "refs"]
case res of if res
Nothing -> createCacheRepo then lift $ right $ Just "Repo already existed"
Just _ -> lift $ right $ Just "Repo already existed" else createCacheRepo
-- | Returns a Just String or Nothing based on whether the input is null or -- | Returns a Just String or Nothing based on whether the input is null or
-- has contents. -- has contents.
@ -185,7 +215,7 @@ maybeString possibleString =
-- | Returns the full path of the folder containing all of the release builds. -- | Returns the full path of the folder containing all of the release builds.
releasesPath :: Config -> FilePath releasesPath :: Config -> FilePath
releasesPath conf = joinPath [conf ^. deployPath, "releases"] releasesPath conf = joinPath [deployPath conf, "releases"]
-- | Figures out the most recent release if possible, and sets the -- | Figures out the most recent release if possible, and sets the
-- StateT monad with the correct timestamp. This function is used -- StateT monad with the correct timestamp. This function is used
@ -193,10 +223,12 @@ releasesPath conf = joinPath [conf ^. deployPath, "releases"]
detectPrevious :: [String] -> RC (Maybe String) detectPrevious :: [String] -> RC (Maybe String)
detectPrevious rs = do detectPrevious rs = do
let mostRecentRls = biggest rs let mostRecentRls = biggest rs
case mostRecentRls of case mostRecentRls of
Nothing -> lift $ left (1, Just "No previous releases detected!") Nothing -> lift $ left (1, Just "No previous releases detected!")
Just rls -> do Just rls -> do
timestamp .= mostRecentRls conf <- get
_ <- put $ conf { timestamp = mostRecentRls }
lift $ right $ Just rls lift $ right $ Just rls
-- | Activates the previous detected release. -- | Activates the previous detected release.
@ -207,29 +239,29 @@ rollback = previousReleases >>= detectPrevious >> activateRelease
-- timestamp if one doesn't yet exist in the HapistranoState. -- timestamp if one doesn't yet exist in the HapistranoState.
cloneToRelease :: RC (Maybe String) cloneToRelease :: RC (Maybe String)
cloneToRelease = do cloneToRelease = do
conf <- use config hs <- get
releaseTimestamp <- use timestamp conf <- gets config
rls <- case releaseTimestamp of rls <- case timestamp hs of
Nothing -> do Nothing -> do
ts <- liftIO currentTimestamp ts <- liftIO $ currentTimestamp (releaseFormat conf)
timestamp .= Just ts _ <- put $ hs { timestamp = Just ts }
return ts return ts
Just r -> return r Just r -> return r
remoteCommand $ "git clone " ++ cacheRepoPath conf ++ " " ++ runCommand (host conf) $ "git clone " ++ cacheRepoPath (config hs) ++
joinPath [ releasesPath conf, rls ] " " ++ joinPath [ releasesPath (config hs), rls ]
-- | Returns the full path to the git repo used for cache purposes on the -- | Returns the full path to the git repo used for cache purposes on the
-- target host filesystem. -- target host filesystem.
cacheRepoPath :: Config -> FilePath cacheRepoPath :: Config -> FilePath
cacheRepoPath conf = joinPath [conf ^. deployPath, "repo"] cacheRepoPath conf = joinPath [deployPath conf, "repo"]
-- | Returns the full path to the current symlink. -- | Returns the full path to the current symlink.
currentPath :: Config -> FilePath currentPath :: FilePath -> FilePath
currentPath conf = joinPath [conf ^. deployPath, "current"] currentPath depPath = joinPath [depPath, "current"]
-- | Take the release timestamp from the end of a filepath. -- | Take the release timestamp from the end of a filepath.
pathToRelease :: FilePath -> Release pathToRelease :: FilePath -> Release
@ -238,25 +270,27 @@ pathToRelease = last . splitPath
-- | Returns a list of Strings representing the currently deployed releases. -- | Returns a list of Strings representing the currently deployed releases.
releases :: RC [Release] releases :: RC [Release]
releases = do releases = do
conf <- use config conf <- gets config
res <- remoteCommand $ "find " ++ releasesPath conf ++ " -type d -maxdepth 1" res <- runCommand (host conf) $ "find " ++ releasesPath conf ++
" -type d -maxdepth 1"
case res of case res of
Nothing -> lift $ right [] Nothing -> lift $ right []
Just s -> Just s ->
lift $ right $ filter isReleaseString . map pathToRelease lift $ right $
$ lines s filter (isReleaseString (releaseFormat conf)) . map pathToRelease $
lines s
previousReleases :: RC [Release] previousReleases :: RC [Release]
previousReleases = do previousReleases = do
rls <- releases rls <- releases
currentRelease <- readCurrentLink conf <- gets config
case currentRelease of currentRelease <-
Nothing -> lift $ left (1, Just "Bad pointer from current link") liftIO $ readCurrentLink (host conf) (currentPath (deployPath conf))
Just c -> do
let currentRel = (head . lines . pathToRelease) c let currentRel = (head . lines . pathToRelease) currentRelease
return $ filter (< currentRel) rls return $ filter (< currentRel) rls
releasePath :: Config -> Release -> FilePath releasePath :: Config -> Release -> FilePath
releasePath conf rls = joinPath [releasesPath conf, rls] releasePath conf rls = joinPath [releasesPath conf, rls]
@ -274,66 +308,77 @@ oldReleases conf rs = map mergePath toDelete
-- host filesystem. -- host filesystem.
cleanReleases :: RC (Maybe String) cleanReleases :: RC (Maybe String)
cleanReleases = do cleanReleases = do
conf <- use config conf <- gets config
allReleases <- releases allReleases <- releases
case allReleases of let deletable = oldReleases conf allReleases
[] -> echoMessage "There are no old releases to prune."
xs -> do
let deletable = oldReleases conf xs
remoteCommand $ "rm -rf -- " ++ foldr (\a b -> a ++ " " ++ b) "" if null deletable
deletable then
echoMessage "There are no old releases to prune."
else
runCommand (host conf) $
"rm -rf -- " ++ foldr (\a b -> a ++ " " ++ b) "" deletable
-- | Returns a Bool indicating if the given String is in the proper release -- | Returns a Bool indicating if the given String is in the proper release
-- format. -- format.
isReleaseString :: String -> Bool isReleaseString :: ReleaseFormat -> String -> Bool
isReleaseString s = all isNumber s && length s == 14 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 -- | Creates the git repository that is used on the target host for
-- cache purposes. -- cache purposes.
createCacheRepo :: RC (Maybe String) createCacheRepo :: RC (Maybe String)
createCacheRepo = do createCacheRepo = do
conf <- use config conf <- gets config
remoteCommand $ "git clone --bare " ++ conf ^. repository ++ " " ++
runCommand (host conf) $ "git clone --bare " ++ repository conf ++ " " ++
cacheRepoPath conf cacheRepoPath conf
-- | Returns the full path of the symlink pointing to the current -- | Returns the full path of the symlink pointing to the current
-- release. -- release.
currentSymlinkPath :: Config -> FilePath currentSymlinkPath :: Config -> FilePath
currentSymlinkPath conf = joinPath [conf ^. deployPath, "current"] currentSymlinkPath conf = joinPath [deployPath conf, "current"]
currentTempSymlinkPath :: Config -> FilePath currentTempSymlinkPath :: Config -> FilePath
currentTempSymlinkPath conf = joinPath [conf ^. deployPath, "current_tmp"] currentTempSymlinkPath conf = joinPath [deployPath conf, "current_tmp"]
-- | Removes the current symlink in preparation for a new release being -- | Removes the current symlink in preparation for a new release being
-- activated. -- activated.
removeCurrentSymlink :: RC (Maybe String) removeCurrentSymlink :: RC (Maybe String)
removeCurrentSymlink = do removeCurrentSymlink = do
conf <- use config conf <- gets config
remoteCommand $ "rm -rf " ++ currentSymlinkPath conf
runCommand (host conf) $ "rm -rf " ++ currentSymlinkPath conf
-- | Determines whether the target host OS is Linux -- | Determines whether the target host OS is Linux
remoteIsLinux :: RC Bool targetIsLinux :: RC Bool
remoteIsLinux = do targetIsLinux = do
st <- get conf <- gets config
res <- remoteCommand "uname" res <- runCommand (host conf) "uname"
case res of case res of
Just output -> lift $ right $ "Linux" `isInfixOf` output Just output -> lift $ right $ "Linux" `isInfixOf` output
_ -> lift $ left (1, Just "Unable to determine remote host type") _ -> lift $ left (1, Just "Unable to determine remote host type")
-- | Runs a command to restart a server if a command is provided.
restartServerCommand :: RC (Maybe String) restartServerCommand :: RC (Maybe String)
restartServerCommand = do restartServerCommand = do
conf <- use config conf <- gets config
case conf ^. restartCommand of
Nothing -> return $ Just "No command given for restart action."
Just cmd -> remoteCommand cmd
case restartCommand conf of
Nothing -> return $ Just "No command given for restart action."
Just cmd -> runCommand (host conf) cmd
-- | Runs a build script if one is provided.
runBuild :: RC (Maybe String) runBuild :: RC (Maybe String)
runBuild = do runBuild = do
conf <- use config conf <- gets config
case conf ^. buildScript of
case buildScript conf of
Nothing -> do Nothing -> do
liftIO $ putStrLn "No build script specified, skipping build step." liftIO $ putStrLn "No build script specified, skipping build step."
return Nothing return Nothing
@ -344,51 +389,65 @@ runBuild = do
buildRelease commands buildRelease commands
-- | Returns the best 'mv' command for a symlink given the target platform. -- | Returns the best 'mv' command for a symlink given the target platform.
mvCommand :: mvCommand :: Bool -- ^ Whether the target host is Linux
Bool -- ^ Whether the target host is Linux -> String -- ^ The best mv command for a symlink on the platform
-> String -- ^ The best mv command for a symlink on the platform
mvCommand True = "mv -Tf" mvCommand True = "mv -Tf"
mvCommand False = "mv -f" 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. -- | 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 :: RC (Maybe String) symlinkCurrent :: RC (Maybe String)
symlinkCurrent = do symlinkCurrent = do
conf <- use config conf <- gets config
releaseTimestamp <- use timestamp releaseTimestamp <- gets timestamp
case releaseTimestamp of case releaseTimestamp of
Nothing -> lift $ left (1, Just "No releases to symlink!") Nothing -> lift $ left (1, Just "No releases to symlink!")
Just rls -> do Just rls -> do
isLnx <- remoteIsLinux isLnx <- targetIsLinux
let tmpLnCmd =
lnCommand (releasePath conf rls) (currentTempSymlinkPath conf)
_ <- runCommand (host conf) $ tmpLnCmd
runCommand (host conf) $ unwords [ mvCommand isLnx
, currentTempSymlinkPath conf
, currentSymlinkPath conf ]
remoteCommand $ "ln -s " ++ releasePath conf rls ++ " " ++
currentTempSymlinkPath conf ++
" && " ++ mvCommand isLnx ++ " " ++
currentTempSymlinkPath conf
++ " " ++ currentSymlinkPath conf
-- | Updates the git repo used as a cache in the target host filesystem. -- | Updates the git repo used as a cache in the target host filesystem.
updateCacheRepo :: RC (Maybe String) updateCacheRepo :: RC ()
updateCacheRepo = do updateCacheRepo = do
conf <- use config conf <- gets config
remoteCommand $ intercalate " && "
void $ runCommand (host conf) $ intercalate " && "
[ "cd " ++ cacheRepoPath conf [ "cd " ++ cacheRepoPath conf
, "git fetch origin +refs/heads/*:refs/heads/*" ] , "git fetch origin +refs/heads/*:refs/heads/*" ]
-- | Sets the release to the correct revision by resetting the -- | Sets the release to the correct revision by resetting the
-- head of the git repo. -- head of the git repo.
setReleaseRevision :: RC (Maybe String) setReleaseRevision :: RC ()
setReleaseRevision = do setReleaseRevision = do
conf <- use config conf <- gets config
releaseTimestamp <- use timestamp releaseTimestamp <- gets timestamp
case releaseTimestamp of case releaseTimestamp of
Nothing -> lift $ left (1, Just "No releases to symlink!") Nothing -> lift $ left (1, Just "No releases to symlink!")
Just rls -> Just rls -> do
remoteCommand $ intercalate " && " void $ runCommand (host conf) $ intercalate " && "
[ "cd " ++ releasePath conf rls [ "cd " ++ releasePath conf rls
, "git fetch --all" , "git fetch --all"
, "git reset --hard " ++ conf ^. revision , "git reset --hard " ++ revision conf
] ]
-- | Returns a command that builds this application. Sets the context -- | Returns a command that builds this application. Sets the context
-- of the build by switching to the release directory before running -- of the build by switching to the release directory before running
@ -398,13 +457,14 @@ buildRelease :: [String] -- ^ Commands to be run. List intercalated
-- sequence. -- sequence.
-> RC (Maybe String) -> RC (Maybe String)
buildRelease commands = do buildRelease commands = do
conf <- use config conf <- gets config
releaseTimestamp <- use timestamp releaseTimestamp <- gets timestamp
case releaseTimestamp of case releaseTimestamp of
Nothing -> lift $ left (1, Just "No releases to symlink!") Nothing -> lift $ left (1, Just "No releases to symlink!")
Just rls -> do Just rls -> do
let cdCmd = "cd " ++ releasePath conf rls let cdCmd = "cd " ++ releasePath conf rls
remoteCommand $ intercalate " && " $ cdCmd : commands runCommand (host conf) $ intercalate " && " $ cdCmd : commands
-- | A safe version of the `maximum` function in Data.List. -- | A safe version of the `maximum` function in Data.List.
biggest :: Ord a => [a] -> Maybe a biggest :: Ord a => [a] -> Maybe a

View File

@ -0,0 +1,46 @@
module System.Hapistrano.Types
( Config(..)
, HapistranoState(..)
, RC
, Release
, ReleaseFormat(..)
) where
import Control.Monad.Trans.State (StateT)
import Control.Monad.Trans.Either (EitherT(..))
-- | Config stuff that will be replaced by config file reading
data Config =
Config { deployPath :: String
-- ^ The root of the deploy target on the remote host
, repository :: String -- ^ The remote git repo
, revision :: String -- ^ A SHA1 or branch to release
, releaseFormat :: ReleaseFormat
, host :: Maybe String
-- ^ The target host for the deploy, or Nothing to indicate that
-- operations should be done directly in the local deployPath without
-- going over SSH
, buildScript :: Maybe FilePath
-- ^ The local path to a file that should be executed on the remote
-- server to build the application.
, restartCommand :: Maybe String
-- ^ Optional command to restart the server after a successful deploy
} deriving (Show)
data ReleaseFormat = Short -- ^ Standard release path following Capistrano
| Long -- ^ Long release path including picoseconds for testing or people seriously into continuous deployment
deriving (Show)
data HapistranoState = HapistranoState { config :: Config
, timestamp :: Maybe String
} deriving Show
type Release = String
type RC a = StateT HapistranoState (EitherT (Int, Maybe String) IO) a