diff --git a/.gitignore b/.gitignore index bfccd91..e36d14e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ *.swp angel -reconfig -unpacker -*.hi -*.o +deploy testapp1.yesod testapp1/testapp1 +/dist/ +testapp1/*.hi +testapp1/*.o diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..443e4ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c)2012, Michael Snoyman + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of Michael Snoyman nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Reconfig.hs b/Reconfig.hs deleted file mode 100644 index 27c178e..0000000 --- a/Reconfig.hs +++ /dev/null @@ -1,182 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -module Reconfig where - -import Data.Yaml -import Data.Text (Text, unpack) -import Filesystem.Path.CurrentOS - ( FilePath, fromText, (), directory, encodeString, decodeString - ) -import Filesystem - ( listDirectory, isFile, isDirectory, canonicalizePath, rename - ) -import Prelude hiding (FilePath, writeFile) -import Control.Applicative ((<$>), (<*>)) -import Control.Monad (mzero, foldM) -import qualified Data.Map as Map -import Data.Maybe (catMaybes) -import System.Cmd (rawSystem) -import qualified Prelude - -data Deploy = Deploy - { deployName :: Text - , deployDirectory :: FilePath - , deployWebapps :: [Webapp] - , deployStatics :: [Static] - } - deriving Show - -instance FromJSON Deploy where - parseJSON (Object o) = Deploy - <$> o .: "name" - <*> return "" - <*> o .: "webapps" - <*> o .: "statics" - parseJSON _ = mzero - -data Webapp = Webapp - { webappHost :: Text - , webappExec :: FilePath - } - deriving Show - -instance FromJSON Webapp where - parseJSON (Object o) = Webapp - <$> o .: "host" - <*> (fromText <$> o .: "exec") - parseJSON _ = mzero - -data Static = Static - { staticHost :: Text - , staticDirectory :: FilePath - } - deriving Show - -instance FromJSON Static where - parseJSON (Object o) = Static - <$> o .: "host" - <*> (fromText <$> o .: "directory") - parseJSON _ = mzero - -loadDeploy :: FilePath -> IO Deploy -loadDeploy fp = do - putStrLn $ "Loading deploy config from: " ++ show fp - Just deploy <- decodeFile $ encodeString fp - dir <- canonicalizePath $ directory fp - makeAbsolute deploy { deployDirectory = dir } - -makeAbsolute :: Deploy -> IO Deploy -makeAbsolute (Deploy name dir ws ss) = - Deploy <$> return name - <*> return dir - <*> mapM goW ws - <*> mapM goS ss - where - goW (Webapp h e) = do - path <- canonicalizePath $ dir e - return $ Webapp h path - goS (Static h d) = do - path <- canonicalizePath $ dir d - return $ Static h path - -loadDeploys :: FilePath -> IO (Map.Map Text Deploy) -loadDeploys root = do - contents <- listDirectory root - deploys <- catMaybes <$> mapM go contents - foldM addDeploy Map.empty deploys - where - go folder = do - isD <- isDirectory folder - if isD - then do - let fp = folder "deploy.yaml" - isF <- isFile fp - if isF - then Just <$> loadDeploy fp - else return Nothing - else return Nothing - addDeploy m d = - case Map.lookup (deployName d) m of - Nothing -> return $ Map.insert (deployName d) d m - Just _ -> error $ "Duplicate name: " ++ show (deployName d) - -webappsPorts :: Map.Map Text Deploy -> [((Deploy, Webapp), Int)] -webappsPorts m = zip webapps [4000..] - where - webapps = concatMap (\d -> zip (repeat d) (deployWebapps d)) - $ Map.elems m - -angelBlock :: ((Deploy, Webapp), Int) -> String -angelBlock ((d, w), p) = unlines - [ concat [unpack $ deployName d, "-", unpack $ webappHost w, " {"] - , concat - [ " exec = \"env PORT=" - , show p - , " " - , encodeString $ webappExec w - , "\"" - ] - , concat - [ " directory = \"" - , encodeString $ deployDirectory d - , "\"" - ] - , "}" - ] - -nginxBlockWebapp :: ((Deploy, Webapp), Int) -> String -nginxBlockWebapp ((_, w), p) = unlines - [ "server {" - , concat [" server_name ", unpack $ webappHost w, ";"] - , " location / {" - , concat - [ " proxy_pass http://127.0.0.1:" - , show p - , ";" - ] - , " }" - , "}" - ] - -nginxBlockStatic :: Static -> String -nginxBlockStatic s = unlines - [ "server {" - , concat [" server_name ", unpack $ staticHost s, ";"] - , concat - [ " root " - , encodeString $ staticDirectory s - , ";" - ] - , "}" - ] - -reconfig :: String -> String -> String -> String -> IO () -reconfig rootDir unpackedFolder angelConfig nginxConfig = do - let deploy = unlines - [ "deploy {" - , concat - [ " exec = \"" - , rootDir - , "bin/deploy " - , rootDir - , "\"" - ] - , "}" - ] - deploys <- loadDeploys $ decodeString unpackedFolder - let was = webappsPorts deploys - let statics = concatMap deployStatics $ Map.elems deploys - let rootDir' = decodeString rootDir - writeFile rootDir' angelConfig - $ deploy ++ concatMap angelBlock was - writeFile rootDir' nginxConfig - $ concatMap nginxBlockWebapp was ++ - concatMap nginxBlockStatic statics - _ <- rawSystem "reload" ["yesod-deploy-angel"] - _ <- rawSystem "/etc/init.d/nginx" ["reload"] - return () - -writeFile :: FilePath -> String -> String -> IO () -writeFile rootDir file contents = do - let fp = rootDir "tmp" - Prelude.writeFile (encodeString fp) contents - rename fp $ decodeString file diff --git a/Setup.hs b/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/build.sh b/build.sh index af4f74d..5c36655 100755 --- a/build.sh +++ b/build.sh @@ -1,10 +1,9 @@ #!/bin/bash -xe -sudo rm -rf /yesod-deploy -sudo stop yesod-deploy-angel || true -sudo rm -f /etc/init/yesod-deploy-angel.conf -sudo rm -f /etc/nginx/sites-enabled/yesod-deploy.conf -sudo /etc/init.d/nginx reload +sudo ./clean.sh + +cabal build +cp dist/build/deploy/deploy . +strip deploy -ghc -Wall -Werror --make deploy.hs && strip deploy sudo ./setup.sh diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..fa22b23 --- /dev/null +++ b/clean.sh @@ -0,0 +1,7 @@ +#!/bin/bash -ex + +rm -rf /yesod-deploy +stop yesod-deploy-angel || true +rm -f /etc/init/yesod-deploy-angel.conf +rm -f /etc/nginx/sites-enabled/yesod-deploy.conf +/etc/init.d/nginx reload diff --git a/deploy.hs b/deploy.hs deleted file mode 100644 index f4dd1cd..0000000 --- a/deploy.hs +++ /dev/null @@ -1,58 +0,0 @@ -{-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE OverloadedStrings #-} -import System.Environment (getArgs) -import System.INotify -import Control.Concurrent -import Control.Monad -import Control.Exception -import Prelude hiding (catch, FilePath) -import Filesystem.Path.CurrentOS -import Filesystem -import System.IO.Error (isAlreadyExistsError) -import System.Cmd (rawSystem) -import Reconfig - -main :: IO () -main = do - args <- getArgs - dir <- - case args of - [a] -> return a - _ -> error "Invalid args" - let dir' = decodeString dir - unpack dir' - inotify <- initINotify - poll <- newEmptyMVar - _ <- addWatch inotify [AllEvents] (dir ++ "incoming") (const $ putMVar poll ()) - takeMVar poll - -unpack :: FilePath -> IO () -unpack dir = do - dest <- getDest [1..] - let incoming = dir "incoming" - listDirectory incoming >>= mapM_ (unpack1 dest) - reconfig - (encodeString dir) - (encodeString dest) - (encodeString $ dir "etc" "angel.conf") - ("/etc/nginx/sites-enabled/yesod-deploy.conf") - where - getDest :: [Int] -> IO FilePath - getDest [] = error "getDest:impossible happened" - getDest (i:is) = do - let dest = dir "unpacked" decodeString (show i) - (createDirectory False dest >> return dest) `catch` - \e -> if isAlreadyExistsError e then getDest is else throwIO e - unpack1 dest file = do - isF <- isFile file - if isF && hasExtension file "yesod" - then do - let dest' = dest basename file - createDirectory True dest' - _ <- rawSystem "tar" - [ "zxfC" - , encodeString file - , encodeString dest' - ] - return () - else return () diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..cee1691 --- /dev/null +++ b/package.sh @@ -0,0 +1,15 @@ +#!/bin/bash -ex + +cabal clean +cabal configure +cabal build + +rm -rf yesod-deploy +mkdir yesod-deploy + +cp dist/build/deploy/deploy `which angel` yesod-deploy +strip yesod-deploy/* +cp setup.sh yesod-deploy + +tar czfv yesod-deploy.tar.gz yesod-deploy +rm -rf yesod-deploy diff --git a/src/Angel.hs b/src/Angel.hs new file mode 100644 index 0000000..3580dea --- /dev/null +++ b/src/Angel.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Generate an Angel config file. +module Angel + ( angelFile + ) where + +import Prelude hiding (FilePath) +import Data.Monoid (Monoid, mappend, mconcat) +import Data.Text.Lazy.Builder (Builder, fromText, fromString) +import Data.Text.Lazy.Builder.Int (decimal) +import Filesystem.Path.CurrentOS (encodeString) +import Config +import Paths + +infixr 5 <> +(<>) :: Monoid m => m -> m -> m +(<>) = mappend + +-- | A block in an angel file for an individual webapp. +angelBlock :: WebappPort -> Builder +angelBlock (WebappPort w d p) = + fromText (deployName d) <> "-" <> fromText (webappHost w) <> " {\n" <> + " exec = \"env PORT=" <> decimal p <> " " <> fromString (encodeString $ webappExec w) <> "\"\n" <> + " directory = \"" <> fromString (encodeString $ deployDirectory d) <> "\"\n" <> + "}\n\n" + +-- | Generate the deploy block, for monitoring this program. +deployBlock :: RootDir -> Builder +deployBlock rootDir = + "deploy {\n" <> + " exec = \"" <> rootDir' <> "bin/deploy " <> rootDir' <> "\"\n" <> + "}\n\n" + where + rootDir' = fromString $ encodeString rootDir + +-- | The full text of the angel config file. +angelFile :: RootDir -> Deploys -> Builder +angelFile rootDir ds = deployBlock rootDir <> mconcat (map angelBlock $ webappPorts ds) diff --git a/src/Config.hs b/src/Config.hs new file mode 100644 index 0000000..140cf35 --- /dev/null +++ b/src/Config.hs @@ -0,0 +1,130 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Load the config data from the individual deploy.yaml files. +module Config + ( Deploys + , Deploy (..) + , Webapp (..) + , Static (..) + , loadDeploys + , WebappPort (..) + , webappPorts + ) where + +import Prelude hiding (FilePath, writeFile) +import Control.Applicative ((<$>), (<*>)) +import Control.Monad (foldM, mzero) +import Data.Maybe (catMaybes) +import qualified Data.Map as Map + +import Data.Text (Text) +import Data.Yaml +import Filesystem.Path.CurrentOS + ( FilePath, fromText, (), directory, encodeString + ) +import Filesystem (canonicalizePath, listDirectory, isDirectory, isFile) + +-- | A single deployment bundle. +data Deploy = Deploy + { deployName :: Text + , deployDirectory :: FilePath + , deployWebapps :: [Webapp] + , deployStatics :: [Static] + } + deriving Show + +-- | One webapp within a bundle. +data Webapp = Webapp + { webappHost :: Text + , webappExec :: FilePath + } + deriving Show + +-- | A static folder to be served. +data Static = Static + { staticHost :: Text + , staticDirectory :: FilePath + } + deriving Show + +instance FromJSON Deploy where + parseJSON (Object o) = Deploy + <$> o .: "name" + <*> return "" + <*> o .: "webapps" + <*> o .: "statics" + parseJSON _ = mzero + +instance FromJSON Webapp where + parseJSON (Object o) = Webapp + <$> o .: "host" + <*> (fromText <$> o .: "exec") + parseJSON _ = mzero + +instance FromJSON Static where + parseJSON (Object o) = Static + <$> o .: "host" + <*> (fromText <$> o .: "directory") + parseJSON _ = mzero + +-- | Load a 'Deploy' from the given file. All paths returned are absolute and +-- canonicalized. +loadDeploy :: FilePath -> IO Deploy +loadDeploy fp = do + putStrLn $ "Loading deploy config from: " ++ show fp + Just deploy <- decodeFile $ encodeString fp + dir <- canonicalizePath $ directory fp + makeAbsolute deploy { deployDirectory = dir } + +-- | Turn the relative paths in a 'Deploy' into absolute, canonical paths. +makeAbsolute :: Deploy -> IO Deploy +makeAbsolute (Deploy name dir ws ss) = + Deploy <$> return name + <*> return dir + <*> mapM goW ws + <*> mapM goS ss + where + goW (Webapp h e) = do + path <- canonicalizePath $ dir e + return $ Webapp h path + goS (Static h d) = do + path <- canonicalizePath $ dir d + return $ Static h path + +type Deploys = Map.Map Text Deploy + +-- | Load all the deployment information from the given folder. +loadDeploys :: FilePath -> IO Deploys +loadDeploys root = do + contents <- listDirectory root + deploys <- catMaybes <$> mapM go contents + foldM addDeploy Map.empty deploys + where + go folder = do + isD <- isDirectory folder + if isD + then do + let fp = folder "deploy.yaml" + isF <- isFile fp + if isF + then Just <$> loadDeploy fp + else return Nothing + else return Nothing + addDeploy m d = + case Map.lookup (deployName d) m of + Nothing -> return $ Map.insert (deployName d) d m + Just _ -> error $ "Duplicate name: " ++ show (deployName d) + +-- | Full information on a single webapp +data WebappPort = WebappPort + { wapWebapp :: Webapp + , wapDeploy :: Deploy + , wapPort :: Int + } + +-- | Get all the 'WebappPort's from the full config information. +webappPorts :: Deploys -> [WebappPort] +webappPorts m = + map (\((d, w), p) -> WebappPort w d p) $ zip webapps [4000..] + where + webapps = concatMap (\d -> zip (repeat d) (deployWebapps d)) + $ Map.elems m diff --git a/src/Nginx.hs b/src/Nginx.hs new file mode 100644 index 0000000..4467a89 --- /dev/null +++ b/src/Nginx.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Generate an Nginx config file. +module Nginx + ( nginxFile + ) where + +import Prelude hiding (FilePath) +import Data.Monoid (Monoid, mappend, mconcat) +import qualified Data.Map as Map +import Data.Text.Lazy.Builder (Builder, fromText, fromString) +import Data.Text.Lazy.Builder.Int (decimal) +import Filesystem.Path.CurrentOS (encodeString) +import Config + +infixr 5 <> +(<>) :: Monoid m => m -> m -> m +(<>) = mappend + +nginxBlockWebapp :: WebappPort -> Builder +nginxBlockWebapp (WebappPort w _ p) = + "server {\n" <> + " server_name " <> fromText (webappHost w) <> ";\n" <> + " location / {\n" <> + " proxy_pass http://127.0.0.1:" <> decimal p <> ";\n" <> + " }\n" <> + "}\n\n" + +nginxBlockStatic :: Static -> Builder +nginxBlockStatic s = + "server {\n" <> + " server_name " <> fromText (staticHost s) <> ";\n" <> + " root " <> fromString (encodeString $ staticDirectory s) <> ";\n" <> + "}\n\n" + +nginxFile :: Deploys -> Builder +nginxFile ds = mconcat (map nginxBlockWebapp $ webappPorts ds) <> + mconcat (map nginxBlockStatic $ concatMap deployStatics $ Map.elems ds) diff --git a/src/Paths.hs b/src/Paths.hs new file mode 100644 index 0000000..28d1044 --- /dev/null +++ b/src/Paths.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Find paths given the root directory of deployment. +module Paths + ( RootDir + , incoming + , angelConf + , nginxConf + ) where + +import Prelude () +import Filesystem.Path.CurrentOS (FilePath, ()) + +type RootDir = FilePath + +incoming :: RootDir -> FilePath +incoming = ( "incoming") + +angelConf :: RootDir -> FilePath +angelConf r = r "etc" "angel.conf" + +nginxConf :: FilePath +nginxConf = "/etc/nginx/sites-enabled/yesod-deploy.conf" diff --git a/src/Reload.hs b/src/Reload.hs new file mode 100644 index 0000000..0158266 --- /dev/null +++ b/src/Reload.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Reload config data and write new config files. +module Reload + ( reload + ) where + +import Prelude hiding (FilePath, writeFile) +import System.Cmd (rawSystem) +import qualified Data.ByteString.Lazy as L +import Data.Text.Lazy.Builder (Builder, toLazyText) +import Data.Text.Lazy.Encoding (encodeUtf8) +import Filesystem.Path.CurrentOS + ( FilePath, (), encodeString + ) +import Filesystem (rename) + +import Config +import Angel +import Nginx +import Paths + +reload :: RootDir -> FilePath -> IO () +reload rootDir unpackedFolder = do + deploys <- loadDeploys unpackedFolder + writeFile rootDir (angelConf rootDir) $ angelFile rootDir deploys + writeFile rootDir nginxConf $ nginxFile deploys + _ <- rawSystem "reload" ["yesod-deploy-angel"] + _ <- rawSystem "/etc/init.d/nginx" ["reload"] + return () + +writeFile :: RootDir -> FilePath -> Builder -> IO () +writeFile rootDir file contents = do + let fp = rootDir "tmp" + L.writeFile (encodeString fp) $ encodeUtf8 $ toLazyText contents + rename fp file diff --git a/src/Unpack.hs b/src/Unpack.hs new file mode 100644 index 0000000..2cab07e --- /dev/null +++ b/src/Unpack.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE OverloadedStrings #-} +-- | Unpack the files from incoming. +module Unpack + ( unpack + ) where + +import Prelude hiding (FilePath, catch) +import Control.Exception (catch, throwIO) +import System.IO.Error (isAlreadyExistsError) +import System.Cmd (rawSystem) +import Filesystem.Path.CurrentOS + ( FilePath, (), decodeString, hasExtension, basename, encodeString + ) +import Filesystem (listDirectory, createDirectory, isFile) +import Paths (RootDir, incoming) +import Reload (reload) + +unpack :: RootDir -> IO () +unpack rootDir = do + dest <- getDest [1..] + listDirectory (incoming rootDir) >>= mapM_ (unpack1 dest) + reload rootDir dest + where + -- find the next unused folder + getDest :: [Int] -> IO FilePath + getDest [] = error "getDest:impossible happened" + getDest (i:is) = do + let dest = rootDir "unpacked" decodeString (show i) + (createDirectory False dest >> return dest) `catch` + \e -> if isAlreadyExistsError e then getDest is else throwIO e + + -- unpacks a single file, assuming it has a .yesod extension + unpack1 dest file = do + isF <- isFile file + if isF && hasExtension file "yesod" + then do + let dest' = dest basename file + createDirectory True dest' + _ <- rawSystem "tar" + [ "zxfC" + , encodeString file + , encodeString dest' + ] + return () + else return () diff --git a/src/deploy.hs b/src/deploy.hs new file mode 100644 index 0000000..0dcbb3b --- /dev/null +++ b/src/deploy.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE OverloadedStrings #-} +import System.Environment (getArgs) +import System.INotify (initINotify, addWatch, EventVariety (AllEvents)) +import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) +import Prelude hiding (catch, FilePath) +import Filesystem.Path.CurrentOS (encodeString, decodeString) +import Unpack (unpack) +import Paths (incoming) + +main :: IO () +main = do + args <- getArgs + rootDir <- + case args of + [a] -> return $ decodeString a + _ -> error "Invalid args" + + -- Unpack, then wait until there's any activity in the incoming folder. + -- When this process dies, angel will restart it, allowing it to unpack + -- again. We do the unpack first so that when the code is first loaded, we + -- get an initial unpack even without filesystem activity. + unpack rootDir + inotify <- initINotify + poll <- newEmptyMVar + _ <- addWatch inotify [AllEvents] (encodeString $ incoming rootDir) (const $ putMVar poll ()) + takeMVar poll diff --git a/yesod-deploy.cabal b/yesod-deploy.cabal new file mode 100644 index 0000000..2a07706 --- /dev/null +++ b/yesod-deploy.cabal @@ -0,0 +1,37 @@ +Name: yesod-deploy +Version: 0.0.0 +Synopsis: Automated single-file deployments of Yesod apps. +Description: Automated single-file deployments of Yesod apps. +Homepage: http://www.yesodweb.com/ +License: BSD3 +License-file: LICENSE +Author: Michael Snoyman +Maintainer: michael@snoyman.com +Category: Web +Build-type: Simple +Extra-source-files: setup.sh +Cabal-version: >=1.8 + +Executable deploy + hs-source-dirs: src + Main-is: deploy.hs + Build-depends: base >= 4 && < 5 + , process + , containers + , text + , bytestring + , system-fileio >= 0.3.3 && < 0.4 + , system-filepath >= 0.4.4 && < 0.5 + , yaml >= 0.5.1.1 && < 0.6 + , hinotify >= 0.3.2 && < 0.4 + Other-modules: Config + Angel + Nginx + Reload + Unpack + Paths + ghc-options: -Wall -Werror + +source-repository head + type: git + location: git://github.com/yesodweb/yesod-deploy.git