From 7115d1ec162b4059b3e8e8f84bd8d5898c1db025 Mon Sep 17 00:00:00 2001 From: Joey Hess Date: Wed, 14 May 2014 19:41:05 -0400 Subject: moved source code to src This is to work around OSX's brain-damange regarding filename case insensitivity. Avoided moving config.hs, because it's a config file. Put in a symlink to make build work. --- Propellor.hs | 77 ---- Propellor/Attr.hs | 111 ----- Propellor/CmdLine.hs | 392 ------------------ Propellor/Engine.hs | 37 -- Propellor/Exception.hs | 18 - Propellor/Message.hs | 51 --- Propellor/PrivData.hs | 91 ---- Propellor/Property.hs | 163 -------- Propellor/Property/Apache.hs | 62 --- Propellor/Property/Apt.hs | 256 ------------ Propellor/Property/Cmd.hs | 49 --- Propellor/Property/Cron.hs | 49 --- Propellor/Property/Dns.hs | 405 ------------------ Propellor/Property/Docker.hs | 456 --------------------- Propellor/Property/Docker/Shim.hs | 61 --- Propellor/Property/File.hs | 94 ----- Propellor/Property/Git.hs | 93 ----- Propellor/Property/Gpg.hs | 41 -- Propellor/Property/Hostname.hs | 33 -- Propellor/Property/Network.hs | 30 -- Propellor/Property/Obnam.hs | 155 ------- Propellor/Property/OpenId.hs | 29 -- Propellor/Property/Postfix.hs | 25 -- Propellor/Property/Reboot.hs | 7 - Propellor/Property/Scheduled.hs | 67 --- Propellor/Property/Service.hs | 31 -- Propellor/Property/SiteSpecific/GitAnnexBuilder.hs | 57 --- Propellor/Property/SiteSpecific/GitHome.hs | 34 -- Propellor/Property/SiteSpecific/JoeySites.hs | 314 -------------- Propellor/Property/Ssh.hs | 152 ------- Propellor/Property/Sudo.hs | 32 -- Propellor/Property/Tor.hs | 19 - Propellor/Property/User.hs | 61 --- Propellor/SimpleSh.hs | 101 ----- Propellor/Types.hs | 153 ------- Propellor/Types/Attr.hs | 48 --- Propellor/Types/Dns.hs | 92 ----- Propellor/Types/OS.hs | 27 -- Utility/Applicative.hs | 16 - Utility/Data.hs | 17 - Utility/Directory.hs | 135 ------ Utility/Env.hs | 81 ---- Utility/Exception.hs | 59 --- Utility/FileMode.hs | 158 ------- Utility/FileSystemEncoding.hs | 132 ------ Utility/LinuxMkLibs.hs | 61 --- Utility/Misc.hs | 148 ------- Utility/Monad.hs | 69 ---- Utility/PartialPrelude.hs | 68 --- Utility/Path.hs | 293 ------------- Utility/PosixFiles.hs | 33 -- Utility/Process.hs | 364 ---------------- Utility/QuickCheck.hs | 52 --- Utility/SafeCommand.hs | 120 ------ Utility/Scheduled.hs | 396 ------------------ Utility/ThreadScheduler.hs | 75 ---- Utility/Tmp.hs | 100 ----- Utility/UserInfo.hs | 55 --- propellor.cabal | 5 +- src/Propellor.hs | 77 ++++ src/Propellor/Attr.hs | 111 +++++ src/Propellor/CmdLine.hs | 392 ++++++++++++++++++ src/Propellor/Engine.hs | 37 ++ src/Propellor/Exception.hs | 18 + src/Propellor/Message.hs | 51 +++ src/Propellor/PrivData.hs | 91 ++++ src/Propellor/Property.hs | 163 ++++++++ src/Propellor/Property/Apache.hs | 62 +++ src/Propellor/Property/Apt.hs | 256 ++++++++++++ src/Propellor/Property/Cmd.hs | 49 +++ src/Propellor/Property/Cron.hs | 49 +++ src/Propellor/Property/Dns.hs | 405 ++++++++++++++++++ src/Propellor/Property/Docker.hs | 456 +++++++++++++++++++++ src/Propellor/Property/Docker/Shim.hs | 61 +++ src/Propellor/Property/File.hs | 94 +++++ src/Propellor/Property/Git.hs | 93 +++++ src/Propellor/Property/Gpg.hs | 41 ++ src/Propellor/Property/Hostname.hs | 33 ++ src/Propellor/Property/Network.hs | 30 ++ src/Propellor/Property/Obnam.hs | 155 +++++++ src/Propellor/Property/OpenId.hs | 29 ++ src/Propellor/Property/Postfix.hs | 25 ++ src/Propellor/Property/Reboot.hs | 7 + src/Propellor/Property/Scheduled.hs | 67 +++ src/Propellor/Property/Service.hs | 31 ++ .../Property/SiteSpecific/GitAnnexBuilder.hs | 57 +++ src/Propellor/Property/SiteSpecific/GitHome.hs | 34 ++ src/Propellor/Property/SiteSpecific/JoeySites.hs | 314 ++++++++++++++ src/Propellor/Property/Ssh.hs | 152 +++++++ src/Propellor/Property/Sudo.hs | 32 ++ src/Propellor/Property/Tor.hs | 19 + src/Propellor/Property/User.hs | 61 +++ src/Propellor/SimpleSh.hs | 101 +++++ src/Propellor/Types.hs | 153 +++++++ src/Propellor/Types/Attr.hs | 48 +++ src/Propellor/Types/Dns.hs | 92 +++++ src/Propellor/Types/OS.hs | 27 ++ src/Utility/Applicative.hs | 16 + src/Utility/Data.hs | 17 + src/Utility/Directory.hs | 135 ++++++ src/Utility/Env.hs | 81 ++++ src/Utility/Exception.hs | 59 +++ src/Utility/FileMode.hs | 158 +++++++ src/Utility/FileSystemEncoding.hs | 132 ++++++ src/Utility/LinuxMkLibs.hs | 61 +++ src/Utility/Misc.hs | 148 +++++++ src/Utility/Monad.hs | 69 ++++ src/Utility/PartialPrelude.hs | 68 +++ src/Utility/Path.hs | 293 +++++++++++++ src/Utility/PosixFiles.hs | 33 ++ src/Utility/Process.hs | 364 ++++++++++++++++ src/Utility/QuickCheck.hs | 52 +++ src/Utility/SafeCommand.hs | 120 ++++++ src/Utility/Scheduled.hs | 396 ++++++++++++++++++ src/Utility/ThreadScheduler.hs | 75 ++++ src/Utility/Tmp.hs | 100 +++++ src/Utility/UserInfo.hs | 55 +++ src/config.hs | 1 + src/wrapper.hs | 93 +++++ wrapper.hs | 93 ----- 120 files changed, 6503 insertions(+), 6499 deletions(-) delete mode 100644 Propellor.hs delete mode 100644 Propellor/Attr.hs delete mode 100644 Propellor/CmdLine.hs delete mode 100644 Propellor/Engine.hs delete mode 100644 Propellor/Exception.hs delete mode 100644 Propellor/Message.hs delete mode 100644 Propellor/PrivData.hs delete mode 100644 Propellor/Property.hs delete mode 100644 Propellor/Property/Apache.hs delete mode 100644 Propellor/Property/Apt.hs delete mode 100644 Propellor/Property/Cmd.hs delete mode 100644 Propellor/Property/Cron.hs delete mode 100644 Propellor/Property/Dns.hs delete mode 100644 Propellor/Property/Docker.hs delete mode 100644 Propellor/Property/Docker/Shim.hs delete mode 100644 Propellor/Property/File.hs delete mode 100644 Propellor/Property/Git.hs delete mode 100644 Propellor/Property/Gpg.hs delete mode 100644 Propellor/Property/Hostname.hs delete mode 100644 Propellor/Property/Network.hs delete mode 100644 Propellor/Property/Obnam.hs delete mode 100644 Propellor/Property/OpenId.hs delete mode 100644 Propellor/Property/Postfix.hs delete mode 100644 Propellor/Property/Reboot.hs delete mode 100644 Propellor/Property/Scheduled.hs delete mode 100644 Propellor/Property/Service.hs delete mode 100644 Propellor/Property/SiteSpecific/GitAnnexBuilder.hs delete mode 100644 Propellor/Property/SiteSpecific/GitHome.hs delete mode 100644 Propellor/Property/SiteSpecific/JoeySites.hs delete mode 100644 Propellor/Property/Ssh.hs delete mode 100644 Propellor/Property/Sudo.hs delete mode 100644 Propellor/Property/Tor.hs delete mode 100644 Propellor/Property/User.hs delete mode 100644 Propellor/SimpleSh.hs delete mode 100644 Propellor/Types.hs delete mode 100644 Propellor/Types/Attr.hs delete mode 100644 Propellor/Types/Dns.hs delete mode 100644 Propellor/Types/OS.hs delete mode 100644 Utility/Applicative.hs delete mode 100644 Utility/Data.hs delete mode 100644 Utility/Directory.hs delete mode 100644 Utility/Env.hs delete mode 100644 Utility/Exception.hs delete mode 100644 Utility/FileMode.hs delete mode 100644 Utility/FileSystemEncoding.hs delete mode 100644 Utility/LinuxMkLibs.hs delete mode 100644 Utility/Misc.hs delete mode 100644 Utility/Monad.hs delete mode 100644 Utility/PartialPrelude.hs delete mode 100644 Utility/Path.hs delete mode 100644 Utility/PosixFiles.hs delete mode 100644 Utility/Process.hs delete mode 100644 Utility/QuickCheck.hs delete mode 100644 Utility/SafeCommand.hs delete mode 100644 Utility/Scheduled.hs delete mode 100644 Utility/ThreadScheduler.hs delete mode 100644 Utility/Tmp.hs delete mode 100644 Utility/UserInfo.hs create mode 100644 src/Propellor.hs create mode 100644 src/Propellor/Attr.hs create mode 100644 src/Propellor/CmdLine.hs create mode 100644 src/Propellor/Engine.hs create mode 100644 src/Propellor/Exception.hs create mode 100644 src/Propellor/Message.hs create mode 100644 src/Propellor/PrivData.hs create mode 100644 src/Propellor/Property.hs create mode 100644 src/Propellor/Property/Apache.hs create mode 100644 src/Propellor/Property/Apt.hs create mode 100644 src/Propellor/Property/Cmd.hs create mode 100644 src/Propellor/Property/Cron.hs create mode 100644 src/Propellor/Property/Dns.hs create mode 100644 src/Propellor/Property/Docker.hs create mode 100644 src/Propellor/Property/Docker/Shim.hs create mode 100644 src/Propellor/Property/File.hs create mode 100644 src/Propellor/Property/Git.hs create mode 100644 src/Propellor/Property/Gpg.hs create mode 100644 src/Propellor/Property/Hostname.hs create mode 100644 src/Propellor/Property/Network.hs create mode 100644 src/Propellor/Property/Obnam.hs create mode 100644 src/Propellor/Property/OpenId.hs create mode 100644 src/Propellor/Property/Postfix.hs create mode 100644 src/Propellor/Property/Reboot.hs create mode 100644 src/Propellor/Property/Scheduled.hs create mode 100644 src/Propellor/Property/Service.hs create mode 100644 src/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs create mode 100644 src/Propellor/Property/SiteSpecific/GitHome.hs create mode 100644 src/Propellor/Property/SiteSpecific/JoeySites.hs create mode 100644 src/Propellor/Property/Ssh.hs create mode 100644 src/Propellor/Property/Sudo.hs create mode 100644 src/Propellor/Property/Tor.hs create mode 100644 src/Propellor/Property/User.hs create mode 100644 src/Propellor/SimpleSh.hs create mode 100644 src/Propellor/Types.hs create mode 100644 src/Propellor/Types/Attr.hs create mode 100644 src/Propellor/Types/Dns.hs create mode 100644 src/Propellor/Types/OS.hs create mode 100644 src/Utility/Applicative.hs create mode 100644 src/Utility/Data.hs create mode 100644 src/Utility/Directory.hs create mode 100644 src/Utility/Env.hs create mode 100644 src/Utility/Exception.hs create mode 100644 src/Utility/FileMode.hs create mode 100644 src/Utility/FileSystemEncoding.hs create mode 100644 src/Utility/LinuxMkLibs.hs create mode 100644 src/Utility/Misc.hs create mode 100644 src/Utility/Monad.hs create mode 100644 src/Utility/PartialPrelude.hs create mode 100644 src/Utility/Path.hs create mode 100644 src/Utility/PosixFiles.hs create mode 100644 src/Utility/Process.hs create mode 100644 src/Utility/QuickCheck.hs create mode 100644 src/Utility/SafeCommand.hs create mode 100644 src/Utility/Scheduled.hs create mode 100644 src/Utility/ThreadScheduler.hs create mode 100644 src/Utility/Tmp.hs create mode 100644 src/Utility/UserInfo.hs create mode 120000 src/config.hs create mode 100644 src/wrapper.hs delete mode 100644 wrapper.hs diff --git a/Propellor.hs b/Propellor.hs deleted file mode 100644 index e6312248..00000000 --- a/Propellor.hs +++ /dev/null @@ -1,77 +0,0 @@ -{-# LANGUAGE PackageImports #-} - --- | Pulls in lots of useful modules for building and using Properties. --- --- When propellor runs on a Host, it ensures that its list of Properties --- is satisfied, taking action as necessary when a Property is not --- currently satisfied. --- --- A simple propellor program example: --- --- > import Propellor --- > import Propellor.CmdLine --- > import qualified Propellor.Property.File as File --- > import qualified Propellor.Property.Apt as Apt --- > --- > main :: IO () --- > main = defaultMain hosts --- > --- > hosts :: [Host] --- > hosts = --- > [ host "example.com" --- > & Apt.installed ["mydaemon"] --- > & "/etc/mydaemon.conf" `File.containsLine` "secure=1" --- > `onChange` cmdProperty "service" ["mydaemon", "restart"] --- > ! Apt.installed ["unwantedpackage"] --- > ] --- --- See config.hs for a more complete example, and clone Propellor's --- git repository for a deployable system using Propellor: --- git clone - -module Propellor ( - module Propellor.Types - , module Propellor.Property - , module Propellor.Property.Cmd - , module Propellor.Attr - , module Propellor.PrivData - , module Propellor.Engine - , module Propellor.Exception - , module Propellor.Message - , localdir - - , module X -) where - -import Propellor.Types -import Propellor.Property -import Propellor.Engine -import Propellor.Property.Cmd -import Propellor.PrivData -import Propellor.Message -import Propellor.Exception -import Propellor.Attr - -import Utility.PartialPrelude as X -import Utility.Process as X -import Utility.Exception as X -import Utility.Env as X -import Utility.Directory as X -import Utility.Tmp as X -import Utility.Monad as X -import Utility.Misc as X - -import System.Directory as X -import System.IO as X -import System.FilePath as X -import Data.Maybe as X -import Data.Either as X -import Control.Applicative as X -import Control.Monad as X -import Data.Monoid as X -import Control.Monad.IfElse as X -import "mtl" Control.Monad.Reader as X - --- | This is where propellor installs itself when deploying a host. -localdir :: FilePath -localdir = "/usr/local/propellor" diff --git a/Propellor/Attr.hs b/Propellor/Attr.hs deleted file mode 100644 index 98cfc64d..00000000 --- a/Propellor/Attr.hs +++ /dev/null @@ -1,111 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Attr where - -import Propellor.Types -import Propellor.Types.Attr - -import "mtl" Control.Monad.Reader -import qualified Data.Set as S -import qualified Data.Map as M -import Data.Maybe -import Control.Applicative - -pureAttrProperty :: Desc -> SetAttr -> Property -pureAttrProperty desc = Property ("has " ++ desc) (return NoChange) - -hostname :: HostName -> Property -hostname name = pureAttrProperty ("hostname " ++ name) $ - \d -> d { _hostname = name } - -getHostName :: Propellor HostName -getHostName = asks _hostname - -os :: System -> Property -os system = pureAttrProperty ("Operating " ++ show system) $ - \d -> d { _os = Just system } - -getOS :: Propellor (Maybe System) -getOS = asks _os - --- | Indidate that a host has an A record in the DNS. --- --- TODO check at run time if the host really has this address. --- (Can't change the host's address, but as a sanity check.) -ipv4 :: String -> Property -ipv4 addr = pureAttrProperty ("ipv4 " ++ addr) - (addDNS $ Address $ IPv4 addr) - --- | Indidate that a host has an AAAA record in the DNS. -ipv6 :: String -> Property -ipv6 addr = pureAttrProperty ("ipv6 " ++ addr) - (addDNS $ Address $ IPv6 addr) - --- | Indicates another name for the host in the DNS. -alias :: Domain -> Property -alias domain = pureAttrProperty ("alias " ++ domain) - (addDNS $ CNAME $ AbsDomain domain) - -addDNS :: Record -> SetAttr -addDNS record d = d { _dns = S.insert record (_dns d) } - --- | Adds a DNS NamedConf stanza. --- --- Note that adding a Master stanza for a domain always overrides an --- existing Secondary stanza, while a Secondary stanza is only added --- when there is no existing Master stanza. -addNamedConf :: NamedConf -> SetAttr -addNamedConf conf d = d { _namedconf = new } - where - m = _namedconf d - domain = confDomain conf - new = case (confDnsServerType conf, confDnsServerType <$> M.lookup domain m) of - (Secondary, Just Master) -> m - _ -> M.insert domain conf m - -getNamedConf :: Propellor (M.Map Domain NamedConf) -getNamedConf = asks _namedconf - -sshPubKey :: String -> Property -sshPubKey k = pureAttrProperty ("ssh pubkey known") $ - \d -> d { _sshPubKey = Just k } - -getSshPubKey :: Propellor (Maybe String) -getSshPubKey = asks _sshPubKey - -hostnameless :: Attr -hostnameless = newAttr (error "hostname Attr not specified") - -hostAttr :: Host -> Attr -hostAttr (Host _ mkattrs) = mkattrs hostnameless - -hostProperties :: Host -> [Property] -hostProperties (Host ps _) = ps - -hostMap :: [Host] -> M.Map HostName Host -hostMap l = M.fromList $ zip (map (_hostname . hostAttr) l) l - -hostAttrMap :: [Host] -> M.Map HostName Attr -hostAttrMap l = M.fromList $ zip (map _hostname attrs) attrs - where - attrs = map hostAttr l - -findHost :: [Host] -> HostName -> Maybe Host -findHost l hn = M.lookup hn (hostMap l) - -getAddresses :: Attr -> [IPAddr] -getAddresses = mapMaybe getIPAddr . S.toList . _dns - -hostAddresses :: HostName -> [Host] -> [IPAddr] -hostAddresses hn hosts = case hostAttr <$> findHost hosts hn of - Nothing -> [] - Just attr -> mapMaybe getIPAddr $ S.toList $ _dns attr - --- | Lifts an action into a different host. --- --- For example, `fromHost hosts "otherhost" getSshPubKey` -fromHost :: [Host] -> HostName -> Propellor a -> Propellor (Maybe a) -fromHost l hn getter = case findHost l hn of - Nothing -> return Nothing - Just h -> liftIO $ Just <$> - runReaderT (runWithAttr getter) (hostAttr h) diff --git a/Propellor/CmdLine.hs b/Propellor/CmdLine.hs deleted file mode 100644 index ab1d7f9e..00000000 --- a/Propellor/CmdLine.hs +++ /dev/null @@ -1,392 +0,0 @@ -module Propellor.CmdLine where - -import System.Environment (getArgs) -import Data.List -import System.Exit -import System.Log.Logger -import System.Log.Formatter -import System.Log.Handler (setFormatter, LogHandler) -import System.Log.Handler.Simple -import System.PosixCompat -import Control.Exception (bracket) -import System.Posix.IO -import Data.Time.Clock.POSIX - -import Propellor -import qualified Propellor.Property.Docker as Docker -import qualified Propellor.Property.Docker.Shim as DockerShim -import Utility.FileMode -import Utility.SafeCommand -import Utility.UserInfo - -usage :: IO a -usage = do - putStrLn $ unlines - [ "Usage:" - , " propellor" - , " propellor hostname" - , " propellor --spin hostname" - , " propellor --set hostname field" - , " propellor --add-key keyid" - ] - exitFailure - -processCmdLine :: IO CmdLine -processCmdLine = go =<< getArgs - where - go ("--help":_) = usage - go ("--spin":h:[]) = return $ Spin h - go ("--boot":h:[]) = return $ Boot h - go ("--add-key":k:[]) = return $ AddKey k - go ("--set":h:f:[]) = case readish f of - Just pf -> return $ Set h pf - Nothing -> errorMessage $ "Unknown privdata field " ++ f - go ("--continue":s:[]) = case readish s of - Just cmdline -> return $ Continue cmdline - Nothing -> errorMessage "--continue serialization failure" - go ("--chain":h:[]) = return $ Chain h - go ("--docker":h:[]) = return $ Docker h - go (h:[]) - | "--" `isPrefixOf` h = usage - | otherwise = return $ Run h - go [] = do - s <- takeWhile (/= '\n') <$> readProcess "hostname" ["-f"] - if null s - then errorMessage "Cannot determine hostname! Pass it on the command line." - else return $ Run s - go _ = usage - -defaultMain :: [Host] -> IO () -defaultMain hostlist = do - DockerShim.cleanEnv - checkDebugMode - cmdline <- processCmdLine - debug ["command line: ", show cmdline] - go True cmdline - where - go _ (Continue cmdline) = go False cmdline - go _ (Set hn field) = setPrivData hn field - go _ (AddKey keyid) = addKey keyid - go _ (Chain hn) = withprops hn $ \attr ps -> do - r <- runPropellor attr $ ensureProperties ps - putStrLn $ "\n" ++ show r - go _ (Docker hn) = Docker.chain hn - go True cmdline@(Spin _) = buildFirst cmdline $ go False cmdline - go True cmdline = updateFirst cmdline $ go False cmdline - go False (Spin hn) = withprops hn $ const . const $ spin hn - go False (Run hn) = ifM ((==) 0 <$> getRealUserID) - ( onlyProcess $ withprops hn mainProperties - , go True (Spin hn) - ) - go False (Boot hn) = onlyProcess $ withprops hn boot - - withprops :: HostName -> (Attr -> [Property] -> IO ()) -> IO () - withprops hn a = maybe - (unknownhost hn) - (\h -> a (hostAttr h) (hostProperties h)) - (findHost hostlist hn) - -onlyProcess :: IO a -> IO a -onlyProcess a = bracket lock unlock (const a) - where - lock = do - l <- createFile lockfile stdFileMode - setLock l (WriteLock, AbsoluteSeek, 0, 0) - `catchIO` const alreadyrunning - return l - unlock = closeFd - alreadyrunning = error "Propellor is already running on this host!" - lockfile = localdir ".lock" - -unknownhost :: HostName -> IO a -unknownhost h = errorMessage $ unlines - [ "Propellor does not know about host: " ++ h - , "(Perhaps you should specify the real hostname on the command line?)" - , "(Or, edit propellor's config.hs to configure this host)" - ] - -buildFirst :: CmdLine -> IO () -> IO () -buildFirst cmdline next = do - oldtime <- getmtime - ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) - ( do - newtime <- getmtime - if newtime == oldtime - then next - else void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] - , errorMessage "Propellor build failed!" - ) - where - getmtime = catchMaybeIO $ getModificationTime "propellor" - -getCurrentBranch :: IO String -getCurrentBranch = takeWhile (/= '\n') - <$> readProcess "git" ["symbolic-ref", "--short", "HEAD"] - -updateFirst :: CmdLine -> IO () -> IO () -updateFirst cmdline next = do - branchref <- getCurrentBranch - let originbranch = "origin" branchref - - void $ actionMessage "Git fetch" $ boolSystem "git" [Param "fetch"] - - whenM (doesFileExist keyring) $ do - {- To verify origin branch commit's signature, have to - - convince gpg to use our keyring. While running git log. - - Which has no way to pass options to gpg. - - Argh! -} - let gpgconf = privDataDir "gpg.conf" - writeFile gpgconf $ unlines - [ " keyring " ++ keyring - , "no-auto-check-trustdb" - ] - -- gpg is picky about perms - modifyFileMode privDataDir (removeModes otherGroupModes) - s <- readProcessEnv "git" ["log", "-n", "1", "--format=%G?", originbranch] - (Just [("GNUPGHOME", privDataDir)]) - nukeFile $ privDataDir "trustdb.gpg" - nukeFile $ privDataDir "pubring.gpg" - nukeFile $ privDataDir "gpg.conf" - if s == "U\n" || s == "G\n" - then do - putStrLn $ "git branch " ++ originbranch ++ " gpg signature verified; merging" - hFlush stdout - else errorMessage $ "git branch " ++ originbranch ++ " is not signed with a trusted gpg key; refusing to deploy it!" - - oldsha <- getCurrentGitSha1 branchref - void $ boolSystem "git" [Param "merge", Param originbranch] - newsha <- getCurrentGitSha1 branchref - - if oldsha == newsha - then next - else ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) - ( void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] - , errorMessage "Propellor build failed!" - ) - -getCurrentGitSha1 :: String -> IO String -getCurrentGitSha1 branchref = readProcess "git" ["show-ref", "--hash", branchref] - -spin :: HostName -> IO () -spin hn = do - url <- getUrl - void $ gitCommit [Param "--allow-empty", Param "-a", Param "-m", Param "propellor spin"] - void $ boolSystem "git" [Param "push"] - cacheparams <- toCommand <$> sshCachingParams hn - go cacheparams url =<< gpgDecrypt (privDataFile hn) - where - go cacheparams url privdata = withBothHandles createProcessSuccess (proc "ssh" $ cacheparams ++ [user, bootstrapcmd]) $ \(toh, fromh) -> do - let finish = do - senddata toh (privDataFile hn) privDataMarker privdata - hClose toh - - -- Display remaining output. - void $ tryIO $ forever $ - showremote =<< hGetLine fromh - hClose fromh - status <- getstatus fromh `catchIO` (const $ errorMessage "protocol error (perhaps the remote propellor failed to run?)") - case status of - Ready -> finish - NeedGitClone -> do - hClose toh - hClose fromh - sendGitClone hn url - go cacheparams url privdata - - user = "root@"++hn - - bootstrapcmd = shellWrap $ intercalate " ; " - [ "if [ ! -d " ++ localdir ++ " ]" - , "then " ++ intercalate " && " - [ "apt-get --no-install-recommends --no-upgrade -y install git make" - , "echo " ++ toMarked statusMarker (show NeedGitClone) - ] - , "else " ++ intercalate " && " - [ "cd " ++ localdir - , "if ! test -x ./propellor; then make deps build; fi" - , "./propellor --boot " ++ hn - ] - , "fi" - ] - - getstatus :: Handle -> IO BootStrapStatus - getstatus h = do - l <- hGetLine h - case readish =<< fromMarked statusMarker l of - Nothing -> do - showremote l - getstatus h - Just status -> return status - - showremote s = putStrLn s - senddata toh f marker s = void $ - actionMessage ("Sending " ++ f ++ " (" ++ show (length s) ++ " bytes) to " ++ hn) $ do - sendMarked toh marker s - return True - -sendGitClone :: HostName -> String -> IO () -sendGitClone hn url = void $ actionMessage ("Pushing git repository to " ++ hn) $ do - branch <- getCurrentBranch - cacheparams <- sshCachingParams hn - withTmpFile "propellor.git" $ \tmp _ -> allM id - [ boolSystem "git" [Param "bundle", Param "create", File tmp, Param "HEAD"] - , boolSystem "scp" $ cacheparams ++ [File tmp, Param ("root@"++hn++":"++remotebundle)] - , boolSystem "ssh" $ cacheparams ++ [Param ("root@"++hn), Param $ unpackcmd branch] - ] - where - remotebundle = "/usr/local/propellor.git" - unpackcmd branch = shellWrap $ intercalate " && " - [ "git clone " ++ remotebundle ++ " " ++ localdir - , "cd " ++ localdir - , "git checkout -b " ++ branch - , "git remote rm origin" - , "rm -f " ++ remotebundle - , "git remote add origin " ++ url - -- same as --set-upstream-to, except origin branch - -- has not been pulled yet - , "git config branch."++branch++".remote origin" - , "git config branch."++branch++".merge refs/heads/"++branch - ] - -data BootStrapStatus = Ready | NeedGitClone - deriving (Read, Show, Eq) - -type Marker = String -type Marked = String - -statusMarker :: Marker -statusMarker = "STATUS" - -privDataMarker :: String -privDataMarker = "PRIVDATA " - -toMarked :: Marker -> String -> String -toMarked marker = intercalate "\n" . map (marker ++) . lines - -sendMarked :: Handle -> Marker -> String -> IO () -sendMarked h marker s = do - -- Prefix string with newline because sometimes a - -- incomplete line is output. - hPutStrLn h ("\n" ++ toMarked marker s) - hFlush h - -fromMarked :: Marker -> Marked -> Maybe String -fromMarked marker s - | null matches = Nothing - | otherwise = Just $ intercalate "\n" $ - map (drop len) matches - where - len = length marker - matches = filter (marker `isPrefixOf`) $ lines s - -boot :: Attr -> [Property] -> IO () -boot attr ps = do - sendMarked stdout statusMarker $ show Ready - reply <- hGetContentsStrict stdin - - makePrivDataDir - maybe noop (writeFileProtected privDataLocal) $ - fromMarked privDataMarker reply - mainProperties attr ps - -addKey :: String -> IO () -addKey keyid = exitBool =<< allM id [ gpg, gitadd, gitconfig, gitcommit ] - where - gpg = do - createDirectoryIfMissing True privDataDir - boolSystem "sh" - [ Param "-c" - , Param $ "gpg --export " ++ keyid ++ " | gpg " ++ - unwords (gpgopts ++ ["--import"]) - ] - gitadd = boolSystem "git" - [ Param "add" - , File keyring - ] - - gitconfig = boolSystem "git" - [ Param "config" - , Param "user.signingkey" - , Param keyid - ] - - gitcommit = gitCommit - [ File keyring - , Param "-m" - , Param "propellor addkey" - ] - -{- Automatically sign the commit if there'a a keyring. -} -gitCommit :: [CommandParam] -> IO Bool -gitCommit ps = do - k <- doesFileExist keyring - boolSystem "git" $ catMaybes $ - [ Just (Param "commit") - , if k then Just (Param "--gpg-sign") else Nothing - ] ++ map Just ps - -keyring :: FilePath -keyring = privDataDir "keyring.gpg" - -gpgopts :: [String] -gpgopts = ["--options", "/dev/null", "--no-default-keyring", "--keyring", keyring] - -getUrl :: IO String -getUrl = maybe nourl return =<< getM get urls - where - urls = ["remote.deploy.url", "remote.origin.url"] - nourl = errorMessage $ "Cannot find deploy url in " ++ show urls - get u = do - v <- catchMaybeIO $ - takeWhile (/= '\n') - <$> readProcess "git" ["config", u] - return $ case v of - Just url | not (null url) -> Just url - _ -> Nothing - -checkDebugMode :: IO () -checkDebugMode = go =<< getEnv "PROPELLOR_DEBUG" - where - go (Just s) - | s == "1" = do - f <- setFormatter - <$> streamHandler stderr DEBUG - <*> pure (simpleLogFormatter "[$time] $msg") - updateGlobalLogger rootLoggerName $ - setLevel DEBUG . setHandlers [f] - go _ = noop - --- Parameters can be passed to both ssh and scp, to enable a ssh connection --- caching socket. --- --- If the socket already exists, check if its mtime is older than 10 --- minutes, and if so stop that ssh process, in order to not try to --- use an old stale connection. (atime would be nicer, but there's --- a good chance a laptop uses noatime) -sshCachingParams :: HostName -> IO [CommandParam] -sshCachingParams hn = do - home <- myHomeDir - let cachedir = home ".ssh" "propellor" - createDirectoryIfMissing False cachedir - let socketfile = cachedir hn ++ ".sock" - let ps = - [ Param "-o", Param ("ControlPath=" ++ socketfile) - , Params "-o ControlMaster=auto -o ControlPersist=yes" - ] - - maybe noop (expireold ps socketfile) - =<< catchMaybeIO (getFileStatus socketfile) - - return ps - - where - expireold ps f s = do - now <- truncate <$> getPOSIXTime :: IO Integer - if modificationTime s > fromIntegral now - tenminutes - then touchFile f - else do - void $ boolSystem "ssh" $ - [ Params "-O stop" ] ++ ps ++ - [ Param "localhost" ] - nukeFile f - tenminutes = 600 diff --git a/Propellor/Engine.hs b/Propellor/Engine.hs deleted file mode 100644 index 55ce7f77..00000000 --- a/Propellor/Engine.hs +++ /dev/null @@ -1,37 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Engine where - -import System.Exit -import System.IO -import Data.Monoid -import System.Console.ANSI -import "mtl" Control.Monad.Reader - -import Propellor.Types -import Propellor.Message -import Propellor.Exception - -runPropellor :: Attr -> Propellor a -> IO a -runPropellor attr a = runReaderT (runWithAttr a) attr - -mainProperties :: Attr -> [Property] -> IO () -mainProperties attr ps = do - r <- runPropellor attr $ - ensureProperties [Property "overall" (ensureProperties ps) id] - setTitle "propellor: done" - hFlush stdout - case r of - FailedChange -> exitWith (ExitFailure 1) - _ -> exitWith ExitSuccess - -ensureProperties :: [Property] -> Propellor Result -ensureProperties ps = ensure ps NoChange - where - ensure [] rs = return rs - ensure (l:ls) rs = do - r <- actionMessage (propertyDesc l) (ensureProperty l) - ensure ls (r <> rs) - -ensureProperty :: Property -> Propellor Result -ensureProperty = catchPropellor . propertySatisfy diff --git a/Propellor/Exception.hs b/Propellor/Exception.hs deleted file mode 100644 index f6fd15f1..00000000 --- a/Propellor/Exception.hs +++ /dev/null @@ -1,18 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Exception where - -import qualified "MonadCatchIO-transformers" Control.Monad.CatchIO as M -import Control.Exception - -import Propellor.Types -import Propellor.Message - --- | Catches IO exceptions and returns FailedChange. -catchPropellor :: Propellor Result -> Propellor Result -catchPropellor a = either err return =<< tryPropellor a - where - err e = warningMessage (show e) >> return FailedChange - -tryPropellor :: Propellor a -> Propellor (Either IOException a) -tryPropellor = M.try diff --git a/Propellor/Message.hs b/Propellor/Message.hs deleted file mode 100644 index 780471c3..00000000 --- a/Propellor/Message.hs +++ /dev/null @@ -1,51 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Message where - -import System.Console.ANSI -import System.IO -import System.Log.Logger -import "mtl" Control.Monad.Reader - -import Propellor.Types - --- | Shows a message while performing an action, with a colored status --- display. -actionMessage :: (MonadIO m, ActionResult r) => Desc -> m r -> m r -actionMessage desc a = do - liftIO $ do - setTitle $ "propellor: " ++ desc - hFlush stdout - - r <- a - - liftIO $ do - setTitle "propellor: running" - let (msg, intensity, color) = getActionResult r - putStr $ desc ++ " ... " - colorLine intensity color msg - hFlush stdout - - return r - -warningMessage :: MonadIO m => String -> m () -warningMessage s = liftIO $ colorLine Vivid Magenta $ "** warning: " ++ s - -colorLine :: ColorIntensity -> Color -> String -> IO () -colorLine intensity color msg = do - setSGR [SetColor Foreground intensity color] - putStr msg - setSGR [] - -- Note this comes after the color is reset, so that - -- the color set and reset happen in the same line. - putStrLn "" - hFlush stdout - -errorMessage :: String -> IO a -errorMessage s = do - liftIO $ colorLine Vivid Red $ "** error: " ++ s - error "Cannot continue!" - --- | Causes a debug message to be displayed when PROPELLOR_DEBUG=1 -debug :: [String] -> IO () -debug = debugM "propellor" . unwords diff --git a/Propellor/PrivData.hs b/Propellor/PrivData.hs deleted file mode 100644 index ad2c8d22..00000000 --- a/Propellor/PrivData.hs +++ /dev/null @@ -1,91 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.PrivData where - -import qualified Data.Map as M -import Control.Applicative -import System.FilePath -import System.IO -import System.Directory -import Data.Maybe -import Data.List -import Control.Monad -import "mtl" Control.Monad.Reader - -import Propellor.Types -import Propellor.Attr -import Propellor.Message -import Utility.Monad -import Utility.PartialPrelude -import Utility.Exception -import Utility.Process -import Utility.Tmp -import Utility.SafeCommand -import Utility.Misc - --- | When the specified PrivDataField is available on the host Propellor --- is provisioning, it provies the data to the action. Otherwise, it prints --- a message to help the user make the necessary private data available. -withPrivData :: PrivDataField -> (String -> Propellor Result) -> Propellor Result -withPrivData field a = maybe missing a =<< liftIO (getPrivData field) - where - missing = do - host <- getHostName - let host' = if ".docker" `isSuffixOf` host - then "$parent_host" - else host - liftIO $ do - warningMessage $ "Missing privdata " ++ show field - putStrLn $ "Fix this by running: propellor --set "++host'++" '" ++ show field ++ "'" - return FailedChange - -getPrivData :: PrivDataField -> IO (Maybe String) -getPrivData field = do - m <- catchDefaultIO Nothing $ readish <$> readFile privDataLocal - return $ maybe Nothing (M.lookup field) m - -setPrivData :: HostName -> PrivDataField -> IO () -setPrivData host field = do - putStrLn "Enter private data on stdin; ctrl-D when done:" - value <- chomp <$> hGetContentsStrict stdin - makePrivDataDir - let f = privDataFile host - m <- fromMaybe M.empty . readish <$> gpgDecrypt f - let m' = M.insert field value m - gpgEncrypt f (show m') - putStrLn "Private data set." - void $ boolSystem "git" [Param "add", File f] - where - chomp s - | end s == "\n" = chomp (beginning s) - | otherwise = s - -makePrivDataDir :: IO () -makePrivDataDir = createDirectoryIfMissing False privDataDir - -privDataDir :: FilePath -privDataDir = "privdata" - -privDataFile :: HostName -> FilePath -privDataFile host = privDataDir host ++ ".gpg" - -privDataLocal :: FilePath -privDataLocal = privDataDir "local" - -gpgDecrypt :: FilePath -> IO String -gpgDecrypt f = ifM (doesFileExist f) - ( readProcess "gpg" ["--decrypt", f] - , return "" - ) - -gpgEncrypt :: FilePath -> String -> IO () -gpgEncrypt f s = do - encrypted <- writeReadProcessEnv "gpg" - [ "--default-recipient-self" - , "--armor" - , "--encrypt" - ] - Nothing - (Just $ flip hPutStr s) - Nothing - viaTmp writeFile f encrypted diff --git a/Propellor/Property.hs b/Propellor/Property.hs deleted file mode 100644 index 24494654..00000000 --- a/Propellor/Property.hs +++ /dev/null @@ -1,163 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Property where - -import System.Directory -import Control.Monad -import Data.Monoid -import Data.List -import Control.Monad.IfElse -import "mtl" Control.Monad.Reader - -import Propellor.Types -import Propellor.Types.Attr -import Propellor.Attr -import Propellor.Engine -import Utility.Monad -import System.FilePath - --- Constructs a Property. -property :: Desc -> Propellor Result -> Property -property d s = Property d s id - --- | Combines a list of properties, resulting in a single property --- that when run will run each property in the list in turn, --- and print out the description of each as it's run. Does not stop --- on failure; does propigate overall success/failure. -propertyList :: Desc -> [Property] -> Property -propertyList desc ps = Property desc (ensureProperties ps) (combineSetAttrs ps) - --- | Combines a list of properties, resulting in one property that --- ensures each in turn, stopping on failure. -combineProperties :: Desc -> [Property] -> Property -combineProperties desc ps = Property desc (go ps NoChange) (combineSetAttrs ps) - where - go [] rs = return rs - go (l:ls) rs = do - r <- ensureProperty l - case r of - FailedChange -> return FailedChange - _ -> go ls (r <> rs) - --- | Combines together two properties, resulting in one property --- that ensures the first, and if the first succeeds, ensures the second. --- The property uses the description of the first property. -before :: Property -> Property -> Property -p1 `before` p2 = p2 `requires` p1 - `describe` (propertyDesc p1) - --- | Makes a perhaps non-idempotent Property be idempotent by using a flag --- file to indicate whether it has run before. --- Use with caution. -flagFile :: Property -> FilePath -> Property -flagFile p = flagFile' p . return - -flagFile' :: Property -> IO FilePath -> Property -flagFile' p getflagfile = adjustProperty p $ \satisfy -> do - flagfile <- liftIO getflagfile - go satisfy flagfile =<< liftIO (doesFileExist flagfile) - where - go _ _ True = return NoChange - go satisfy flagfile False = do - r <- satisfy - when (r == MadeChange) $ liftIO $ - unlessM (doesFileExist flagfile) $ do - createDirectoryIfMissing True (takeDirectory flagfile) - writeFile flagfile "" - return r - ---- | Whenever a change has to be made for a Property, causes a hook --- Property to also be run, but not otherwise. -onChange :: Property -> Property -> Property -p `onChange` hook = Property (propertyDesc p) satisfy (combineSetAttr p hook) - where - satisfy = do - r <- ensureProperty p - case r of - MadeChange -> do - r' <- ensureProperty hook - return $ r <> r' - _ -> return r - -(==>) :: Desc -> Property -> Property -(==>) = flip describe -infixl 1 ==> - --- | Makes a Property only need to do anything when a test succeeds. -check :: IO Bool -> Property -> Property -check c p = adjustProperty p $ \satisfy -> ifM (liftIO c) - ( satisfy - , return NoChange - ) - --- | Marks a Property as trivial. It can only return FailedChange or --- NoChange. --- --- Useful when it's just as expensive to check if a change needs --- to be made as it is to just idempotently assure the property is --- satisfied. For example, chmodding a file. -trivial :: Property -> Property -trivial p = adjustProperty p $ \satisfy -> do - r <- satisfy - if r == MadeChange - then return NoChange - else return r - --- | Makes a property that is satisfied differently depending on the host's --- operating system. --- --- Note that the operating system may not be declared for some hosts. -withOS :: Desc -> (Maybe System -> Propellor Result) -> Property -withOS desc a = property desc $ a =<< getOS - -boolProperty :: Desc -> IO Bool -> Property -boolProperty desc a = property desc $ ifM (liftIO a) - ( return MadeChange - , return FailedChange - ) - --- | Undoes the effect of a property. -revert :: RevertableProperty -> RevertableProperty -revert (RevertableProperty p1 p2) = RevertableProperty p2 p1 - --- | Starts accumulating the properties of a Host. --- --- > host "example.com" --- > & someproperty --- > ! oldproperty --- > & otherproperty -host :: HostName -> Host -host hn = Host [] (\_ -> newAttr hn) - --- | Adds a property to a Host --- --- Can add Properties and RevertableProperties -(&) :: IsProp p => Host -> p -> Host -(Host ps as) & p = Host (ps ++ [toProp p]) (setAttr p . as) - -infixl 1 & - --- | Adds a property to the Host in reverted form. -(!) :: Host -> RevertableProperty -> Host -(Host ps as) ! p = Host (ps ++ [toProp q]) (setAttr q . as) - where - q = revert p - -infixl 1 ! - --- Changes the action that is performed to satisfy a property. -adjustProperty :: Property -> (Propellor Result -> Propellor Result) -> Property -adjustProperty p f = p { propertySatisfy = f (propertySatisfy p) } - --- Combines the Attr settings of two properties. -combineSetAttr :: (IsProp p, IsProp q) => p -> q -> SetAttr -combineSetAttr p q = setAttr p . setAttr q - -combineSetAttrs :: IsProp p => [p] -> SetAttr -combineSetAttrs = foldl' (.) id . map setAttr - -makeChange :: IO () -> Propellor Result -makeChange a = liftIO a >> return MadeChange - -noChange :: Propellor Result -noChange = return NoChange diff --git a/Propellor/Property/Apache.hs b/Propellor/Property/Apache.hs deleted file mode 100644 index cf3e62cc..00000000 --- a/Propellor/Property/Apache.hs +++ /dev/null @@ -1,62 +0,0 @@ -module Propellor.Property.Apache where - -import Propellor -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Service as Service - -type ConfigFile = [String] - -siteEnabled :: HostName -> ConfigFile -> RevertableProperty -siteEnabled hn cf = RevertableProperty enable disable - where - enable = trivial $ cmdProperty "a2ensite" ["--quiet", hn] - `describe` ("apache site enabled " ++ hn) - `requires` siteAvailable hn cf - `requires` installed - `onChange` reloaded - disable = trivial $ File.notPresent (siteCfg hn) - `describe` ("apache site disabled " ++ hn) - `onChange` cmdProperty "a2dissite" ["--quiet", hn] - `requires` installed - `onChange` reloaded - -siteAvailable :: HostName -> ConfigFile -> Property -siteAvailable hn cf = siteCfg hn `File.hasContent` (comment:cf) - `describe` ("apache site available " ++ hn) - where - comment = "# deployed with propellor, do not modify" - -modEnabled :: String -> RevertableProperty -modEnabled modname = RevertableProperty enable disable - where - enable = trivial $ cmdProperty "a2enmod" ["--quiet", modname] - `describe` ("apache module enabled " ++ modname) - `requires` installed - `onChange` reloaded - disable = trivial $ cmdProperty "a2dismod" ["--quiet", modname] - `describe` ("apache module disabled " ++ modname) - `requires` installed - `onChange` reloaded - -siteCfg :: HostName -> FilePath -siteCfg hn = "/etc/apache2/sites-available/" ++ hn - -installed :: Property -installed = Apt.installed ["apache2"] - -restarted :: Property -restarted = cmdProperty "service" ["apache2", "restart"] - -reloaded :: Property -reloaded = Service.reloaded "apache2" - --- | Configure apache to use SNI to differentiate between --- https hosts. -multiSSL :: Property -multiSSL = "/etc/apache2/conf.d/ssl" `File.hasContent` - [ "NameVirtualHost *:443" - , "SSLStrictSNIVHostCheck off" - ] - `describe` "apache SNI enabled" - `onChange` reloaded diff --git a/Propellor/Property/Apt.hs b/Propellor/Property/Apt.hs deleted file mode 100644 index 7329c7a8..00000000 --- a/Propellor/Property/Apt.hs +++ /dev/null @@ -1,256 +0,0 @@ -module Propellor.Property.Apt where - -import Data.Maybe -import Control.Applicative -import Data.List -import System.IO -import Control.Monad - -import Propellor -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Service as Service -import Propellor.Property.File (Line) - -sourcesList :: FilePath -sourcesList = "/etc/apt/sources.list" - -type Url = String -type Section = String - -type SourcesGenerator = DebianSuite -> [Line] - -showSuite :: DebianSuite -> String -showSuite Stable = "stable" -showSuite Testing = "testing" -showSuite Unstable = "unstable" -showSuite Experimental = "experimental" -showSuite (DebianRelease r) = r - -backportSuite :: String -backportSuite = showSuite stableRelease ++ "-backports" - -debLine :: String -> Url -> [Section] -> Line -debLine suite mirror sections = unwords $ - ["deb", mirror, suite] ++ sections - -srcLine :: Line -> Line -srcLine l = case words l of - ("deb":rest) -> unwords $ "deb-src" : rest - _ -> "" - -stdSections :: [Section] -stdSections = ["main", "contrib", "non-free"] - -binandsrc :: String -> SourcesGenerator -binandsrc url suite - | isStable suite = [l, srcLine l, bl, srcLine bl] - | otherwise = [l, srcLine l] - where - l = debLine (showSuite suite) url stdSections - bl = debLine backportSuite url stdSections - -debCdn :: SourcesGenerator -debCdn = binandsrc "http://cdn.debian.net/debian" - -kernelOrg :: SourcesGenerator -kernelOrg = binandsrc "http://mirrors.kernel.org/debian" - --- | Only available for Stable and Testing -securityUpdates :: SourcesGenerator -securityUpdates suite - | isStable suite || suite == Testing = - let l = "deb http://security.debian.org/ " ++ showSuite suite ++ "/updates " ++ unwords stdSections - in [l, srcLine l] - | otherwise = [] - --- | Makes sources.list have a standard content using the mirror CDN, --- with a particular DebianSuite. --- --- Since the CDN is sometimes unreliable, also adds backup lines using --- kernel.org. -stdSourcesList :: DebianSuite -> Property -stdSourcesList suite = stdSourcesList' suite [] - --- | Adds additional sources.list generators. --- --- Note that if a Property needs to enable an apt source, it's better --- to do so via a separate file in /etc/apt/sources.list.d/ -stdSourcesList' :: DebianSuite -> [SourcesGenerator] -> Property -stdSourcesList' suite more = setSourcesList - (concatMap (\gen -> gen suite) generators) - `describe` ("standard sources.list for " ++ show suite) - where - generators = [debCdn, kernelOrg, securityUpdates] ++ more - -setSourcesList :: [Line] -> Property -setSourcesList ls = sourcesList `File.hasContent` ls `onChange` update - -setSourcesListD :: [Line] -> FilePath -> Property -setSourcesListD ls basename = f `File.hasContent` ls `onChange` update - where - f = "/etc/apt/sources.list.d/" ++ basename ++ ".list" - -runApt :: [String] -> Property -runApt ps = cmdProperty' "apt-get" ps noninteractiveEnv - -noninteractiveEnv :: [(String, String)] -noninteractiveEnv = - [ ("DEBIAN_FRONTEND", "noninteractive") - , ("APT_LISTCHANGES_FRONTEND", "none") - ] - -update :: Property -update = runApt ["update"] - `describe` "apt update" - -upgrade :: Property -upgrade = runApt ["-y", "dist-upgrade"] - `describe` "apt dist-upgrade" - -type Package = String - -installed :: [Package] -> Property -installed = installed' ["-y"] - -installed' :: [String] -> [Package] -> Property -installed' params ps = robustly $ check (isInstallable ps) go - `describe` (unwords $ "apt installed":ps) - where - go = runApt $ params ++ ["install"] ++ ps - -installedBackport :: [Package] -> Property -installedBackport ps = trivial $ withOS desc $ \o -> case o of - Nothing -> error "cannot install backports; os not declared" - (Just (System (Debian suite) _)) - | isStable suite -> - ensureProperty $ runApt $ - ["install", "-t", backportSuite, "-y"] ++ ps - _ -> error $ "backports not supported on " ++ show o - where - desc = (unwords $ "apt installed backport":ps) - --- | Minimal install of package, without recommends. -installedMin :: [Package] -> Property -installedMin = installed' ["--no-install-recommends", "-y"] - -removed :: [Package] -> Property -removed ps = check (or <$> isInstalled' ps) go - `describe` (unwords $ "apt removed":ps) - where - go = runApt $ ["-y", "remove"] ++ ps - -buildDep :: [Package] -> Property -buildDep ps = robustly go - `describe` (unwords $ "apt build-dep":ps) - where - go = runApt $ ["-y", "build-dep"] ++ ps - --- | Installs the build deps for the source package unpacked --- in the specifed directory, with a dummy package also --- installed so that autoRemove won't remove them. -buildDepIn :: FilePath -> Property -buildDepIn dir = go `requires` installedMin ["devscripts", "equivs"] - where - go = cmdProperty' "sh" ["-c", "cd '" ++ dir ++ "' && mk-build-deps debian/control --install --tool 'apt-get -y --no-install-recommends' --remove"] - noninteractiveEnv - --- | Package installation may fail becuse the archive has changed. --- Run an update in that case and retry. -robustly :: Property -> Property -robustly p = adjustProperty p $ \satisfy -> do - r <- satisfy - if r == FailedChange - then ensureProperty $ p `requires` update - else return r - -isInstallable :: [Package] -> IO Bool -isInstallable ps = do - l <- isInstalled' ps - return $ any (== False) l && not (null l) - -isInstalled :: Package -> IO Bool -isInstalled p = (== [True]) <$> isInstalled' [p] - --- | Note that the order of the returned list will not always --- correspond to the order of the input list. The number of items may --- even vary. If apt does not know about a package at all, it will not --- be included in the result list. -isInstalled' :: [Package] -> IO [Bool] -isInstalled' ps = catMaybes . map parse . lines - <$> readProcess "apt-cache" ("policy":ps) - where - parse l - | "Installed: (none)" `isInfixOf` l = Just False - | "Installed: " `isInfixOf` l = Just True - | otherwise = Nothing - -autoRemove :: Property -autoRemove = runApt ["-y", "autoremove"] - `describe` "apt autoremove" - --- | Enables unattended upgrades. Revert to disable. -unattendedUpgrades :: RevertableProperty -unattendedUpgrades = RevertableProperty enable disable - where - enable = setup True - `before` Service.running "cron" - `before` configure - disable = setup False - - setup enabled = (if enabled then installed else removed) ["unattended-upgrades"] - `onChange` reConfigure "unattended-upgrades" - [("unattended-upgrades/enable_auto_updates" , "boolean", v)] - `describe` ("unattended upgrades " ++ v) - where - v - | enabled = "true" - | otherwise = "false" - - configure = withOS "unattended upgrades configured" $ \o -> - case o of - -- the package defaults to only upgrading stable - (Just (System (Debian suite) _)) - | not (isStable suite) -> ensureProperty $ - "/etc/apt/apt.conf.d/50unattended-upgrades" - `File.containsLine` - ("\t\"o=Debian,a="++showSuite suite++"\";") - _ -> noChange - --- | Preseeds debconf values and reconfigures the package so it takes --- effect. -reConfigure :: Package -> [(String, String, String)] -> Property -reConfigure package vals = reconfigure `requires` setselections - `describe` ("reconfigure " ++ package) - where - setselections = property "preseed" $ makeChange $ - withHandle StdinHandle createProcessSuccess - (proc "debconf-set-selections" []) $ \h -> do - forM_ vals $ \(tmpl, tmpltype, value) -> - hPutStrLn h $ unwords [package, tmpl, tmpltype, value] - hClose h - reconfigure = cmdProperty' "dpkg-reconfigure" ["-fnone", package] noninteractiveEnv - --- | Ensures that a service is installed and running. --- --- Assumes that there is a 1:1 mapping between service names and apt --- package names. -serviceInstalledRunning :: Package -> Property -serviceInstalledRunning svc = Service.running svc `requires` installed [svc] - -data AptKey = AptKey - { keyname :: String - , pubkey :: String - } - -trustsKey :: AptKey -> RevertableProperty -trustsKey k = RevertableProperty trust untrust - where - desc = "apt trusts key " ++ keyname k - f = "/etc/apt/trusted.gpg.d" keyname k ++ ".gpg" - untrust = File.notPresent f - trust = check (not <$> doesFileExist f) $ property desc $ makeChange $ do - withHandle StdinHandle createProcessSuccess - (proc "gpg" ["--no-default-keyring", "--keyring", f, "--import", "-"]) $ \h -> do - hPutStr h (pubkey k) - hClose h - nukeFile $ f ++ "~" -- gpg dropping diff --git a/Propellor/Property/Cmd.hs b/Propellor/Property/Cmd.hs deleted file mode 100644 index bcd08246..00000000 --- a/Propellor/Property/Cmd.hs +++ /dev/null @@ -1,49 +0,0 @@ -{-# LANGUAGE PackageImports #-} - -module Propellor.Property.Cmd ( - cmdProperty, - cmdProperty', - scriptProperty, - userScriptProperty, -) where - -import Control.Applicative -import Data.List -import "mtl" Control.Monad.Reader - -import Propellor.Types -import Propellor.Property -import Utility.Monad -import Utility.SafeCommand -import Utility.Env - --- | A property that can be satisfied by running a command. --- --- The command must exit 0 on success. -cmdProperty :: String -> [String] -> Property -cmdProperty cmd params = cmdProperty' cmd params [] - --- | A property that can be satisfied by running a command, --- with added environment. -cmdProperty' :: String -> [String] -> [(String, String)] -> Property -cmdProperty' cmd params env = property desc $ liftIO $ do - env' <- addEntries env <$> getEnvironment - ifM (boolSystemEnv cmd (map Param params) (Just env')) - ( return MadeChange - , return FailedChange - ) - where - desc = unwords $ cmd : params - --- | A property that can be satisfied by running a series of shell commands. -scriptProperty :: [String] -> Property -scriptProperty script = cmdProperty "sh" ["-c", shellcmd] - where - shellcmd = intercalate " ; " ("set -e" : script) - --- | A property that can satisfied by running a series of shell commands, --- as user (cd'd to their home directory). -userScriptProperty :: UserName -> [String] -> Property -userScriptProperty user script = cmdProperty "su" ["-c", shellcmd, user] - where - shellcmd = intercalate " ; " ("set -e" : "cd" : script) diff --git a/Propellor/Property/Cron.hs b/Propellor/Property/Cron.hs deleted file mode 100644 index 5b070eff..00000000 --- a/Propellor/Property/Cron.hs +++ /dev/null @@ -1,49 +0,0 @@ -module Propellor.Property.Cron where - -import Propellor -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Apt as Apt -import Utility.SafeCommand - -import Data.Char - -type CronTimes = String - --- | Installs a cron job, run as a specified user, in a particular --- directory. Note that the Desc must be unique, as it is used for the --- cron.d/ filename. --- --- Only one instance of the cron job is allowed to run at a time, no matter --- how long it runs. This is accomplished using flock locking of the cron --- job file. --- --- The cron job's output will only be emailed if it exits nonzero. -job :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property -job desc times user cddir command = cronjobfile `File.hasContent` - [ "# Generated by propellor" - , "" - , "SHELL=/bin/sh" - , "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin" - , "" - , times ++ "\t" ++ user ++ "\t" - ++ "chronic flock -n " ++ shellEscape cronjobfile - ++ " sh -c " ++ shellEscape cmdline - ] - `requires` Apt.serviceInstalledRunning "cron" - `requires` Apt.installed ["util-linux", "moreutils"] - `describe` ("cronned " ++ desc) - where - cmdline = "cd " ++ cddir ++ " && ( " ++ command ++ " )" - cronjobfile = "/etc/cron.d/" ++ map sanitize desc - sanitize c - | isAlphaNum c = c - | otherwise = '_' - --- | Installs a cron job, and runs it niced and ioniced. -niceJob :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property -niceJob desc times user cddir command = job desc times user cddir - ("nice ionice -c 3 " ++ command) - --- | Installs a cron job to run propellor. -runPropellor :: CronTimes -> Property -runPropellor times = niceJob "propellor" times "root" localdir "make" diff --git a/Propellor/Property/Dns.hs b/Propellor/Property/Dns.hs deleted file mode 100644 index 5c3162cb..00000000 --- a/Propellor/Property/Dns.hs +++ /dev/null @@ -1,405 +0,0 @@ -module Propellor.Property.Dns ( - module Propellor.Types.Dns, - primary, - secondary, - secondaryFor, - mkSOA, - writeZoneFile, - nextSerialNumber, - adjustSerialNumber, - serialNumberOffset, - WarningMessage, - genZone, -) where - -import Propellor -import Propellor.Types.Dns -import Propellor.Property.File -import Propellor.Types.Attr -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Service as Service -import Utility.Applicative - -import qualified Data.Map as M -import qualified Data.Set as S -import Data.List - --- | Primary dns server for a domain. --- --- Most of the content of the zone file is configured by setting properties --- of hosts. For example, --- --- > host "foo.example.com" --- > & ipv4 "192.168.1.1" --- > & alias "mail.exmaple.com" --- --- Will cause that hostmame and its alias to appear in the zone file, --- with the configured IP address. --- --- The [(BindDomain, Record)] list can be used for additional records --- that cannot be configured elsewhere. This often includes NS records, --- TXT records and perhaps CNAMEs pointing at hosts that propellor does --- not control. --- --- The primary server is configured to only allow zone transfers to --- secondary dns servers. These are determined in two ways: --- --- 1. By looking at the properties of other hosts, to find hosts that --- are configured as the secondary dns server. --- --- 2. By looking for NS Records in the passed list of records. --- --- In either case, the secondary dns server Host should have an ipv4 and/or --- ipv6 property defined. -primary :: [Host] -> Domain -> SOA -> [(BindDomain, Record)] -> RevertableProperty -primary hosts domain soa rs = RevertableProperty setup cleanup - where - setup = withwarnings (check needupdate baseprop) - `requires` servingZones - `onChange` Service.reloaded "bind9" - cleanup = check (doesFileExist zonefile) $ - property ("removed dns primary for " ++ domain) - (makeChange $ removeZoneFile zonefile) - `requires` namedConfWritten - `onChange` Service.reloaded "bind9" - - (partialzone, zonewarnings) = genZone hosts domain soa - zone = partialzone { zHosts = zHosts partialzone ++ rs } - zonefile = "/etc/bind/propellor/db." ++ domain - baseprop = Property ("dns primary for " ++ domain) - (makeChange $ writeZoneFile zone zonefile) - (addNamedConf conf) - withwarnings p = adjustProperty p $ \satisfy -> do - mapM_ warningMessage $ zonewarnings ++ secondarywarnings - satisfy - conf = NamedConf - { confDomain = domain - , confDnsServerType = Master - , confFile = zonefile - , confMasters = [] - , confAllowTransfer = nub $ - concatMap (\h -> hostAddresses h hosts) $ - secondaries ++ nssecondaries - , confLines = [] - } - secondaries = otherServers Secondary hosts domain - secondarywarnings = map (\h -> "No IP address defined for DNS seconary " ++ h) $ - filter (\h -> null (hostAddresses h hosts)) secondaries - nssecondaries = mapMaybe (domainHostName <=< getNS) rootRecords - rootRecords = map snd $ - filter (\(d, _r) -> d == RootDomain || d == AbsDomain domain) rs - needupdate = do - v <- readZonePropellorFile zonefile - return $ case v of - Nothing -> True - Just oldzone -> - -- compare everything except serial - let oldserial = sSerialĀ (zSOA oldzone) - z = zone { zSOA = (zSOA zone) { sSerial = oldserial } } - in z /= oldzone || oldserial < sSerial (zSOA zone) - --- | Secondary dns server for a domain. --- --- The primary server is determined by looking at the properties of other --- hosts to find which one is configured as the primary. --- --- Note that if a host is declared to be a primary and a secondary dns --- server for the same domain, the primary server config always wins. -secondary :: [Host] -> Domain -> RevertableProperty -secondary hosts domain = secondaryFor (otherServers Master hosts domain) hosts domain - --- | This variant is useful if the primary server does not have its DNS --- configured via propellor. -secondaryFor :: [HostName] -> [Host] -> Domain -> RevertableProperty -secondaryFor masters hosts domain = RevertableProperty setup cleanup - where - setup = pureAttrProperty desc (addNamedConf conf) - `requires` servingZones - cleanup = namedConfWritten - - desc = "dns secondary for " ++ domain - conf = NamedConf - { confDomain = domain - , confDnsServerType = Secondary - , confFile = "db." ++ domain - , confMasters = concatMap (\m -> hostAddresses m hosts) masters - , confAllowTransfer = [] - , confLines = [] - } - -otherServers :: DnsServerType -> [Host] -> Domain -> [HostName] -otherServers wantedtype hosts domain = - M.keys $ M.filter wanted $ hostAttrMap hosts - where - wanted attr = case M.lookup domain (_namedconf attr) of - Nothing -> False - Just conf -> confDnsServerType conf == wantedtype - && confDomain conf == domain - --- | Rewrites the whole named.conf.local file to serve the zones --- configured by `primary` and `secondary`, and ensures that bind9 is --- running. -servingZones :: Property -servingZones = namedConfWritten - `onChange` Service.reloaded "bind9" - `requires` Apt.serviceInstalledRunning "bind9" - -namedConfWritten :: Property -namedConfWritten = property "named.conf configured" $ do - zs <- getNamedConf - ensureProperty $ - hasContent namedConfFile $ - concatMap confStanza $ M.elems zs - -confStanza :: NamedConf -> [Line] -confStanza c = - [ "// automatically generated by propellor" - , "zone \"" ++ confDomain c ++ "\" {" - , cfgline "type" (if confDnsServerType c == Master then "master" else "slave") - , cfgline "file" ("\"" ++ confFile c ++ "\"") - ] ++ - mastersblock ++ - allowtransferblock ++ - (map (\l -> "\t" ++ l ++ ";") (confLines c)) ++ - [ "};" - , "" - ] - where - cfgline f v = "\t" ++ f ++ " " ++ v ++ ";" - ipblock name l = - [ "\t" ++ name ++ " {" ] ++ - (map (\ip -> "\t\t" ++ fromIPAddr ip ++ ";") l) ++ - [ "\t};" ] - mastersblock - | null (confMasters c) = [] - | otherwise = ipblock "masters" (confMasters c) - -- an empty block prohibits any transfers - allowtransferblock = ipblock "allow-transfer" (confAllowTransfer c) - -namedConfFile :: FilePath -namedConfFile = "/etc/bind/named.conf.local" - --- | Generates a SOA with some fairly sane numbers in it. --- --- The Domain is the domain to use in the SOA record. Typically --- something like ns1.example.com. So, not the domain that this is the SOA --- record for. --- --- The SerialNumber can be whatever serial number was used by the domain --- before propellor started managing it. Or 0 if the domain has only ever --- been managed by propellor. --- --- You do not need to increment the SerialNumber when making changes! --- Propellor will automatically add the number of commits in the git --- repository to the SerialNumber. -mkSOA :: Domain -> SerialNumber -> SOA -mkSOA d sn = SOA - { sDomain = AbsDomain d - , sSerial = sn - , sRefresh = hours 4 - , sRetry = hours 1 - , sExpire = 2419200 -- 4 weeks - , sNegativeCacheTTL = hours 8 - } - where - hours n = n * 60 * 60 - -dValue :: BindDomain -> String -dValue (RelDomain d) = d -dValue (AbsDomain d) = d ++ "." -dValue (RootDomain) = "@" - -rField :: Record -> String -rField (Address (IPv4 _)) = "A" -rField (Address (IPv6 _)) = "AAAA" -rField (CNAME _) = "CNAME" -rField (MX _ _) = "MX" -rField (NS _) = "NS" -rField (TXT _) = "TXT" -rField (SRV _ _ _ _) = "SRV" - -rValue :: Record -> String -rValue (Address (IPv4 addr)) = addr -rValue (Address (IPv6 addr)) = addr -rValue (CNAME d) = dValue d -rValue (MX pri d) = show pri ++ " " ++ dValue d -rValue (NS d) = dValue d -rValue (SRV priority weight port target) = unwords - [ show priority - , show weight - , show port - , dValue target - ] -rValue (TXT s) = [q] ++ filter (/= q) s ++ [q] - where - q = '"' - --- | Adjusts the serial number of the zone to always be larger --- than the serial number in the Zone record, --- and always be larger than the passed SerialNumber. -nextSerialNumber :: Zone -> SerialNumber -> Zone -nextSerialNumber z serial = adjustSerialNumber z $ \sn -> succ $ max sn serial - -adjustSerialNumber :: Zone -> (SerialNumber -> SerialNumber) -> Zone -adjustSerialNumber (Zone d soa l) f = Zone d soa' l - where - soa' = soa { sSerial = f (sSerial soa) } - --- | Count the number of git commits made to the current branch. -serialNumberOffset :: IO SerialNumber -serialNumberOffset = fromIntegral . length . lines - <$> readProcess "git" ["log", "--pretty=%H"] - --- | Write a Zone out to a to a file. --- --- The serial number in the Zone automatically has the serialNumberOffset --- added to it. Also, just in case, the old serial number used in the zone --- file is checked, and if it is somehow larger, its succ is used. -writeZoneFile :: Zone -> FilePath -> IO () -writeZoneFile z f = do - oldserial <- oldZoneFileSerialNumber f - offset <- serialNumberOffset - let z' = nextSerialNumber - (adjustSerialNumber z (+ offset)) - oldserial - createDirectoryIfMissing True (takeDirectory f) - writeFile f (genZoneFile z') - writeZonePropellorFile f z' - -removeZoneFile :: FilePath -> IO () -removeZoneFile f = do - nukeFile f - nukeFile (zonePropellorFile f) - --- | Next to the zone file, is a ".propellor" file, which contains --- the serialized Zone. This saves the bother of parsing --- the horrible bind zone file format. -zonePropellorFile :: FilePath -> FilePath -zonePropellorFile f = f ++ ".propellor" - -oldZoneFileSerialNumber :: FilePath -> IO SerialNumber -oldZoneFileSerialNumber = maybe 0 (sSerial . zSOA) <$$> readZonePropellorFile - -writeZonePropellorFile :: FilePath -> Zone -> IO () -writeZonePropellorFile f z = writeFile (zonePropellorFile f) (show z) - -readZonePropellorFile :: FilePath -> IO (Maybe Zone) -readZonePropellorFile f = catchDefaultIO Nothing $ - readish <$> readFileStrict (zonePropellorFile f) - --- | Generating a zone file. -genZoneFile :: Zone -> String -genZoneFile (Zone zdomain soa rs) = unlines $ - header : genSOA soa ++ map (genRecord zdomain) rs - where - header = com $ "BIND zone file for " ++ zdomain ++ ". Generated by propellor, do not edit." - -genRecord :: Domain -> (BindDomain, Record) -> String -genRecord zdomain (domain, record) = intercalate "\t" - [ domainHost zdomain domain - , "IN" - , rField record - , rValue record - ] - -genSOA :: SOA -> [String] -genSOA soa = - -- "@ IN SOA ns1.example.com. root (" - [ intercalate "\t" - [ dValue RootDomain - , "IN" - , "SOA" - , dValue (sDomain soa) - , "root" - , "(" - ] - , headerline sSerial "Serial" - , headerline sRefresh "Refresh" - , headerline sRetry "Retry" - , headerline sExpire "Expire" - , headerline sNegativeCacheTTL "Negative Cache TTL" - , inheader ")" - ] - where - headerline r comment = inheader $ show (r soa) ++ "\t\t" ++ com comment - inheader l = "\t\t\t" ++ l - --- | Comment line in a zone file. -com :: String -> String -com s = "; " ++ s - -type WarningMessage = String - --- | Generates a Zone for a particular Domain from the DNS properies of all --- hosts that propellor knows about that are in that Domain. -genZone :: [Host] -> Domain -> SOA -> (Zone, [WarningMessage]) -genZone hosts zdomain soa = - let (warnings, zhosts) = partitionEithers $ concat $ map concat - [ map hostips inzdomain - , map hostrecords inzdomain - , map addcnames (M.elems m) - ] - in (Zone zdomain soa (nub zhosts), warnings) - where - m = hostAttrMap hosts - -- Known hosts with hostname located in the zone's domain. - inzdomain = M.elems $ M.filterWithKey (\hn _ -> inDomain zdomain $ AbsDomain $ hn) m - - -- Each host with a hostname located in the zdomain - -- should have 1 or more IPAddrs in its Attr. - -- - -- If a host lacks any IPAddr, it's probably a misconfiguration, - -- so warn. - hostips :: Attr -> [Either WarningMessage (BindDomain, Record)] - hostips attr - | null l = [Left $ "no IP address defined for host " ++ _hostname attr] - | otherwise = map Right l - where - l = zip (repeat $ AbsDomain $ _hostname attr) - (map Address $ getAddresses attr) - - -- Any host, whether its hostname is in the zdomain or not, - -- may have cnames which are in the zdomain. The cname may even be - -- the same as the root of the zdomain, which is a nice way to - -- specify IP addresses for a SOA record. - -- - -- Add Records for those.. But not actually, usually, cnames! - -- Why not? Well, using cnames doesn't allow doing some things, - -- including MX and round robin DNS, and certianly CNAMES - -- shouldn't be used in SOA records. - -- - -- We typically know the host's IPAddrs anyway. - -- So we can just use the IPAddrs. - addcnames :: Attr -> [Either WarningMessage (BindDomain, Record)] - addcnames attr = concatMap gen $ filter (inDomain zdomain) $ - mapMaybe getCNAME $ S.toList (_dns attr) - where - gen c = case getAddresses attr of - [] -> [ret (CNAME c)] - l -> map (ret . Address) l - where - ret record = Right (c, record) - - -- Adds any other DNS records for a host located in the zdomain. - hostrecords :: Attr -> [Either WarningMessage (BindDomain, Record)] - hostrecords attr = map Right l - where - l = zip (repeat $ AbsDomain $ _hostname attr) - (S.toList $ S.filter (\r -> isNothing (getIPAddr r) && isNothing (getCNAME r)) (_dns attr)) - -inDomain :: Domain -> BindDomain -> Bool -inDomain domain (AbsDomain d) = domain == d || ('.':domain) `isSuffixOf` d -inDomain _ _ = False -- can't tell, so assume not - --- | Gets the hostname of the second domain, relative to the first domain, --- suitable for using in a zone file. -domainHost :: Domain -> BindDomain -> String -domainHost _ (RelDomain d) = d -domainHost _ RootDomain = "@" -domainHost base (AbsDomain d) - | dotbase `isSuffixOf` d = take (length d - length dotbase) d - | base == d = "@" - | otherwise = d - where - dotbase = '.':base - diff --git a/Propellor/Property/Docker.hs b/Propellor/Property/Docker.hs deleted file mode 100644 index 09d7d6a4..00000000 --- a/Propellor/Property/Docker.hs +++ /dev/null @@ -1,456 +0,0 @@ -{-# LANGUAGE BangPatterns #-} - --- | Docker support for propellor --- --- The existance of a docker container is just another Property of a system, --- which propellor can set up. See config.hs for an example. - -module Propellor.Property.Docker where - -import Propellor -import Propellor.SimpleSh -import Propellor.Types.Attr -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Docker.Shim as Shim -import Utility.SafeCommand -import Utility.Path - -import Control.Concurrent.Async -import System.Posix.Directory -import System.Posix.Process -import Data.List -import Data.List.Utils - --- | Configures docker with an authentication file, so that images can be --- pushed to index.docker.io. -configured :: Property -configured = property "docker configured" go `requires` installed - where - go = withPrivData DockerAuthentication $ \cfg -> ensureProperty $ - "/root/.dockercfg" `File.hasContent` (lines cfg) - -installed :: Property -installed = Apt.installed ["docker.io"] - --- | A short descriptive name for a container. --- Should not contain whitespace or other unusual characters, --- only [a-zA-Z0-9_-] are allowed -type ContainerName = String - --- | Starts accumulating the properties of a Docker container. --- --- > container "web-server" "debian" --- > & publish "80:80" --- > & Apt.installed {"apache2"] --- > & ... -container :: ContainerName -> Image -> Host -container cn image = Host [] (\_ -> attr) - where - attr = (newAttr (cn2hn cn)) { _dockerImage = Just image } - -cn2hn :: ContainerName -> HostName -cn2hn cn = cn ++ ".docker" - --- | Ensures that a docker container is set up and running. The container --- has its own Properties which are handled by running propellor --- inside the container. --- --- Reverting this property ensures that the container is stopped and --- removed. -docked - :: [Host] - -> ContainerName - -> RevertableProperty -docked hosts cn = RevertableProperty (go "docked" setup) (go "undocked" teardown) - where - go desc a = property (desc ++ " " ++ cn) $ do - hn <- getHostName - let cid = ContainerId hn cn - ensureProperties [findContainer hosts cid cn $ a cid] - - setup cid (Container image runparams) = - provisionContainer cid - `requires` - runningContainer cid image runparams - `requires` - installed - - teardown cid (Container image _runparams) = - combineProperties ("undocked " ++ fromContainerId cid) - [ stoppedContainer cid - , property ("cleaned up " ++ fromContainerId cid) $ - liftIO $ report <$> mapM id - [ removeContainer cid - , removeImage image - ] - ] - -findContainer - :: [Host] - -> ContainerId - -> ContainerName - -> (Container -> Property) - -> Property -findContainer hosts cid cn mk = case findHost hosts (cn2hn cn) of - Nothing -> cantfind - Just h -> maybe cantfind mk (mkContainer cid h) - where - cantfind = containerDesc cid $ property "" $ do - liftIO $ warningMessage $ - "missing definition for docker container \"" ++ cn2hn cn - return FailedChange - -mkContainer :: ContainerId -> Host -> Maybe Container -mkContainer cid@(ContainerId hn _cn) h = Container - <$> _dockerImage attr - <*> pure (map (\a -> a hn) (_dockerRunParams attr)) - where - attr = hostAttr h' - h' = h - -- expose propellor directory inside the container - & volume (localdir++":"++localdir) - -- name the container in a predictable way so we - -- and the user can easily find it later - & name (fromContainerId cid) - --- | Causes *any* docker images that are not in use by running containers to --- be deleted. And deletes any containers that propellor has set up --- before that are not currently running. Does not delete any containers --- that were not set up using propellor. --- --- Generally, should come after the properties for the desired containers. -garbageCollected :: Property -garbageCollected = propertyList "docker garbage collected" - [ gccontainers - , gcimages - ] - where - gccontainers = property "docker containers garbage collected" $ - liftIO $ report <$> (mapM removeContainer =<< listContainers AllContainers) - gcimages = property "docker images garbage collected" $ do - liftIO $ report <$> (mapM removeImage =<< listImages) - -data Container = Container Image [RunParam] - --- | Parameters to pass to `docker run` when creating a container. -type RunParam = String - --- | A docker image, that can be used to run a container. -type Image = String - --- | Set custom dns server for container. -dns :: String -> Property -dns = runProp "dns" - --- | Set container host name. -hostname :: String -> Property -hostname = runProp "hostname" - --- | Set name for container. (Normally done automatically.) -name :: String -> Property -name = runProp "name" - --- | Publish a container's port to the host --- (format: ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort) -publish :: String -> Property -publish = runProp "publish" - --- | Username or UID for container. -user :: String -> Property -user = runProp "user" - --- | Mount a volume --- Create a bind mount with: [host-dir]:[container-dir]:[rw|ro] --- With just a directory, creates a volume in the container. -volume :: String -> Property -volume = runProp "volume" - --- | Mount a volume from the specified container into the current --- container. -volumes_from :: ContainerName -> Property -volumes_from cn = genProp "volumes-from" $ \hn -> - fromContainerId (ContainerId hn cn) - --- | Work dir inside the container. -workdir :: String -> Property -workdir = runProp "workdir" - --- | Memory limit for container. ---Format: , where unit = b, k, m or g -memory :: String -> Property -memory = runProp "memory" - --- | Link with another container on the same host. -link :: ContainerName -> ContainerAlias -> Property -link linkwith calias = genProp "link" $ \hn -> - fromContainerId (ContainerId hn linkwith) ++ ":" ++ calias - --- | A short alias for a linked container. --- Each container has its own alias namespace. -type ContainerAlias = String - --- | A container is identified by its name, and the host --- on which it's deployed. -data ContainerId = ContainerId HostName ContainerName - deriving (Eq, Read, Show) - --- | Two containers with the same ContainerIdent were started from --- the same base image (possibly a different version though), and --- with the same RunParams. -data ContainerIdent = ContainerIdent Image HostName ContainerName [RunParam] - deriving (Read, Show, Eq) - -ident2id :: ContainerIdent -> ContainerId -ident2id (ContainerIdent _ hn cn _) = ContainerId hn cn - -toContainerId :: String -> Maybe ContainerId -toContainerId s - | myContainerSuffix `isSuffixOf` s = case separate (== '.') (desuffix s) of - (cn, hn) - | null hn || null cn -> Nothing - | otherwise -> Just $ ContainerId hn cn - | otherwise = Nothing - where - desuffix = reverse . drop len . reverse - len = length myContainerSuffix - -fromContainerId :: ContainerId -> String -fromContainerId (ContainerId hn cn) = cn++"."++hn++myContainerSuffix - -containerHostName :: ContainerId -> HostName -containerHostName (ContainerId _ cn) = cn2hn cn - -myContainerSuffix :: String -myContainerSuffix = ".propellor" - -containerDesc :: ContainerId -> Property -> Property -containerDesc cid p = p `describe` desc - where - desc = "[" ++ fromContainerId cid ++ "] " ++ propertyDesc p - -runningContainer :: ContainerId -> Image -> [RunParam] -> Property -runningContainer cid@(ContainerId hn cn) image runps = containerDesc cid $ property "running" $ do - l <- liftIO $ listContainers RunningContainers - if cid `elem` l - then do - -- Check if the ident has changed; if so the - -- parameters of the container differ and it must - -- be restarted. - runningident <- liftIO $ getrunningident - if runningident == Just ident - then noChange - else do - void $ liftIO $ stopContainer cid - restartcontainer - else ifM (liftIO $ elem cid <$> listContainers AllContainers) - ( restartcontainer - , go image - ) - where - ident = ContainerIdent image hn cn runps - - restartcontainer = do - oldimage <- liftIO $ fromMaybe image <$> commitContainer cid - void $ liftIO $ removeContainer cid - go oldimage - - getrunningident :: IO (Maybe ContainerIdent) - getrunningident = simpleShClient (namedPipe cid) "cat" [propellorIdent] $ \rs -> do - let !v = extractident rs - return v - - extractident :: [Resp] -> Maybe ContainerIdent - extractident = headMaybe . catMaybes . map readish . catMaybes . map getStdout - - go img = do - liftIO $ do - clearProvisionedFlag cid - createDirectoryIfMissing True (takeDirectory $ identFile cid) - shim <- liftIO $ Shim.setup (localdir "propellor") (localdir shimdir cid) - liftIO $ writeFile (identFile cid) (show ident) - ensureProperty $ boolProperty "run" $ runContainer img - (runps ++ ["-i", "-d", "-t"]) - [shim, "--docker", fromContainerId cid] - --- | Called when propellor is running inside a docker container. --- The string should be the container's ContainerId. --- --- This process is effectively init inside the container. --- It even needs to wait on zombie processes! --- --- Fork a thread to run the SimpleSh server in the background. --- In the foreground, run an interactive bash (or sh) shell, --- so that the user can interact with it when attached to the container. --- --- When the system reboots, docker restarts the container, and this is run --- again. So, to make the necessary services get started on boot, this needs --- to provision the container then. However, if the container is already --- being provisioned by the calling propellor, it would be redundant and --- problimatic to also provisoon it here. --- --- The solution is a flag file. If the flag file exists, then the container --- was already provisioned. So, it must be a reboot, and time to provision --- again. If the flag file doesn't exist, don't provision here. -chain :: String -> IO () -chain s = case toContainerId s of - Nothing -> error $ "Invalid ContainerId: " ++ s - Just cid -> do - changeWorkingDirectory localdir - writeFile propellorIdent . show =<< readIdentFile cid - -- Run boot provisioning before starting simpleSh, - -- to avoid ever provisioning twice at the same time. - whenM (checkProvisionedFlag cid) $ do - let shim = Shim.file (localdir "propellor") (localdir shimdir cid) - unlessM (boolSystem shim [Param "--continue", Param $ show $ Chain $ containerHostName cid]) $ - warningMessage "Boot provision failed!" - void $ async $ job reapzombies - void $ async $ job $ simpleSh $ namedPipe cid - job $ do - void $ tryIO $ ifM (inPath "bash") - ( boolSystem "bash" [Param "-l"] - , boolSystem "/bin/sh" [] - ) - putStrLn "Container is still running. Press ^P^Q to detach." - where - job = forever . void . tryIO - reapzombies = void $ getAnyProcessStatus True False - --- | Once a container is running, propellor can be run inside --- it to provision it. --- --- Note that there is a race here, between the simplesh --- server starting up in the container, and this property --- being run. So, retry connections to the client for up to --- 1 minute. -provisionContainer :: ContainerId -> Property -provisionContainer cid = containerDesc cid $ property "provision" $ liftIO $ do - let shim = Shim.file (localdir "propellor") (localdir shimdir cid) - r <- simpleShClientRetry 60 (namedPipe cid) shim params (go Nothing) - when (r /= FailedChange) $ - setProvisionedFlag cid - return r - where - params = ["--continue", show $ Chain $ containerHostName cid] - - go lastline (v:rest) = case v of - StdoutLine s -> do - maybe noop putStrLn lastline - hFlush stdout - go (Just s) rest - StderrLine s -> do - maybe noop putStrLn lastline - hFlush stdout - hPutStrLn stderr s - hFlush stderr - go Nothing rest - Done -> ret lastline - go lastline [] = ret lastline - - ret lastline = pure $ fromMaybe FailedChange $ readish =<< lastline - -stopContainer :: ContainerId -> IO Bool -stopContainer cid = boolSystem dockercmd [Param "stop", Param $ fromContainerId cid ] - -stoppedContainer :: ContainerId -> Property -stoppedContainer cid = containerDesc cid $ property desc $ - ifM (liftIO $ elem cid <$> listContainers RunningContainers) - ( liftIO cleanup `after` ensureProperty - (boolProperty desc $ stopContainer cid) - , return NoChange - ) - where - desc = "stopped" - cleanup = do - nukeFile $ namedPipe cid - nukeFile $ identFile cid - removeDirectoryRecursive $ shimdir cid - clearProvisionedFlag cid - -removeContainer :: ContainerId -> IO Bool -removeContainer cid = catchBoolIO $ - snd <$> processTranscript dockercmd ["rm", fromContainerId cid ] Nothing - -removeImage :: Image -> IO Bool -removeImage image = catchBoolIO $ - snd <$> processTranscript dockercmd ["rmi", image ] Nothing - -runContainer :: Image -> [RunParam] -> [String] -> IO Bool -runContainer image ps cmd = boolSystem dockercmd $ map Param $ - "run" : (ps ++ image : cmd) - -commitContainer :: ContainerId -> IO (Maybe Image) -commitContainer cid = catchMaybeIO $ - takeWhile (/= '\n') - <$> readProcess dockercmd ["commit", fromContainerId cid] - -data ContainerFilter = RunningContainers | AllContainers - deriving (Eq) - --- | Only lists propellor managed containers. -listContainers :: ContainerFilter -> IO [ContainerId] -listContainers status = - catMaybes . map toContainerId . concat . map (split ",") - . catMaybes . map (lastMaybe . words) . lines - <$> readProcess dockercmd ps - where - ps - | status == AllContainers = baseps ++ ["--all"] - | otherwise = baseps - baseps = ["ps", "--no-trunc"] - -listImages :: IO [Image] -listImages = lines <$> readProcess dockercmd ["images", "--all", "--quiet"] - -runProp :: String -> RunParam -> Property -runProp field val = pureAttrProperty (param) $ \attr -> - attr { _dockerRunParams = _dockerRunParams attr ++ [\_ -> "--"++param] } - where - param = field++"="++val - -genProp :: String -> (HostName -> RunParam) -> Property -genProp field mkval = pureAttrProperty field $ \attr -> - attr { _dockerRunParams = _dockerRunParams attr ++ [\hn -> "--"++field++"=" ++ mkval hn] } - --- | The ContainerIdent of a container is written to --- /.propellor-ident inside it. This can be checked to see if --- the container has the same ident later. -propellorIdent :: FilePath -propellorIdent = "/.propellor-ident" - --- | Named pipe used for communication with the container. -namedPipe :: ContainerId -> FilePath -namedPipe cid = "docker" fromContainerId cid - -provisionedFlag :: ContainerId -> FilePath -provisionedFlag cid = "docker" fromContainerId cid ++ ".provisioned" - -clearProvisionedFlag :: ContainerId -> IO () -clearProvisionedFlag = nukeFile . provisionedFlag - -setProvisionedFlag :: ContainerId -> IO () -setProvisionedFlag cid = do - createDirectoryIfMissing True (takeDirectory (provisionedFlag cid)) - writeFile (provisionedFlag cid) "1" - -checkProvisionedFlag :: ContainerId -> IO Bool -checkProvisionedFlag = doesFileExist . provisionedFlag - -shimdir :: ContainerId -> FilePath -shimdir cid = "docker" fromContainerId cid ++ ".shim" - -identFile :: ContainerId -> FilePath -identFile cid = "docker" fromContainerId cid ++ ".ident" - -readIdentFile :: ContainerId -> IO ContainerIdent -readIdentFile cid = fromMaybe (error "bad ident in identFile") - . readish <$> readFile (identFile cid) - -dockercmd :: String -dockercmd = "docker.io" - -report :: [Bool] -> Result -report rmed - | or rmed = MadeChange - | otherwise = NoChange - diff --git a/Propellor/Property/Docker/Shim.hs b/Propellor/Property/Docker/Shim.hs deleted file mode 100644 index c2f35d0c..00000000 --- a/Propellor/Property/Docker/Shim.hs +++ /dev/null @@ -1,61 +0,0 @@ --- | Support for running propellor, as built outside a docker container, --- inside the container. --- --- Note: This is currently Debian specific, due to glibcLibs. - -module Propellor.Property.Docker.Shim (setup, cleanEnv, file) where - -import Propellor -import Utility.LinuxMkLibs -import Utility.SafeCommand -import Utility.Path -import Utility.FileMode - -import Data.List -import System.Posix.Files - --- | Sets up a shimmed version of the program, in a directory, and --- returns its path. -setup :: FilePath -> FilePath -> IO FilePath -setup propellorbin dest = do - createDirectoryIfMissing True dest - - libs <- parseLdd <$> readProcess "ldd" [propellorbin] - glibclibs <- glibcLibs - let libs' = nub $ libs ++ glibclibs - libdirs <- map (dest ++) . nub . catMaybes - <$> mapM (installLib installFile dest) libs' - - let linker = (dest ++) $ - fromMaybe (error "cannot find ld-linux linker") $ - headMaybe $ filter ("ld-linux" `isInfixOf`) libs' - let gconvdir = (dest ++) $ parentDir $ - fromMaybe (error "cannot find gconv directory") $ - headMaybe $ filter ("/gconv/" `isInfixOf`) glibclibs - let linkerparams = ["--library-path", intercalate ":" libdirs ] - let shim = file propellorbin dest - writeFile shim $ unlines - [ "#!/bin/sh" - , "GCONV_PATH=" ++ shellEscape gconvdir - , "export GCONV_PATH" - , "exec " ++ unwords (map shellEscape $ linker : linkerparams) ++ - " " ++ shellEscape propellorbin ++ " \"$@\"" - ] - modifyFileMode shim (addModes executeModes) - return shim - -cleanEnv :: IO () -cleanEnv = void $ unsetEnv "GCONV_PATH" - -file :: FilePath -> FilePath -> FilePath -file propellorbin dest = dest takeFileName propellorbin - -installFile :: FilePath -> FilePath -> IO () -installFile top f = do - createDirectoryIfMissing True destdir - nukeFile dest - createLink f dest `catchIO` (const copy) - where - copy = void $ boolSystem "cp" [Param "-a", Param f, Param dest] - destdir = inTop top $ parentDir f - dest = inTop top f diff --git a/Propellor/Property/File.hs b/Propellor/Property/File.hs deleted file mode 100644 index 0b060177..00000000 --- a/Propellor/Property/File.hs +++ /dev/null @@ -1,94 +0,0 @@ -module Propellor.Property.File where - -import Propellor -import Utility.FileMode - -import System.Posix.Files -import System.PosixCompat.Types - -type Line = String - --- | Replaces all the content of a file. -hasContent :: FilePath -> [Line] -> Property -f `hasContent` newcontent = fileProperty ("replace " ++ f) - (\_oldcontent -> newcontent) f - --- | Ensures a file has contents that comes from PrivData. --- --- The file's permissions are preserved if the file already existed. --- Otherwise, they're set to 600. -hasPrivContent :: FilePath -> Property -hasPrivContent f = property desc $ withPrivData (PrivFile f) $ \privcontent -> - ensureProperty $ fileProperty' writeFileProtectedĀ desc - (\_oldcontent -> lines privcontent) f - where - desc = "privcontent " ++ f - --- | Leaves the file world-readable. -hasPrivContentExposed :: FilePath -> Property -hasPrivContentExposed f = hasPrivContent f `onChange` - mode f (combineModes (ownerWriteMode:readModes)) - --- | Ensures that a line is present in a file, adding it to the end if not. -containsLine :: FilePath -> Line -> Property -f `containsLine` l = f `containsLines` [l] - -containsLines :: FilePath -> [Line] -> Property -f `containsLines` l = fileProperty (f ++ " contains:" ++ show l) go f - where - go ls - | all (`elem` ls) l = ls - | otherwise = ls++l - --- | Ensures that a line is not present in a file. --- Note that the file is ensured to exist, so if it doesn't, an empty --- file will be written. -lacksLine :: FilePath -> Line -> Property -f `lacksLine` l = fileProperty (f ++ " remove: " ++ l) (filter (/= l)) f - --- | Removes a file. Does not remove symlinks or non-plain-files. -notPresent :: FilePath -> Property -notPresent f = check (doesFileExist f) $ property (f ++ " not present") $ - makeChange $ nukeFile f - -fileProperty :: Desc -> ([Line] -> [Line]) -> FilePath -> Property -fileProperty = fileProperty' writeFile -fileProperty' :: (FilePath -> String -> IO ()) -> Desc -> ([Line] -> [Line]) -> FilePath -> Property -fileProperty' writer desc a f = property desc $ go =<< liftIO (doesFileExist f) - where - go True = do - ls <- liftIO $ lines <$> readFile f - let ls' = a ls - if ls' == ls - then noChange - else makeChange $ viaTmp updatefile f (unlines ls') - go False = makeChange $ writer f (unlines $ a []) - - -- viaTmp makes the temp file mode 600. - -- Replicate the original file's owner and mode. - updatefile f' content = do - writer f' content - s <- getFileStatus f - setFileMode f' (fileMode s) - setOwnerAndGroup f' (fileOwner s) (fileGroup s) - --- | Ensures a directory exists. -dirExists :: FilePath -> Property -dirExists d = check (not <$> doesDirectoryExist d) $ property (d ++ " exists") $ - makeChange $ createDirectoryIfMissing True d - --- | Ensures that a file/dir has the specified owner and group. -ownerGroup :: FilePath -> UserName -> GroupName -> Property -ownerGroup f owner group = property (f ++ " owner " ++ og) $ do - r <- ensureProperty $ cmdProperty "chown" [og, f] - if r == FailedChange - then return r - else noChange - where - og = owner ++ ":" ++ group - --- | Ensures that a file/dir has the specfied mode. -mode :: FilePath -> FileMode -> Property -mode f v = property (f ++ " mode " ++ show v) $ do - liftIO $ modifyFileMode f (\_old -> v) - noChange diff --git a/Propellor/Property/Git.hs b/Propellor/Property/Git.hs deleted file mode 100644 index e5df7e48..00000000 --- a/Propellor/Property/Git.hs +++ /dev/null @@ -1,93 +0,0 @@ -module Propellor.Property.Git where - -import Propellor -import Propellor.Property.File -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Service as Service -import Utility.SafeCommand - -import Data.List - --- | Exports all git repos in a directory (that user nobody can read) --- using git-daemon, run from inetd. --- --- Note that reverting this property does not remove or stop inetd. -daemonRunning :: FilePath -> RevertableProperty -daemonRunning exportdir = RevertableProperty setup unsetup - where - setup = containsLine conf (mkl "tcp4") - `requires` - containsLine conf (mkl "tcp6") - `requires` - dirExists exportdir - `requires` - Apt.serviceInstalledRunning "openbsd-inetd" - `onChange` - Service.running "openbsd-inetd" - `describe` ("git-daemon exporting " ++ exportdir) - unsetup = lacksLine conf (mkl "tcp4") - `requires` - lacksLine conf (mkl "tcp6") - `onChange` - Service.reloaded "openbsd-inetd" - - conf = "/etc/inetd.conf" - - mkl tcpv = intercalate "\t" - [ "git" - , "stream" - , tcpv - , "nowait" - , "nobody" - , "/usr/bin/git" - , "git" - , "daemon" - , "--inetd" - , "--export-all" - , "--base-path=" ++ exportdir - , exportdir - ] - -installed :: Property -installed = Apt.installed ["git"] - -type RepoUrl = String - -type Branch = String - --- | Specified git repository is cloned to the specified directory. --- --- If the firectory exists with some other content, it will be recursively --- deleted. --- --- A branch can be specified, to check out. -cloned :: UserName -> RepoUrl -> FilePath -> Maybe Branch -> Property -cloned owner url dir mbranch = check originurl (property desc checkout) - `requires` installed - where - desc = "git cloned " ++ url ++ " to " ++ dir - gitconfig = dir ".git/config" - originurl = ifM (doesFileExist gitconfig) - ( do - v <- catchDefaultIO Nothing $ headMaybe . lines <$> - readProcess "git" ["config", "--file", gitconfig, "remote.origin.url"] - return (v /= Just url) - , return True - ) - checkout = do - liftIO $ do - whenM (doesDirectoryExist dir) $ - removeDirectoryRecursive dir - createDirectoryIfMissing True (takeDirectory dir) - ensureProperty $ userScriptProperty owner $ catMaybes - -- The mbranch - -- In case this repo is exposted via the web, - -- although the hook to do this ongoing is not - -- installed here. - , Just "git update-server-info" - ] diff --git a/Propellor/Property/Gpg.hs b/Propellor/Property/Gpg.hs deleted file mode 100644 index 64ea9fea..00000000 --- a/Propellor/Property/Gpg.hs +++ /dev/null @@ -1,41 +0,0 @@ -module Propellor.Property.Gpg where - -import Propellor -import qualified Propellor.Property.Apt as Apt -import Utility.FileSystemEncoding - -import System.PosixCompat - -installed :: Property -installed = Apt.installed ["gnupg"] - --- | Sets up a user with a gpg key from the privdata. --- --- Note that if a secret key is exported using gpg -a --export-secret-key, --- the public key is also included. Or just a public key could be --- exported, and this would set it up just as well. --- --- Recommend only using this for low-value dedicated role keys. --- No attempt has been made to scrub the key out of memory once it's used. --- --- The GpgKeyId does not have to be a numeric id; it can just as easily --- be a description of the key. -keyImported :: GpgKeyId -> UserName -> Property -keyImported keyid user = flagFile' (property desc go) genflag - `requires` installed - where - desc = user ++ " has gpg key " ++ show keyid - genflag = do - d <- dotDir user - return $ d ".propellor-imported-keyid-" ++ keyid - go = withPrivData (GpgKey keyid) $ \key -> makeChange $ - withHandle StdinHandle createProcessSuccess - (proc "su" ["-c", "gpg --import", user]) $ \h -> do - fileEncoding h - hPutStr h key - hClose h - -dotDir :: UserName -> IO FilePath -dotDir user = do - home <- homeDirectory <$> getUserEntryForName user - return $ home ".gnupg" diff --git a/Propellor/Property/Hostname.hs b/Propellor/Property/Hostname.hs deleted file mode 100644 index 031abb9d..00000000 --- a/Propellor/Property/Hostname.hs +++ /dev/null @@ -1,33 +0,0 @@ -module Propellor.Property.Hostname where - -import Propellor -import qualified Propellor.Property.File as File - --- | Ensures that the hostname is set to the HostAttr value. --- Configures /etc/hostname and the current hostname. --- --- A FQDN also configures /etc/hosts, with an entry for 127.0.1.1, which is --- standard at least on Debian to set the FDQN (127.0.0.1 is localhost). -sane :: Property -sane = property ("sane hostname") (ensureProperty . setTo =<< getHostName) - -setTo :: HostName -> Property -setTo hn = combineProperties desc go - `onChange` cmdProperty "hostname" [basehost] - where - desc = "hostname " ++ hn - (basehost, domain) = separate (== '.') hn - - go = catMaybes - [ Just $ "/etc/hostname" `File.hasContent` [basehost] - , if null domain - then Nothing - else Just $ File.filePropertyĀ desc - addhostline "/etc/hosts" - ] - - hostip = "127.0.1.1" - hostline = hostip ++ "\t" ++ hn ++ " " ++ basehost - - addhostline ls = hostline : filter (not . hashostip) ls - hashostip l = headMaybe (words l) == Just hostip diff --git a/Propellor/Property/Network.hs b/Propellor/Property/Network.hs deleted file mode 100644 index 6009778a..00000000 --- a/Propellor/Property/Network.hs +++ /dev/null @@ -1,30 +0,0 @@ -module Propellor.Property.Network where - -import Propellor -import Propellor.Property.File - -interfaces :: FilePath -interfaces = "/etc/network/interfaces" - --- | 6to4 ipv6 connection, should work anywhere -ipv6to4 :: Property -ipv6to4 = fileProperty "ipv6to4" go interfaces - `onChange` ifUp "sit0" - where - go ls - | all (`elem` ls) stanza = ls - | otherwise = ls ++ stanza - stanza = - [ "# Automatically added by propeller" - , "iface sit0 inet6 static" - , "\taddress 2002:5044:5531::1" - , "\tnetmask 64" - , "\tgateway ::192.88.99.1" - , "auto sit0" - , "# End automatically added by propeller" - ] - -type Interface = String - -ifUp :: Interface -> Property -ifUp iface = cmdProperty "ifup" [iface] diff --git a/Propellor/Property/Obnam.hs b/Propellor/Property/Obnam.hs deleted file mode 100644 index 32374b57..00000000 --- a/Propellor/Property/Obnam.hs +++ /dev/null @@ -1,155 +0,0 @@ -module Propellor.Property.Obnam where - -import Propellor -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Cron as Cron -import Utility.SafeCommand - -import Data.List - -type ObnamParam = String - --- | An obnam repository can be used by multiple clients. Obnam uses --- locking to allow only one client to write at a time. Since stale lock --- files can prevent backups from happening, it's more robust, if you know --- a repository has only one client, to force the lock before starting a --- backup. Using OnlyClient allows propellor to do so when running obnam. -data NumClients = OnlyClient | MultipleClients - deriving (Eq) - --- | Installs a cron job that causes a given directory to be backed --- up, by running obnam with some parameters. --- --- If the directory does not exist, or exists but is completely empty, --- this Property will immediately restore it from an existing backup. --- --- So, this property can be used to deploy a directory of content --- to a host, while also ensuring any changes made to it get backed up. --- And since Obnam encrypts, just make this property depend on a gpg --- key, and tell obnam to use the key, and your data will be backed --- up securely. For example: --- --- > & Obnam.backup "/srv/git" "33 3 * * *" --- > [ "--repository=sftp://2318@usw-s002.rsync.net/~/mygitrepos.obnam" --- > , "--encrypt-with=1B169BE1" --- > ] Obnam.OnlyClient --- > `requires` Gpg.keyImported "1B169BE1" "root" --- > `requires` Ssh.keyImported SshRsa "root" --- --- How awesome is that? -backup :: FilePath -> Cron.CronTimes -> [ObnamParam] -> NumClients -> Property -backup dir crontimes params numclients = cronjob `describe` desc - `requires` restored dir params - where - desc = dir ++ " backed up by obnam" - cronjob = Cron.niceJob ("obnam_backup" ++ dir) crontimes "root" "/" $ - intercalate ";" $ catMaybes - [ if numclients == OnlyClient - then Just $ unwords $ - [ "obnam" - , "force-lock" - ] ++ map shellEscape params - else Nothing - , Just $ unwords $ - [ "obnam" - , "backup" - , shellEscape dir - ] ++ map shellEscape params - ] - --- | Restores a directory from an obnam backup. --- --- Only does anything if the directory does not exist, or exists, --- but is completely empty. --- --- The restore is performed atomically; restoring to a temp directory --- and then moving it to the directory. -restored :: FilePath -> [ObnamParam] -> Property -restored dir params = property (dir ++ " restored by obnam") go - `requires` installed - where - go = ifM (liftIO needsRestore) - ( do - warningMessage $ dir ++ " is empty/missing; restoring from backup ..." - liftIO restore - , noChange - ) - - needsRestore = null <$> catchDefaultIO [] (dirContents dir) - - restore = withTmpDirIn (takeDirectory dir) "obnam-restore" $ \tmpdir -> do - ok <- boolSystem "obnam" $ - [ Param "restore" - , Param "--to" - , Param tmpdir - ] ++ map Param params - let restoreddir = tmpdir ++ "/" ++ dir - ifM (pure ok <&&> doesDirectoryExist restoreddir) - ( do - void $ tryIO $ removeDirectory dir - renameDirectory restoreddir dir - return MadeChange - , return FailedChange - ) - -installed :: Property -installed = Apt.installed ["obnam"] - --- | Ensures that a recent version of obnam gets installed. --- --- Only does anything for Debian Stable. -latestVersion :: Property -latestVersion = withOS "obnam latest version" $ \o -> case o of - (Just (System (Debian suite) _)) | isStable suite -> ensureProperty $ - Apt.setSourcesListD (sources suite) "obnam" - `requires` toProp (Apt.trustsKey key) - _ -> noChange - where - sources suite = - [ "deb http://code.liw.fi/debian " ++ Apt.showSuite suite ++ " main" - ] - -- gpg key used by the code.liw.fi repository. - key = Apt.AptKey "obnam" $ unlines - [ "-----BEGIN PGP PUBLIC KEY BLOCK-----" - , "Version: GnuPG v1.4.9 (GNU/Linux)" - , "" - , "mQGiBEfzuTgRBACcVNG/H6QJqLx5qiQs2zmPe6D6BWOWHfgNgG4IWzNstm21YDxb" - , "KqwFG0gxcnZJGHkXAhkSfqTokYd0lc5eBemcA1pkceNjzMEX8wwiZ810HzJD4eEH" - , "sjoWR8+qKrZeixzZqReAfqztcXoBGKQ0u1R1vpg1txUa75OM4BUqaUbsmwCgmS4x" - , "DjMxSaUSPuu6vQ7ZGZBXSP0D/RQw8DBHMfsv3DiaqFqk8tkuUkpMFPIekHidSHlO" - , "EACbncqbbyHksyCpFNVNcQIDHrOLjOZK9BAXkSd8I3ww7U+nLdDcCblrW8CZnJtm" - , "ZYrxfaXaHZ/It9/RCAsQ+c8xtmyUPjsf//4Vf8olxNQHzgBSe5/LJRi4Vd53he+K" - , "YP4LA/9IZbjvVmm8+8Y0pQrTHlI6nTImtzdBXHc4+T3lLBj9XODHLozC2kSBOQky" - , "q/EisTITHTXL8vYg4NsKm5RTbPAuBwdtxcny8CXfOqKtGOdrebmKotGllTozzdPv" - , "9p53cuce6oJ2oMUodc074JOGTWwDSgLiJX4nViGcU1wy/vtQnrQkY29kZS5saXcu" - , "ZmkgYXJjaGl2ZSBrZXkgPGxpd0BsaXcuZmk+iGAEExECACAFAkfzuTgCGwMGCwkI" - , "BwMCBBUCCAMEFgIDAQIeAQIXgAAKCRBG53tJR95LscKrAJ0ZtKqa2x6Kplwa2mzx" - , "ItImbIGMJACdETqofDYzUN91yLAFlOnxAyrE+UyIRgQQEQIABgUCSFd5GgAKCRAf" - , "u5W/LZrMjqr8AJ4xPVHpW8ZNlgMwDSVb075RnA2DiACgg2SR69jAHFQOWV6xfLRr" - , "vh0bLKGJAhwEEAEIAAYFAktEyIwACgkQ61zh116FEfm7Lg//Wiy3TjWAk8YHUddv" - , "zOioYzCxQ985GsVhJGAVPqSGOc9vfTWBJZ8J3l0NnYTRpEGucmbF9G+mAt9iGXu6" - , "7yZkxyFdvbo7EDsqMU1wLOM6PiU+Un63MKlbTNmFn7OKE8aXPRAFgcyUO/qjdqoD" - , "sa9FgU5Z0f60m9qah6BPXH6IzMLHYoiP7t8rCBIwLgyl3w2w+Fjt1DFpbW9Kb7jz" - , "i8jFvC8jPmxV8xh2OSgVZyNk4qg6hIV8GVQY7AJt8OurZSckgQd7ifHK9JTGohtF" - , "tXCiqeDEvnMF4A9HI/TcXJBzonZ8ds1JCq42nSSKmL+8TyjtUSD/xHygazuc0CK0" - , "hFnQWBub60IfyV6F0oTagJ8cmARv2sezHAeHDkzPHE8RdjgktazH1eJrA4LheEd6" - , "KeSnVtYWpw8dgMv5PleFyQiAj/t3C/N50fd15tUyfnH15G7nFjMQV2Yx35uwSxOj" - , "376OWnDN/YGTNk283XXULbyVJYR8Q2unso20XQ94yQ2A5EpHHPrHoLxrL/ydM08d" - , "nvKstLZIZtal1seiMkymtlSiGz25A5oqsclwS6VZCKdWA8HO/wlElOMcaHyl6Y1y" - , "gYP7y9O5yFYKFOrCH0nFjJbwmkRiBLsxuuWsYgJigVGq/atSrtawkHdshpCw0HCY" - , "N/RFcWkJ864BdsO0C0sDzueNkQO5Ag0ER/O5RBAIAJiwPH9tyJTgXcC2Y4XWboOq" - , "rx5CkOnr5b45oS9cK2eIJ8TKxE3XgKLxUr3mIH0QR2kZgDOwNl0WY+7/CXjn+Spn" - , "BokPg54rafEUePodGpGdUXdgrHhAMHYjh8fXFJ1SlQcg46/zc1wDI7jBCkGrK3V8" - , "5cXDqwTFTN5LcjoSRWeM4Voa6pEfDdL3rMlnOw9R9gDHRBBb6CDSjWXqM86pR889" - , "5QrR0SDwiJNrMoyxSjMXFKGBQAsYHJ82myZrlbuZbroZjVp5Uh7eB1ZiPljNVtcr" - , "sksACIWBCo1rvLzrPXsLYOeV3cDDtYAkSwGfuzC1Etbe+qgfIroFTOqdefMw4s8A" - , "AwUH/0KLXm4MS54QQspg3evu4Q4U/E8Hem5/FqB0GhBCitQ4rUsucKyY8/ItpUn5" - , "ismLE60bQqka+Mzd/Zw18TCTzImv0ozAaZ2sNtBado7f6jcC8EDfY5zzK1ukcsAr" - , "Qc5hdLHYuTQW5KpA6fKaW969OUzIwPbdVaCOLOBpxKC6N6iBspQYd6uiQtLw6EUO" - , "50oQqUiJABf0eOocvdw5e2KQQpuC3205+VMYtyl4w3pdJihK8NK0AikGXzDVsbQt" - , "l8kmB5ZrN4WIKhMke1FxbqQC5Q3XATvYRzpzzisZb/HYGNti8W6du5EUwJ0D2NRh" - , "cu+twocOzW0VKfmrDApfifJ9OsSISQQYEQIACQUCR/O5RAIbDAAKCRBG53tJR95L" - , "seQOAJ95KUyzjRjdYgZkDC69Mgu25L86UACdGduINUaRly43ag4kwUXxpqswBBM=" - , "=i2c3" - , "-----END PGP PUBLIC KEY BLOCK-----" - ] diff --git a/Propellor/Property/OpenId.hs b/Propellor/Property/OpenId.hs deleted file mode 100644 index 051d6425..00000000 --- a/Propellor/Property/OpenId.hs +++ /dev/null @@ -1,29 +0,0 @@ -module Propellor.Property.OpenId where - -import Propellor -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.Service as Service - -import Data.List - -providerFor :: [UserName] -> String -> Property -providerFor users baseurl = propertyList desc $ - [ Apt.serviceInstalledRunning "apache2" - , Apt.installed ["simpleid"] - `onChange` Service.restarted "apache2" - , File.fileProperty (desc ++ " configured") - (map setbaseurl) "/etc/simpleid/config.inc" - ] ++ map identfile users - where - url = "http://"++baseurl++"/simpleid" - desc = "openid provider " ++ url - setbaseurl l - | "SIMPLEID_BASE_URL" `isInfixOf` l = - "define('SIMPLEID_BASE_URL', '"++url++"');" - | otherwise = l - - -- the identitites directory controls access, so open up - -- file mode - identfile u = File.hasPrivContentExposed $ - concat $ [ "/var/lib/simpleid/identities/", u, ".identity" ] diff --git a/Propellor/Property/Postfix.hs b/Propellor/Property/Postfix.hs deleted file mode 100644 index 9fa4a2c3..00000000 --- a/Propellor/Property/Postfix.hs +++ /dev/null @@ -1,25 +0,0 @@ -module Propellor.Property.Postfix where - -import Propellor -import qualified Propellor.Property.Apt as Apt - -installed :: Property -installed = Apt.serviceInstalledRunning "postfix" - --- | Configures postfix as a satellite system, which --- relats all mail through a relay host, which defaults to smtp.domain. --- --- The smarthost may refuse to relay mail on to other domains, without --- futher coniguration/keys. But this should be enough to get cron job --- mail flowing to a place where it will be seen. -satellite :: Property -satellite = setup `requires` installed - where - setup = trivial $ property "postfix satellite system" $ do - hn <- getHostName - ensureProperty $ Apt.reConfigure "postfix" - [ ("postfix/main_mailer_type", "select", "Satellite system") - , ("postfix/root_address", "string", "root") - , ("postfix/destinations", "string", " ") - , ("postfix/mailname", "string", hn) - ] diff --git a/Propellor/Property/Reboot.hs b/Propellor/Property/Reboot.hs deleted file mode 100644 index 25e53159..00000000 --- a/Propellor/Property/Reboot.hs +++ /dev/null @@ -1,7 +0,0 @@ -module Propellor.Property.Reboot where - -import Propellor - -now :: Property -now = cmdProperty "reboot" [] - `describe` "reboot now" diff --git a/Propellor/Property/Scheduled.hs b/Propellor/Property/Scheduled.hs deleted file mode 100644 index f2911e50..00000000 --- a/Propellor/Property/Scheduled.hs +++ /dev/null @@ -1,67 +0,0 @@ -module Propellor.Property.Scheduled - ( period - , periodParse - , Recurrance(..) - , WeekDay - , MonthDay - , YearDay - ) where - -import Propellor -import Utility.Scheduled - -import Data.Time.Clock -import Data.Time.LocalTime -import qualified Data.Map as M - --- | Makes a Property only be checked every so often. --- --- This uses the description of the Property to keep track of when it was --- last run. -period :: Property -> Recurrance -> Property -period prop recurrance = flip describe desc $ adjustProperty prop $ \satisfy -> do - lasttime <- liftIO $ getLastChecked (propertyDesc prop) - nexttime <- liftIO $ fmap startTime <$> nextTime schedule lasttime - t <- liftIO localNow - if Just t >= nexttime - then do - r <- satisfy - liftIO $ setLastChecked t (propertyDesc prop) - return r - else noChange - where - schedule = Schedule recurrance AnyTime - desc = propertyDesc prop ++ " (period " ++ fromRecurrance recurrance ++ ")" - --- | Like period, but parse a human-friendly string. -periodParse :: Property -> String -> Property -periodParse prop s = case toRecurrance s of - Just recurrance -> period prop recurrance - Nothing -> property "periodParse" $ do - liftIO $ warningMessage $ "failed periodParse: " ++ s - noChange - -lastCheckedFile :: FilePath -lastCheckedFile = localdir ".lastchecked" - -getLastChecked :: Desc -> IO (Maybe LocalTime) -getLastChecked desc = M.lookup desc <$> readLastChecked - -localNow :: IO LocalTime -localNow = do - now <- getCurrentTime - tz <- getTimeZone now - return $ utcToLocalTime tz now - -setLastChecked :: LocalTime -> Desc -> IO () -setLastChecked time desc = do - m <- readLastChecked - writeLastChecked (M.insert desc time m) - -readLastChecked :: IO (M.Map Desc LocalTime) -readLastChecked = fromMaybe M.empty <$> catchDefaultIO Nothing go - where - go = readish <$> readFileStrict lastCheckedFile - -writeLastChecked :: M.Map Desc LocalTime -> IO () -writeLastChecked = writeFile lastCheckedFile . show diff --git a/Propellor/Property/Service.hs b/Propellor/Property/Service.hs deleted file mode 100644 index 14e769d0..00000000 --- a/Propellor/Property/Service.hs +++ /dev/null @@ -1,31 +0,0 @@ -module Propellor.Property.Service where - -import Propellor -import Utility.SafeCommand - -type ServiceName = String - --- | Ensures that a service is running. Does not ensure that --- any package providing that service is installed. See --- Apt.serviceInstalledRunning --- --- Note that due to the general poor state of init scripts, the best --- we can do is try to start the service, and if it fails, assume --- this means it's already running. -running :: ServiceName -> Property -running svc = property ("running " ++ svc) $ do - void $ ensureProperty $ - scriptProperty ["service " ++ shellEscape svc ++ " start >/dev/null 2>&1 || true"] - return NoChange - -restarted :: ServiceName -> Property -restarted svc = property ("restarted " ++ svc) $ do - void $ ensureProperty $ - scriptProperty ["service " ++ shellEscape svc ++ " restart >/dev/null 2>&1 || true"] - return NoChange - -reloaded :: ServiceName -> Property -reloaded svc = property ("reloaded " ++ svc) $ do - void $ ensureProperty $ - scriptProperty ["service " ++ shellEscape svc ++ " reload >/dev/null 2>&1 || true"] - return NoChange diff --git a/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs b/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs deleted file mode 100644 index 677aa760..00000000 --- a/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs +++ /dev/null @@ -1,57 +0,0 @@ -module Propellor.Property.SiteSpecific.GitAnnexBuilder where - -import Propellor -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.User as User -import qualified Propellor.Property.Cron as Cron -import Propellor.Property.Cron (CronTimes) - -builduser :: UserName -builduser = "builder" - -homedir :: FilePath -homedir = "/home/builder" - -gitbuilderdir :: FilePath -gitbuilderdir = homedir "gitbuilder" - -builddir :: FilePath -builddir = gitbuilderdir "build" - -builder :: Architecture -> CronTimes -> Bool -> Property -builder arch crontimes rsyncupload = combineProperties "gitannexbuilder" - [ Apt.stdSourcesList Unstable - , Apt.buildDep ["git-annex"] - , Apt.installed ["git", "rsync", "moreutils", "ca-certificates", - "liblockfile-simple-perl", "cabal-install", "vim", "less"] - , Apt.serviceInstalledRunning "cron" - , User.accountFor builduser - , check (not <$> doesDirectoryExist gitbuilderdir) $ userScriptProperty builduser - [ "git clone git://git.kitenet.net/gitannexbuilder " ++ gitbuilderdir - , "cd " ++ gitbuilderdir - , "git checkout " ++ arch - ] - `describe` "gitbuilder setup" - , check (not <$> doesDirectoryExist builddir) $ userScriptProperty builduser - [ "git clone git://git-annex.branchable.com/ " ++ builddir - ] - , "git-annex source build deps installed" ==> Apt.buildDepIn builddir - , Cron.niceJob "gitannexbuilder" crontimes builduser gitbuilderdir "git pull ; ./autobuild" - -- The builduser account does not have a password set, - -- instead use the password privdata to hold the rsync server - -- password used to upload the built image. - , property "rsync password" $ do - let f = homedir "rsyncpassword" - if rsyncupload - then withPrivData (Password builduser) $ \p -> do - oldp <- liftIO $ catchDefaultIO "" $ - readFileStrict f - if p /= oldp - then makeChange $ writeFile f p - else noChange - else do - ifM (liftIO $ doesFileExist f) - ( noChange - , makeChange $ writeFile f "no password configured" - ) - ] diff --git a/Propellor/Property/SiteSpecific/GitHome.hs b/Propellor/Property/SiteSpecific/GitHome.hs deleted file mode 100644 index 6ed02146..00000000 --- a/Propellor/Property/SiteSpecific/GitHome.hs +++ /dev/null @@ -1,34 +0,0 @@ -module Propellor.Property.SiteSpecific.GitHome where - -import Propellor -import qualified Propellor.Property.Apt as Apt -import Propellor.Property.User -import Utility.SafeCommand - --- | Clones Joey Hess's git home directory, and runs its fixups script. -installedFor :: UserName -> Property -installedFor user = check (not <$> hasGitDir user) $ - property ("githome " ++ user) (go =<< liftIO (homedir user)) - `requires` Apt.installed ["git"] - where - go home = do - let tmpdir = home "githome" - ensureProperty $ combineProperties "githome setup" - [ userScriptProperty user ["git clone " ++ url ++ " " ++ tmpdir] - , property "moveout" $ makeChange $ void $ - moveout tmpdir home - , property "rmdir" $ makeChange $ void $ - catchMaybeIO $ removeDirectory tmpdir - , userScriptProperty user ["rm -rf .aptitude/ .bashrc .profile; bin/mr checkout; bin/fixups"] - ] - moveout tmpdir home = do - fs <- dirContents tmpdir - forM fs $ \f -> boolSystem "mv" [File f, File home] - -url :: String -url = "git://git.kitenet.net/joey/home" - -hasGitDir :: UserName -> IO Bool -hasGitDir user = go =<< homedir user - where - go home = doesDirectoryExist (home ".git") diff --git a/Propellor/Property/SiteSpecific/JoeySites.hs b/Propellor/Property/SiteSpecific/JoeySites.hs deleted file mode 100644 index 28b3dffd..00000000 --- a/Propellor/Property/SiteSpecific/JoeySites.hs +++ /dev/null @@ -1,314 +0,0 @@ --- | Specific configuation for Joey Hess's sites. Probably not useful to --- others except as an example. - -module Propellor.Property.SiteSpecific.JoeySites where - -import Propellor -import qualified Propellor.Property.Apt as Apt -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Gpg as Gpg -import qualified Propellor.Property.Ssh as Ssh -import qualified Propellor.Property.Git as Git -import qualified Propellor.Property.Cron as Cron -import qualified Propellor.Property.Service as Service -import qualified Propellor.Property.User as User -import qualified Propellor.Property.Obnam as Obnam -import qualified Propellor.Property.Apache as Apache -import Utility.SafeCommand -import Utility.FileMode - -import Data.List -import System.Posix.Files - -oldUseNetServer :: [Host] -> Property -oldUseNetServer hosts = propertyList ("olduse.net server") - [ oldUseNetInstalled "oldusenet-server" - , Obnam.latestVersion - , Obnam.backup datadir "33 4 * * *" - [ "--repository=sftp://2318@usw-s002.rsync.net/~/olduse.net" - , "--client-name=spool" - ] Obnam.OnlyClient - `requires` Ssh.keyImported SshRsa "root" - `requires` Ssh.knownHost hosts "usw-s002.rsync.net" "root" - , check (not . isSymbolicLink <$> getSymbolicLinkStatus newsspool) $ - property "olduse.net spool in place" $ makeChange $ do - removeDirectoryRecursive newsspool - createSymbolicLink (datadir "news") newsspool - , Apt.installed ["leafnode"] - , "/etc/news/leafnode/config" `File.hasContent` - [ "# olduse.net configuration (deployed by propellor)" - , "expire = 1000000" -- no expiry via texpire - , "server = " -- no upstream server - , "debugmode = 1" - , "allowSTRANGERS = 42" -- lets anyone connect - , "nopost = 1" -- no new posting (just gather them) - ] - , "/etc/hosts.deny" `File.lacksLine` "leafnode: ALL" - , Apt.serviceInstalledRunning "openbsd-inetd" - , File.notPresent "/etc/cron.daily/leafnode" - , File.notPresent "/etc/cron.d/leafnode" - , Cron.niceJob "oldusenet-expire" "11 1 * * *" "news" newsspool $ intercalate ";" - [ "find \\( -path ./out.going -or -path ./interesting.groups -or -path './*/.overview' \\) -prune -or -type f -ctime +60 -print | xargs --no-run-if-empty rm" - , "find -type d -empty | xargs --no-run-if-empty rmdir" - ] - , Cron.niceJob "oldusenet-uucp" "*/5 * * * *" "news" "/" $ - "/usr/bin/uucp " ++ datadir - , toProp $ Apache.siteEnabled "nntp.olduse.net" $ apachecfg "nntp.olduse.net" False - [ " DocumentRoot " ++ datadir ++ "/" - , " " - , " Options Indexes FollowSymlinks" - , " AllowOverride None" - -- I had this in the file before. - -- This may be needed by a newer version of apache? - --, " Require all granted" - , " " - ] - ] - where - newsspool = "/var/spool/news" - datadir = "/var/spool/oldusenet" - -oldUseNetShellBox :: Property -oldUseNetShellBox = oldUseNetInstalled "oldusenet" - -oldUseNetInstalled :: Apt.Package -> Property -oldUseNetInstalled pkg = check (not <$> Apt.isInstalled pkg) $ - propertyList ("olduse.net " ++ pkg) - [ Apt.installed (words "build-essential devscripts debhelper git libncursesw5-dev libpcre3-dev pkg-config bison libicu-dev libidn11-dev libcanlock2-dev libuu-dev ghc libghc-strptime-dev libghc-hamlet-dev libghc-ifelse-dev libghc-hxt-dev libghc-utf8-string-dev libghc-missingh-dev libghc-sha-dev") - `describe` "olduse.net build deps" - , scriptProperty - [ "rm -rf /root/tmp/oldusenet" -- idenpotency - , "git clone git://olduse.net/ /root/tmp/oldusenet/source" - , "cd /root/tmp/oldusenet/source/" - , "dpkg-buildpackage -us -uc" - , "dpkg -i ../" ++ pkg ++ "_*.deb || true" - , "apt-get -fy install" -- dependencies - , "rm -rf /root/tmp/oldusenet" - ] `describe` "olduse.net built" - ] - - -kgbServer :: Property -kgbServer = withOS desc $ \o -> case o of - (Just (System (Debian Unstable) _)) -> - ensureProperty $ propertyList desc - [ Apt.serviceInstalledRunning "kgb-bot" - , File.hasPrivContent "/etc/kgb-bot/kgb.conf" - `onChange` Service.restarted "kgb-bot" - , "/etc/default/kgb-bot" `File.containsLine` "BOT_ENABLED=1" - `describe` "kgb bot enabled" - `onChange` Service.running "kgb-bot" - ] - _ -> error "kgb server needs Debian unstable (for kgb-bot 1.31+)" - where - desc = "kgb.kitenet.net setup" - -mumbleServer :: [Host] -> Property -mumbleServer hosts = combineProperties "mumble.debian.net" - [ Apt.serviceInstalledRunning "mumble-server" - , Obnam.latestVersion - , Obnam.backup "/var/lib/mumble-server" "55 5 * * *" - [ "--repository=sftp://joey@turtle.kitenet.net/~/lib/backup/mumble.debian.net.obnam" - , "--client-name=mumble" - ] Obnam.OnlyClient - `requires` Ssh.keyImported SshRsa "root" - `requires` Ssh.knownHost hosts "turtle.kitenet.net" "root" - , trivial $ cmdProperty "chown" ["-R", "mumble-server:mumble-server", "/var/lib/mumble-server"] - ] - -obnamLowMem :: Property -obnamLowMem = combineProperties "obnam tuned for low memory use" - [ Obnam.latestVersion - , "/etc/obnam.conf" `File.containsLines` - [ "[config]" - , "# Suggested by liw to keep Obnam memory consumption down (at some speed cost)." - , "upload-queue-size = 128" - , "lru-size = 128" - ] - ] - --- git.kitenet.net and git.joeyh.name -gitServer :: [Host] -> Property -gitServer hosts = propertyList "git.kitenet.net setup" - [ Obnam.latestVersion - , Obnam.backup "/srv/git" "33 3 * * *" - [ "--repository=sftp://2318@usw-s002.rsync.net/~/git.kitenet.net" - , "--encrypt-with=1B169BE1" - , "--client-name=wren" - ] Obnam.OnlyClient - `requires` Gpg.keyImported "1B169BE1" "root" - `requires` Ssh.keyImported SshRsa "root" - `requires` Ssh.knownHost hosts "usw-s002.rsync.net" "root" - `requires` Ssh.authorizedKeys "family" - `requires` User.accountFor "family" - , Apt.installed ["git", "rsync", "kgb-client-git", "gitweb"] - , Apt.installedBackport ["git-annex"] - , File.hasPrivContentExposed "/etc/kgb-bot/kgb-client.conf" - , toProp $ Git.daemonRunning "/srv/git" - , "/etc/gitweb.conf" `File.containsLines` - [ "$projectroot = '/srv/git';" - , "@git_base_url_list = ('git://git.kitenet.net', 'http://git.kitenet.net/git', 'https://git.kitenet.net/git', 'ssh://git.kitenet.net/srv/git');" - , "# disable snapshot download; overloads server" - , "$feature{'snapshot'}{'default'} = [];" - ] - `describe` "gitweb configured" - -- Repos push on to github. - , Ssh.knownHost hosts "github.com" "joey" - -- I keep the website used for gitweb checked into git.. - , Git.cloned "root" "/srv/git/joey/git.kitenet.net.git" "/srv/web/git.kitenet.net" Nothing - , website "git.kitenet.net" - , website "git.joeyh.name" - , toProp $ Apache.modEnabled "cgi" - ] - where - website hn = toProp $ Apache.siteEnabled hn $ apachecfg hn True - [ " DocumentRoot /srv/web/git.kitenet.net/" - , " " - , " Options Indexes ExecCGI FollowSymlinks" - , " AllowOverride None" - , " AddHandler cgi-script .cgi" - , " DirectoryIndex index.cgi" - , " " - , "" - , " ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/" - , " " - , " SetHandler cgi-script" - , " Options ExecCGI" - , " " - ] - -type AnnexUUID = String - --- | A website, with files coming from a git-annex repository. -annexWebSite :: [Host] -> Git.RepoUrl -> HostName -> AnnexUUID -> [(String, Git.RepoUrl)] -> Property -annexWebSite hosts origin hn uuid remotes = propertyList (hn ++" website using git-annex") - [ Git.cloned "joey" origin dir Nothing - `onChange` setup - , postupdatehook `File.hasContent` - [ "#!/bin/sh" - , "exec git update-server-info" - ] `onChange` - (postupdatehook `File.mode` (combineModes (ownerWriteMode:readModes ++ executeModes))) - , setupapache - ] - where - dir = "/srv/web/" ++ hn - postupdatehook = dir ".git/hooks/post-update" - setup = userScriptProperty "joey" setupscript - `requires` Ssh.keyImported SshRsa "joey" - `requires` Ssh.knownHost hosts "turtle.kitenet.net" "joey" - setupscript = - [ "cd " ++ shellEscape dir - , "git config annex.uuid " ++ shellEscape uuid - ] ++ map addremote remotes ++ - [ "git annex get" - ] - addremote (name, url) = "git remote add " ++ shellEscape name ++ " " ++ shellEscape url - setupapache = toProp $ Apache.siteEnabled hn $ apachecfg hn True $ - [ " ServerAlias www."++hn - , "" - , " DocumentRoot /srv/web/"++hn - , " " - , " Options FollowSymLinks" - , " AllowOverride None" - , " " - , " " - , " Options Indexes FollowSymLinks ExecCGI" - , " AllowOverride None" - , " AddHandler cgi-script .cgi" - , " DirectoryIndex index.html index.cgi" - , " Order allow,deny" - , " allow from all" - , " " - ] - -apachecfg :: HostName -> Bool -> Apache.ConfigFile -> Apache.ConfigFile -apachecfg hn withssl middle - | withssl = vhost False ++ vhost True - | otherwise = vhost False - where - vhost ssl = - [ "" - , " ServerAdmin grue@joeyh.name" - , " ServerName "++hn++":"++show port - ] - ++ mainhttpscert ssl - ++ middle ++ - [ "" - , " ErrorLog /var/log/apache2/error.log" - , " LogLevel warn" - , " CustomLog /var/log/apache2/access.log combined" - , " ServerSignature On" - , " " - , " " - , " Options Indexes MultiViews" - , " AllowOverride None" - , " Order allow,deny" - , " Allow from all" - , " " - , "" - ] - where - port = if ssl then 443 else 80 :: Int - -mainhttpscert :: Bool -> Apache.ConfigFile -mainhttpscert False = [] -mainhttpscert True = - [ " SSLEngine on" - , " SSLCertificateFile /etc/ssl/certs/web.pem" - , " SSLCertificateKeyFile /etc/ssl/private/web.pem" - , " SSLCertificateChainFile /etc/ssl/certs/startssl.pem" - ] - -gitAnnexDistributor :: Property -gitAnnexDistributor = combineProperties "git-annex distributor, including rsync server and signer" - [ Apt.installed ["rsync"] - , File.hasPrivContent "/etc/rsyncd.conf" - , File.hasPrivContent "/etc/rsyncd.secrets" - , "/etc/default/rsync" `File.containsLine` "RSYNC_ENABLE=true" - `onChange` Service.running "rsync" - , endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild" - , endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild/x86_64-apple-mavericks" - -- git-annex distribution signing key - , Gpg.keyImported "89C809CB" "joey" - ] - where - endpoint d = combineProperties ("endpoint " ++ d) - [ File.dirExists d - , File.ownerGroup d "joey" "joey" - ] - --- Twitter, you kill us. -twitRss :: Property -twitRss = combineProperties "twitter rss" - [ Git.cloned "joey" "git://git.kitenet.net/twitrss.git" dir Nothing - , check (not <$> doesFileExist (dir "twitRss")) $ - userScriptProperty "joey" - [ "cd " ++ dir - , "ghc --make twitRss" - ] - `requires` Apt.installed - [ "libghc-xml-dev" - , "libghc-feed-dev" - , "libghc-tagsoup-dev" - ] - , feed "http://twitter.com/search/realtime?q=git-annex" "git-annex-twitter" - , feed "http://twitter.com/search/realtime?q=olduse+OR+git-annex+OR+debhelper+OR+etckeeper+OR+ikiwiki+-ashley_ikiwiki" "twittergrep" - ] - where - dir = "/srv/web/tmp.kitenet.net/twitrss" - crontime = "15 * * * *" - feed url desc = Cron.job desc crontime "joey" dir $ - "./twitRss " ++ shellEscape url ++ " > " ++ shellEscape ("../" ++ desc ++ ".rss") - -ircBouncer :: Property -ircBouncer = propertyList "IRC bouncer" - [ Apt.installed ["znc"] - , User.accountFor "znc" - , File.hasPrivContent conf - , File.ownerGroup conf "znc" "znc" - , Cron.job "znconboot" "@reboot" "znc" "~" "znc" - , Cron.job "zncrunning" "@hourly" "znc" "~" "znc || true" - ] - where - conf = "/home/znc/.znc/configs/znc.conf" diff --git a/Propellor/Property/Ssh.hs b/Propellor/Property/Ssh.hs deleted file mode 100644 index a4f87678..00000000 --- a/Propellor/Property/Ssh.hs +++ /dev/null @@ -1,152 +0,0 @@ -module Propellor.Property.Ssh ( - setSshdConfig, - permitRootLogin, - passwordAuthentication, - hasAuthorizedKeys, - restartSshd, - randomHostKeys, - hostKey, - keyImported, - knownHost, - authorizedKeys -) where - -import Propellor -import qualified Propellor.Property.File as File -import Propellor.Property.User -import Utility.SafeCommand -import Utility.FileMode - -import System.PosixCompat - -sshBool :: Bool -> String -sshBool True = "yes" -sshBool False = "no" - -sshdConfig :: FilePath -sshdConfig = "/etc/ssh/sshd_config" - -setSshdConfig :: String -> Bool -> Property -setSshdConfig setting allowed = combineProperties "sshd config" - [ sshdConfig `File.lacksLine` (sshline $ not allowed) - , sshdConfig `File.containsLine` (sshline allowed) - ] - `onChange` restartSshd - `describe` unwords [ "ssh config:", setting, sshBool allowed ] - where - sshline v = setting ++ " " ++ sshBool v - -permitRootLogin :: Bool -> Property -permitRootLogin = setSshdConfig "PermitRootLogin" - -passwordAuthentication :: Bool -> Property -passwordAuthentication = setSshdConfig "PasswordAuthentication" - -dotDir :: UserName -> IO FilePath -dotDir user = do - h <- homedir user - return $ h ".ssh" - -dotFile :: FilePath -> UserName -> IO FilePath -dotFile f user = do - d <- dotDir user - return $ d f - -hasAuthorizedKeys :: UserName -> IO Bool -hasAuthorizedKeys = go <=< dotFile "authorized_keys" - where - go f = not . null <$> catchDefaultIO "" (readFile f) - -restartSshd :: Property -restartSshd = cmdProperty "service" ["ssh", "restart"] - --- | Blows away existing host keys and make new ones. --- Useful for systems installed from an image that might reuse host keys. --- A flag file is used to only ever do this once. -randomHostKeys :: Property -randomHostKeys = flagFile prop "/etc/ssh/.unique_host_keys" - `onChange` restartSshd - where - prop = property "ssh random host keys" $ do - void $ liftIO $ boolSystem "sh" - [ Param "-c" - , Param "rm -f /etc/ssh/ssh_host_*" - ] - ensureProperty $ - cmdProperty "/var/lib/dpkg/info/openssh-server.postinst" - ["configure"] - --- | Sets ssh host keys from the site's PrivData. --- --- (Uses a null username for host keys.) -hostKey :: SshKeyType -> Property -hostKey keytype = combineProperties desc - [ property desc (install writeFile (SshPubKey keytype "") ".pub") - , property desc (install writeFileProtected (SshPrivKey keytype "") "") - ] - `onChange` restartSshd - where - desc = "known ssh host key (" ++ fromKeyType keytype ++ ")" - install writer p ext = withPrivData p $ \key -> do - let f = "/etc/ssh/ssh_host_" ++ fromKeyType keytype ++ "_key" ++ ext - s <- liftIO $ readFileStrict f - if s == key - then noChange - else makeChange $ writer f key - --- | Sets up a user with a ssh private key and public key pair --- from the site's PrivData. -keyImported :: SshKeyType -> UserName -> Property -keyImported keytype user = combineProperties desc - [ property desc (install writeFile (SshPubKey keytype user) ".pub") - , property desc (install writeFileProtected (SshPrivKey keytype user) "") - ] - where - desc = user ++ " has ssh key (" ++ fromKeyType keytype ++ ")" - install writer p ext = do - f <- liftIO $ keyfile ext - ifM (liftIO $ doesFileExist f) - ( noChange - , ensureProperty $ combineProperties desc - [ property desc $ - withPrivData p $ \key -> makeChange $ - writer f key - , File.ownerGroup f user user - ] - ) - keyfile ext = do - home <- homeDirectory <$> getUserEntryForName user - return $ home ".ssh" "id_" ++ fromKeyType keytype ++ ext - -fromKeyType :: SshKeyType -> String -fromKeyType SshRsa = "rsa" -fromKeyType SshDsa = "dsa" -fromKeyType SshEcdsa = "ecdsa" -fromKeyType SshEd25519 = "ed25519" - --- | Puts some host's ssh public key into the known_hosts file for a user. -knownHost :: [Host] -> HostName -> UserName -> Property -knownHost hosts hn user = property desc $ - go =<< fromHost hosts hn getSshPubKey - where - desc = user ++ " knows ssh key for " ++ hn - go (Just (Just k)) = do - f <- liftIO $ dotFile "known_hosts" user - ensureProperty $ combineProperties desc - [ File.dirExists (takeDirectory f) - , f `File.containsLine` (hn ++ " " ++ k) - , File.ownerGroup f user user - ] - go _ = do - warningMessage $ "no configred sshPubKey for " ++ hn - return FailedChange - --- | Makes a user have authorized_keys from the PrivData -authorizedKeys :: UserName -> Property -authorizedKeys user = property (user ++ " has authorized_keys") $ - withPrivData (SshAuthorizedKeys user) $ \v -> do - f <- liftIO $ dotFile "authorized_keys" user - liftIO $ do - createDirectoryIfMissing True (takeDirectory f) - writeFileProtected f v - ensureProperty $ File.ownerGroup f user user diff --git a/Propellor/Property/Sudo.hs b/Propellor/Property/Sudo.hs deleted file mode 100644 index 68b56608..00000000 --- a/Propellor/Property/Sudo.hs +++ /dev/null @@ -1,32 +0,0 @@ -module Propellor.Property.Sudo where - -import Data.List - -import Propellor -import Propellor.Property.File -import qualified Propellor.Property.Apt as Apt -import Propellor.Property.User - --- | Allows a user to sudo. If the user has a password, sudo is configured --- to require it. If not, NOPASSWORD is enabled for the user. -enabledFor :: UserName -> Property -enabledFor user = property desc go `requires` Apt.installed ["sudo"] - where - go = do - locked <- liftIO $ isLockedPassword user - ensureProperty $ - fileProperty desc - (modify locked . filter (wanted locked)) - "/etc/sudoers" - desc = user ++ " is sudoer" - sudobaseline = user ++ " ALL=(ALL:ALL)" - sudoline True = sudobaseline ++ " NOPASSWD:ALL" - sudoline False = sudobaseline ++ " ALL" - wanted locked l - -- TOOD: Full sudoers file format parse.. - | not (sudobaseline `isPrefixOf` l) = True - | "NOPASSWD" `isInfixOf` l = locked - | otherwise = True - modify locked ls - | sudoline locked `elem` ls = ls - | otherwise = ls ++ [sudoline locked] diff --git a/Propellor/Property/Tor.hs b/Propellor/Property/Tor.hs deleted file mode 100644 index 78e35c89..00000000 --- a/Propellor/Property/Tor.hs +++ /dev/null @@ -1,19 +0,0 @@ -module Propellor.Property.Tor where - -import Propellor -import qualified Propellor.Property.File as File -import qualified Propellor.Property.Apt as Apt - -isBridge :: Property -isBridge = setup `requires` Apt.installed ["tor"] - `describe` "tor bridge" - where - setup = "/etc/tor/torrc" `File.hasContent` - [ "SocksPort 0" - , "ORPort 443" - , "BridgeRelay 1" - , "Exitpolicy reject *:*" - ] `onChange` restartTor - -restartTor :: Property -restartTor = cmdProperty "service" ["tor", "restart"] diff --git a/Propellor/Property/User.hs b/Propellor/Property/User.hs deleted file mode 100644 index eef2a57e..00000000 --- a/Propellor/Property/User.hs +++ /dev/null @@ -1,61 +0,0 @@ -module Propellor.Property.User where - -import System.Posix - -import Propellor - -data Eep = YesReallyDeleteHome - -accountFor :: UserName -> Property -accountFor user = check (isNothing <$> catchMaybeIO (homedir user)) $ cmdProperty "adduser" - [ "--disabled-password" - , "--gecos", "" - , user - ] - `describe` ("account for " ++ user) - --- | Removes user home directory!! Use with caution. -nuked :: UserName -> Eep -> Property -nuked user _ = check (isJust <$> catchMaybeIO (homedir user)) $ cmdProperty "userdel" - [ "-r" - , user - ] - `describe` ("nuked user " ++ user) - --- | Only ensures that the user has some password set. It may or may --- not be the password from the PrivData. -hasSomePassword :: UserName -> Property -hasSomePassword user = check ((/= HasPassword) <$> getPasswordStatus user) $ - hasPassword user - -hasPassword :: UserName -> Property -hasPassword user = property (user ++ " has password") $ - withPrivData (Password user) $ \password -> makeChange $ - withHandle StdinHandle createProcessSuccess - (proc "chpasswd" []) $ \h -> do - hPutStrLn h $ user ++ ":" ++ password - hClose h - -lockedPassword :: UserName -> Property -lockedPassword user = check (not <$> isLockedPassword user) $ cmdProperty "passwd" - [ "--lock" - , user - ] - `describe` ("locked " ++ user ++ " password") - -data PasswordStatus = NoPassword | LockedPassword | HasPassword - deriving (Eq) - -getPasswordStatus :: UserName -> IO PasswordStatus -getPasswordStatus user = parse . words <$> readProcess "passwd" ["-S", user] - where - parse (_:"L":_) = LockedPassword - parse (_:"NP":_) = NoPassword - parse (_:"P":_) = HasPassword - parse _ = NoPassword - -isLockedPassword :: UserName -> IO Bool -isLockedPassword user = (== LockedPassword) <$> getPasswordStatus user - -homedir :: UserName -> IO FilePath -homedir user = homeDirectory <$> getUserEntryForName user diff --git a/Propellor/SimpleSh.hs b/Propellor/SimpleSh.hs deleted file mode 100644 index 7ba30b0e..00000000 --- a/Propellor/SimpleSh.hs +++ /dev/null @@ -1,101 +0,0 @@ --- | Simple server, using a named pipe. Client connects, sends a command, --- and gets back all the output from the command, in a stream. --- --- This is useful for eg, docker. - -module Propellor.SimpleSh where - -import Network.Socket -import Control.Concurrent -import Control.Concurrent.Async -import System.Process (std_in, std_out, std_err) - -import Propellor -import Utility.FileMode -import Utility.ThreadScheduler - -data Cmd = Cmd String [String] - deriving (Read, Show) - -data Resp = StdoutLine String | StderrLine String | Done - deriving (Read, Show) - -simpleSh :: FilePath -> IO () -simpleSh namedpipe = do - nukeFile namedpipe - let dir = takeDirectory namedpipe - createDirectoryIfMissing True dir - modifyFileMode dir (removeModes otherGroupModes) - s <- socket AF_UNIX Stream defaultProtocol - bindSocket s (SockAddrUnix namedpipe) - listen s 2 - forever $ do - (client, _addr) <- accept s - forkIO $ do - h <- socketToHandle client ReadWriteMode - maybe noop (run h) . readish =<< hGetLine h - where - run h (Cmd cmd params) = do - chan <- newChan - let runwriter = do - v <- readChan chan - hPutStrLn h (show v) - hFlush h - case v of - Done -> noop - _ -> runwriter - writer <- async runwriter - - flip catchIO (\_e -> writeChan chan Done) $ do - let p = (proc cmd params) - { std_in = Inherit - , std_out = CreatePipe - , std_err = CreatePipe - } - (Nothing, Just outh, Just errh, pid) <- createProcess p - - let mkreader t from = maybe noop (const $ mkreader t from) - =<< catchMaybeIO (writeChan chan . t =<< hGetLine from) - void $ concurrently - (mkreader StdoutLine outh) - (mkreader StderrLine errh) - - void $ tryIO $ waitForProcess pid - - writeChan chan Done - - hClose outh - hClose errh - - wait writer - hClose h - -simpleShClient :: FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a -simpleShClient namedpipe cmd params handler = do - s <- socket AF_UNIX Stream defaultProtocol - connect s (SockAddrUnix namedpipe) - h <- socketToHandle s ReadWriteMode - hPutStrLn h $ show $ Cmd cmd params - hFlush h - resps <- catMaybes . map readish . lines <$> hGetContents h - v <- hClose h `after` handler resps - return v - -simpleShClientRetry :: Int -> FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a -simpleShClientRetry retries namedpipe cmd params handler = go retries - where - run = simpleShClient namedpipe cmd params handler - go n - | n < 1 = run - | otherwise = do - v <- tryIO run - case v of - Right r -> return r - Left e -> do - debug ["simplesh connection retry", show e] - threadDelaySeconds (Seconds 1) - go (n - 1) - -getStdout :: Resp -> Maybe String -getStdout (StdoutLine s) = Just s -getStdout _ = Nothing diff --git a/Propellor/Types.hs b/Propellor/Types.hs deleted file mode 100644 index 22df9ddb..00000000 --- a/Propellor/Types.hs +++ /dev/null @@ -1,153 +0,0 @@ -{-# LANGUAGE PackageImports #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE ExistentialQuantification #-} - -module Propellor.Types - ( Host(..) - , Attr - , SetAttr - , Propellor(..) - , Property(..) - , RevertableProperty(..) - , IsProp - , describe - , toProp - , setAttr - , requires - , Desc - , Result(..) - , ActionResult(..) - , CmdLine(..) - , PrivDataField(..) - , GpgKeyId - , SshKeyType(..) - , module Propellor.Types.OS - , module Propellor.Types.Dns - ) where - -import Data.Monoid -import Control.Applicative -import System.Console.ANSI -import "mtl" Control.Monad.Reader -import "MonadCatchIO-transformers" Control.Monad.CatchIO - -import Propellor.Types.Attr -import Propellor.Types.OS -import Propellor.Types.Dns - -data Host = Host [Property] SetAttr - --- | Propellor's monad provides read-only access to attributes of the --- system. -newtype Propellor p = Propellor { runWithAttr :: ReaderT Attr IO p } - deriving - ( Monad - , Functor - , Applicative - , MonadReader Attr - , MonadIO - , MonadCatchIO - ) - --- | The core data type of Propellor, this represents a property --- that the system should have, and an action to ensure it has the --- property. -data Property = Property - { propertyDesc :: Desc - , propertySatisfy :: Propellor Result - -- ^ must be idempotent; may run repeatedly - , propertyAttr :: SetAttr - -- ^ a property can set an Attr on the host that has the property. - } - --- | A property that can be reverted. -data RevertableProperty = RevertableProperty Property Property - -class IsProp p where - -- | Sets description. - describe :: p -> Desc -> p - toProp :: p -> Property - -- | Indicates that the first property can only be satisfied - -- once the second one is. - requires :: p -> Property -> p - setAttr :: p -> SetAttr - -instance IsProp Property where - describe p d = p { propertyDesc = d } - toProp p = p - setAttr = propertyAttr - x `requires` y = Property (propertyDesc x) satisfy attr - where - attr = propertyAttr x . propertyAttr y - satisfy = do - r <- propertySatisfy y - case r of - FailedChange -> return FailedChange - _ -> propertySatisfy x - - -instance IsProp RevertableProperty where - -- | Sets the description of both sides. - describe (RevertableProperty p1 p2) d = - RevertableProperty (describe p1 d) (describe p2 ("not " ++ d)) - toProp (RevertableProperty p1 _) = p1 - (RevertableProperty p1 p2) `requires` y = - RevertableProperty (p1 `requires` y) p2 - -- | Return the SetAttr of the currently active side. - setAttr (RevertableProperty p1 _p2) = setAttr p1 - -type Desc = String - -data Result = NoChange | MadeChange | FailedChange - deriving (Read, Show, Eq) - -instance Monoid Result where - mempty = NoChange - - mappend FailedChange _ = FailedChange - mappend _ FailedChange = FailedChange - mappend MadeChange _ = MadeChange - mappend _ MadeChange = MadeChange - mappend NoChange NoChange = NoChange - --- | Results of actions, with color. -class ActionResult a where - getActionResult :: a -> (String, ColorIntensity, Color) - -instance ActionResult Bool where - getActionResult False = ("failed", Vivid, Red) - getActionResult True = ("done", Dull, Green) - -instance ActionResult Result where - getActionResult NoChange = ("ok", Dull, Green) - getActionResult MadeChange = ("done", Vivid, Green) - getActionResult FailedChange = ("failed", Vivid, Red) - -data CmdLine - = Run HostName - | Spin HostName - | Boot HostName - | Set HostName PrivDataField - | AddKey String - | Continue CmdLine - | Chain HostName - | Docker HostName - deriving (Read, Show, Eq) - --- | Note that removing or changing field names will break the --- serialized privdata files, so don't do that! --- It's fine to add new fields. -data PrivDataField - = DockerAuthentication - | SshPubKey SshKeyType UserName - | SshPrivKey SshKeyType UserName - | SshAuthorizedKeys UserName - | Password UserName - | PrivFile FilePath - | GpgKey GpgKeyId - deriving (Read, Show, Ord, Eq) - -type GpgKeyId = String - -data SshKeyType = SshRsa | SshDsa | SshEcdsa | SshEd25519 - deriving (Read, Show, Ord, Eq) diff --git a/Propellor/Types/Attr.hs b/Propellor/Types/Attr.hs deleted file mode 100644 index 8b7d3b09..00000000 --- a/Propellor/Types/Attr.hs +++ /dev/null @@ -1,48 +0,0 @@ -module Propellor.Types.Attr where - -import Propellor.Types.OS -import qualified Propellor.Types.Dns as Dns - -import qualified Data.Set as S -import qualified Data.Map as M - --- | The attributes of a host. For example, its hostname. -data Attr = Attr - { _hostname :: HostName - , _os :: Maybe System - , _sshPubKey :: Maybe String - , _dns :: S.Set Dns.Record - , _namedconf :: M.Map Dns.Domain Dns.NamedConf - - , _dockerImage :: Maybe String - , _dockerRunParams :: [HostName -> String] - } - -instance Eq Attr where - x == y = and - [ _hostname x == _hostname y - , _os x == _os y - , _dns x == _dns y - , _namedconf x == _namedconf y - , _sshPubKey x == _sshPubKey y - - , _dockerImage x == _dockerImage y - , let simpl v = map (\a -> a "") (_dockerRunParams v) - in simpl x == simpl y - ] - -instance Show Attr where - show a = unlines - [ "hostname " ++ _hostname a - , "OS " ++ show (_os a) - , "sshPubKey " ++ show (_sshPubKey a) - , "dns " ++ show (_dns a) - , "namedconf " ++ show (_namedconf a) - , "docker image " ++ show (_dockerImage a) - , "docker run params " ++ show (map (\mk -> mk "") (_dockerRunParams a)) - ] - -newAttr :: HostName -> Attr -newAttr hn = Attr hn Nothing Nothing S.empty M.empty Nothing [] - -type SetAttr = Attr -> Attr diff --git a/Propellor/Types/Dns.hs b/Propellor/Types/Dns.hs deleted file mode 100644 index ba6a92dd..00000000 --- a/Propellor/Types/Dns.hs +++ /dev/null @@ -1,92 +0,0 @@ -module Propellor.Types.Dns where - -import Propellor.Types.OS (HostName) - -import Data.Word - -type Domain = String - -data IPAddr = IPv4 String | IPv6 String - deriving (Read, Show, Eq, Ord) - -fromIPAddr :: IPAddr -> String -fromIPAddr (IPv4 addr) = addr -fromIPAddr (IPv6 addr) = addr - --- | Represents a bind 9 named.conf file. -data NamedConf = NamedConf - { confDomain :: Domain - , confDnsServerType :: DnsServerType - , confFile :: FilePath - , confMasters :: [IPAddr] - , confAllowTransfer :: [IPAddr] - , confLines :: [String] - } - deriving (Show, Eq, Ord) - -data DnsServerType = Master | Secondary - deriving (Show, Eq, Ord) - --- | Represents a bind 9 zone file. -data Zone = Zone - { zDomain :: Domain - , zSOA :: SOA - , zHosts :: [(BindDomain, Record)] - } - deriving (Read, Show, Eq) - --- | Every domain has a SOA record, which is big and complicated. -data SOA = SOA - { sDomain :: BindDomain - -- ^ Typically ns1.your.domain - , sSerial :: SerialNumber - -- ^ The most important parameter is the serial number, - -- which must increase after each change. - , sRefresh :: Integer - , sRetry :: Integer - , sExpire :: Integer - , sNegativeCacheTTL :: Integer - } - deriving (Read, Show, Eq) - --- | Types of DNS records. --- --- This is not a complete list, more can be added. -data Record - = Address IPAddr - | CNAME BindDomain - | MX Int BindDomain - | NS BindDomain - | TXT String - | SRV Word16 Word16 Word16 BindDomain - deriving (Read, Show, Eq, Ord) - -getIPAddr :: Record -> Maybe IPAddr -getIPAddr (Address addr) = Just addr -getIPAddr _ = Nothing - -getCNAME :: Record -> Maybe BindDomain -getCNAME (CNAME d) = Just d -getCNAME _ = Nothing - -getNS :: Record -> Maybe BindDomain -getNS (NS d) = Just d -getNS _ = Nothing - --- | Bind serial numbers are unsigned, 32 bit integers. -type SerialNumber = Word32 - --- | Domains in the zone file must end with a period if they are absolute. --- --- Let's use a type to keep absolute domains straight from relative --- domains. --- --- The RootDomain refers to the top level of the domain, so can be used --- to add nameservers, MX's, etc to a domain. -data BindDomain = RelDomain Domain | AbsDomain Domain | RootDomain - deriving (Read, Show, Eq, Ord) - -domainHostName :: BindDomain -> Maybe HostName -domainHostName (RelDomain d) = Just d -domainHostName (AbsDomain d) = Just d -domainHostName RootDomain = Nothing diff --git a/Propellor/Types/OS.hs b/Propellor/Types/OS.hs deleted file mode 100644 index 23cc8a29..00000000 --- a/Propellor/Types/OS.hs +++ /dev/null @@ -1,27 +0,0 @@ -module Propellor.Types.OS where - -type HostName = String -type UserName = String -type GroupName = String - --- | High level descritption of a operating system. -data System = System Distribution Architecture - deriving (Show, Eq) - -data Distribution - = Debian DebianSuite - | Ubuntu Release - deriving (Show, Eq) - -data DebianSuite = Experimental | Unstable | Testing | Stable | DebianRelease Release - deriving (Show, Eq) - --- | The release that currently corresponds to stable. -stableRelease :: DebianSuite -stableRelease = DebianRelease "wheezy" - -isStable :: DebianSuite -> Bool -isStable s = s == Stable || s == stableRelease - -type Release = String -type Architecture = String diff --git a/Utility/Applicative.hs b/Utility/Applicative.hs deleted file mode 100644 index fd8944b2..00000000 --- a/Utility/Applicative.hs +++ /dev/null @@ -1,16 +0,0 @@ -{- applicative stuff - - - - Copyright 2012 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.Applicative where - -{- Like <$> , but supports one level of currying. - - - - foo v = bar <$> action v == foo = bar <$$> action - -} -(<$$>) :: Functor f => (a -> b) -> (c -> f a) -> c -> f b -f <$$> v = fmap f . v -infixr 4 <$$> diff --git a/Utility/Data.hs b/Utility/Data.hs deleted file mode 100644 index 2df12b36..00000000 --- a/Utility/Data.hs +++ /dev/null @@ -1,17 +0,0 @@ -{- utilities for simple data types - - - - Copyright 2013 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.Data where - -{- First item in the list that is not Nothing. -} -firstJust :: Eq a => [Maybe a] -> Maybe a -firstJust ms = case dropWhile (== Nothing) ms of - [] -> Nothing - (md:_) -> md - -eitherToMaybe :: Either a b -> Maybe b -eitherToMaybe = either (const Nothing) Just diff --git a/Utility/Directory.hs b/Utility/Directory.hs deleted file mode 100644 index d92327c0..00000000 --- a/Utility/Directory.hs +++ /dev/null @@ -1,135 +0,0 @@ -{- directory manipulation - - - - Copyright 2011-2014 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.Directory where - -import System.IO.Error -import System.Directory -import Control.Exception (throw) -import Control.Monad -import Control.Monad.IfElse -import System.FilePath -import Control.Applicative -import System.IO.Unsafe (unsafeInterleaveIO) - -import Utility.PosixFiles -import Utility.SafeCommand -import Utility.Tmp -import Utility.Exception -import Utility.Monad -import Utility.Applicative - -dirCruft :: FilePath -> Bool -dirCruft "." = True -dirCruft ".." = True -dirCruft _ = False - -{- Lists the contents of a directory. - - Unlike getDirectoryContents, paths are not relative to the directory. -} -dirContents :: FilePath -> IO [FilePath] -dirContents d = map (d ) . filter (not . dirCruft) <$> getDirectoryContents d - -{- Gets files in a directory, and then its subdirectories, recursively, - - and lazily. - - - - Does not follow symlinks to other subdirectories. - - - - When the directory does not exist, no exception is thrown, - - instead, [] is returned. -} -dirContentsRecursive :: FilePath -> IO [FilePath] -dirContentsRecursive = dirContentsRecursiveSkipping (const False) True - -{- Skips directories whose basenames match the skipdir. -} -dirContentsRecursiveSkipping :: (FilePath -> Bool) -> Bool -> FilePath -> IO [FilePath] -dirContentsRecursiveSkipping skipdir followsubdirsymlinks topdir = go [topdir] - where - go [] = return [] - go (dir:dirs) - | skipdir (takeFileName dir) = go dirs - | otherwise = unsafeInterleaveIO $ do - (files, dirs') <- collect [] [] - =<< catchDefaultIO [] (dirContents dir) - files' <- go (dirs' ++ dirs) - return (files ++ files') - collect files dirs' [] = return (reverse files, reverse dirs') - collect files dirs' (entry:entries) - | dirCruft entry = collect files dirs' entries - | otherwise = do - let skip = collect (entry:files) dirs' entries - let recurse = collect files (entry:dirs') entries - ms <- catchMaybeIO $ getSymbolicLinkStatus entry - case ms of - (Just s) - | isDirectory s -> recurse - | isSymbolicLink s && followsubdirsymlinks -> - ifM (doesDirectoryExist entry) - ( recurse - , skip - ) - _ -> skip - -{- Gets the directory tree from a point, recursively and lazily, - - with leaf directories **first**, skipping any whose basenames - - match the skipdir. Does not follow symlinks. -} -dirTreeRecursiveSkipping :: (FilePath -> Bool) -> FilePath -> IO [FilePath] -dirTreeRecursiveSkipping skipdir topdir = go [] [topdir] - where - go c [] = return c - go c (dir:dirs) - | skipdir (takeFileName dir) = go c dirs - | otherwise = unsafeInterleaveIO $ do - subdirs <- go c - =<< filterM (isDirectory <$$> getSymbolicLinkStatus) - =<< catchDefaultIO [] (dirContents dir) - go (subdirs++[dir]) dirs - -{- Moves one filename to another. - - First tries a rename, but falls back to moving across devices if needed. -} -moveFile :: FilePath -> FilePath -> IO () -moveFile src dest = tryIO (rename src dest) >>= onrename - where - onrename (Right _) = noop - onrename (Left e) - | isPermissionError e = rethrow - | isDoesNotExistError e = rethrow - | otherwise = do - -- copyFile is likely not as optimised as - -- the mv command, so we'll use the latter. - -- But, mv will move into a directory if - -- dest is one, which is not desired. - whenM (isdir dest) rethrow - viaTmp mv dest undefined - where - rethrow = throw e - mv tmp _ = do - ok <- boolSystem "mv" [Param "-f", Param src, Param tmp] - unless ok $ do - -- delete any partial - _ <- tryIO $ removeFile tmp - rethrow - - isdir f = do - r <- tryIO $ getFileStatus f - case r of - (Left _) -> return False - (Right s) -> return $ isDirectory s - -{- Removes a file, which may or may not exist, and does not have to - - be a regular file. - - - - Note that an exception is thrown if the file exists but - - cannot be removed. -} -nukeFile :: FilePath -> IO () -nukeFile file = void $ tryWhenExists go - where -#ifndef mingw32_HOST_OS - go = removeLink file -#else - go = removeFile file -#endif diff --git a/Utility/Env.hs b/Utility/Env.hs deleted file mode 100644 index 6763c24e..00000000 --- a/Utility/Env.hs +++ /dev/null @@ -1,81 +0,0 @@ -{- portable environment variables - - - - Copyright 2013 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.Env where - -#ifdef mingw32_HOST_OS -import Utility.Exception -import Control.Applicative -import Data.Maybe -import qualified System.Environment as E -#else -import qualified System.Posix.Env as PE -#endif - -getEnv :: String -> IO (Maybe String) -#ifndef mingw32_HOST_OS -getEnv = PE.getEnv -#else -getEnv = catchMaybeIO . E.getEnv -#endif - -getEnvDefault :: String -> String -> IO String -#ifndef mingw32_HOST_OS -getEnvDefault = PE.getEnvDefault -#else -getEnvDefault var fallback = fromMaybe fallback <$> getEnv var -#endif - -getEnvironment :: IO [(String, String)] -#ifndef mingw32_HOST_OS -getEnvironment = PE.getEnvironment -#else -getEnvironment = E.getEnvironment -#endif - -{- Returns True if it could successfully set the environment variable. - - - - There is, apparently, no way to do this in Windows. Instead, - - environment varuables must be provided when running a new process. -} -setEnv :: String -> String -> Bool -> IO Bool -#ifndef mingw32_HOST_OS -setEnv var val overwrite = do - PE.setEnv var val overwrite - return True -#else -setEnv _ _ _ = return False -#endif - -{- Returns True if it could successfully unset the environment variable. -} -unsetEnv :: String -> IO Bool -#ifndef mingw32_HOST_OS -unsetEnv var = do - PE.unsetEnv var - return True -#else -unsetEnv _ = return False -#endif - -{- Adds the environment variable to the input environment. If already - - present in the list, removes the old value. - - - - This does not really belong here, but Data.AssocList is for some reason - - buried inside hxt. - -} -addEntry :: Eq k => k -> v -> [(k, v)] -> [(k, v)] -addEntry k v l = ( (k,v) : ) $! delEntry k l - -addEntries :: Eq k => [(k, v)] -> [(k, v)] -> [(k, v)] -addEntries = foldr (.) id . map (uncurry addEntry) . reverse - -delEntry :: Eq k => k -> [(k, v)] -> [(k, v)] -delEntry _ [] = [] -delEntry k (x@(k1,_) : rest) - | k == k1 = rest - | otherwise = ( x : ) $! delEntry k rest diff --git a/Utility/Exception.hs b/Utility/Exception.hs deleted file mode 100644 index 1fecf65d..00000000 --- a/Utility/Exception.hs +++ /dev/null @@ -1,59 +0,0 @@ -{- Simple IO exception handling (and some more) - - - - Copyright 2011-2012 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE ScopedTypeVariables #-} - -module Utility.Exception where - -import Control.Exception -import qualified Control.Exception as E -import Control.Applicative -import Control.Monad -import System.IO.Error (isDoesNotExistError) -import Utility.Data - -{- Catches IO errors and returns a Bool -} -catchBoolIO :: IO Bool -> IO Bool -catchBoolIO = catchDefaultIO False - -{- Catches IO errors and returns a Maybe -} -catchMaybeIO :: IO a -> IO (Maybe a) -catchMaybeIO a = catchDefaultIO Nothing $ Just <$> a - -{- Catches IO errors and returns a default value. -} -catchDefaultIO :: a -> IO a -> IO a -catchDefaultIO def a = catchIO a (const $ return def) - -{- Catches IO errors and returns the error message. -} -catchMsgIO :: IO a -> IO (Either String a) -catchMsgIO a = either (Left . show) Right <$> tryIO a - -{- catch specialized for IO errors only -} -catchIO :: IO a -> (IOException -> IO a) -> IO a -catchIO = E.catch - -{- try specialized for IO errors only -} -tryIO :: IO a -> IO (Either IOException a) -tryIO = try - -{- Catches all exceptions except for async exceptions. - - This is often better to use than catching them all, so that - - ThreadKilled and UserInterrupt get through. - -} -catchNonAsync :: IO a -> (SomeException -> IO a) -> IO a -catchNonAsync a onerr = a `catches` - [ Handler (\ (e :: AsyncException) -> throw e) - , Handler (\ (e :: SomeException) -> onerr e) - ] - -tryNonAsync :: IO a -> IO (Either SomeException a) -tryNonAsync a = (Right <$> a) `catchNonAsync` (return . Left) - -{- Catches only DoesNotExist exceptions, and lets all others through. -} -tryWhenExists :: IO a -> IO (Maybe a) -tryWhenExists a = eitherToMaybe <$> - tryJust (guard . isDoesNotExistError) a diff --git a/Utility/FileMode.hs b/Utility/FileMode.hs deleted file mode 100644 index c2ef683a..00000000 --- a/Utility/FileMode.hs +++ /dev/null @@ -1,158 +0,0 @@ -{- File mode utilities. - - - - Copyright 2010-2012 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.FileMode where - -import System.IO -import Control.Monad -import Control.Exception (bracket) -import System.PosixCompat.Types -import Utility.PosixFiles -#ifndef mingw32_HOST_OS -import System.Posix.Files -#endif -import Foreign (complement) - -import Utility.Exception - -{- Applies a conversion function to a file's mode. -} -modifyFileMode :: FilePath -> (FileMode -> FileMode) -> IO () -modifyFileMode f convert = void $ modifyFileMode' f convert -modifyFileMode' :: FilePath -> (FileMode -> FileMode) -> IO FileMode -modifyFileMode' f convert = do - s <- getFileStatus f - let old = fileMode s - let new = convert old - when (new /= old) $ - setFileMode f new - return old - -{- Adds the specified FileModes to the input mode, leaving the rest - - unchanged. -} -addModes :: [FileMode] -> FileMode -> FileMode -addModes ms m = combineModes (m:ms) - -{- Removes the specified FileModes from the input mode. -} -removeModes :: [FileMode] -> FileMode -> FileMode -removeModes ms m = m `intersectFileModes` complement (combineModes ms) - -{- Runs an action after changing a file's mode, then restores the old mode. -} -withModifiedFileMode :: FilePath -> (FileMode -> FileMode) -> IO a -> IO a -withModifiedFileMode file convert a = bracket setup cleanup go - where - setup = modifyFileMode' file convert - cleanup oldmode = modifyFileMode file (const oldmode) - go _ = a - -writeModes :: [FileMode] -writeModes = [ownerWriteMode, groupWriteMode, otherWriteMode] - -readModes :: [FileMode] -readModes = [ownerReadMode, groupReadMode, otherReadMode] - -executeModes :: [FileMode] -executeModes = [ownerExecuteMode, groupExecuteMode, otherExecuteMode] - -otherGroupModes :: [FileMode] -otherGroupModes = - [ groupReadMode, otherReadMode - , groupWriteMode, otherWriteMode - ] - -{- Removes the write bits from a file. -} -preventWrite :: FilePath -> IO () -preventWrite f = modifyFileMode f $ removeModes writeModes - -{- Turns a file's owner write bit back on. -} -allowWrite :: FilePath -> IO () -allowWrite f = modifyFileMode f $ addModes [ownerWriteMode] - -{- Turns a file's owner read bit back on. -} -allowRead :: FilePath -> IO () -allowRead f = modifyFileMode f $ addModes [ownerReadMode] - -{- Allows owner and group to read and write to a file. -} -groupSharedModes :: [FileMode] -groupSharedModes = - [ ownerWriteMode, groupWriteMode - , ownerReadMode, groupReadMode - ] - -groupWriteRead :: FilePath -> IO () -groupWriteRead f = modifyFileMode f $ addModes groupSharedModes - -checkMode :: FileMode -> FileMode -> Bool -checkMode checkfor mode = checkfor `intersectFileModes` mode == checkfor - -{- Checks if a file mode indicates it's a symlink. -} -isSymLink :: FileMode -> Bool -#ifdef mingw32_HOST_OS -isSymLink _ = False -#else -isSymLink = checkMode symbolicLinkMode -#endif - -{- Checks if a file has any executable bits set. -} -isExecutable :: FileMode -> Bool -isExecutable mode = combineModes executeModes `intersectFileModes` mode /= 0 - -{- Runs an action without that pesky umask influencing it, unless the - - passed FileMode is the standard one. -} -noUmask :: FileMode -> IO a -> IO a -#ifndef mingw32_HOST_OS -noUmask mode a - | mode == stdFileMode = a - | otherwise = withUmask nullFileMode a -#else -noUmask _ a = a -#endif - -withUmask :: FileMode -> IO a -> IO a -#ifndef mingw32_HOST_OS -withUmask umask a = bracket setup cleanup go - where - setup = setFileCreationMask umask - cleanup = setFileCreationMask - go _ = a -#else -withUmask _ a = a -#endif - -combineModes :: [FileMode] -> FileMode -combineModes [] = undefined -combineModes [m] = m -combineModes (m:ms) = foldl unionFileModes m ms - -isSticky :: FileMode -> Bool -#ifdef mingw32_HOST_OS -isSticky _ = False -#else -isSticky = checkMode stickyMode - -stickyMode :: FileMode -stickyMode = 512 - -setSticky :: FilePath -> IO () -setSticky f = modifyFileMode f $ addModes [stickyMode] -#endif - -{- Writes a file, ensuring that its modes do not allow it to be read - - or written by anyone other than the current user, - - before any content is written. - - - - When possible, this is done using the umask. - - - - On a filesystem that does not support file permissions, this is the same - - as writeFile. - -} -writeFileProtected :: FilePath -> String -> IO () -writeFileProtected file content = withUmask 0o0077 $ - withFile file WriteMode $ \h -> do - void $ tryIO $ modifyFileMode file $ removeModes otherGroupModes - hPutStr h content diff --git a/Utility/FileSystemEncoding.hs b/Utility/FileSystemEncoding.hs deleted file mode 100644 index b81fdc53..00000000 --- a/Utility/FileSystemEncoding.hs +++ /dev/null @@ -1,132 +0,0 @@ -{- GHC File system encoding handling. - - - - Copyright 2012-2014 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.FileSystemEncoding ( - fileEncoding, - withFilePath, - md5FilePath, - decodeBS, - decodeW8, - encodeW8, - truncateFilePath, -) where - -import qualified GHC.Foreign as GHC -import qualified GHC.IO.Encoding as Encoding -import Foreign.C -import System.IO -import System.IO.Unsafe -import qualified Data.Hash.MD5 as MD5 -import Data.Word -import Data.Bits.Utils -import qualified Data.ByteString.Lazy as L -#ifdef mingw32_HOST_OS -import qualified Data.ByteString.Lazy.UTF8 as L8 -#endif - -{- Sets a Handle to use the filesystem encoding. This causes data - - written or read from it to be encoded/decoded the same - - as ghc 7.4 does to filenames etc. This special encoding - - allows "arbitrary undecodable bytes to be round-tripped through it". - -} -fileEncoding :: Handle -> IO () -#ifndef mingw32_HOST_OS -fileEncoding h = hSetEncoding h =<< Encoding.getFileSystemEncoding -#else -{- The file system encoding does not work well on Windows, - - and Windows only has utf FilePaths anyway. -} -fileEncoding h = hSetEncoding h Encoding.utf8 -#endif - -{- Marshal a Haskell FilePath into a NUL terminated C string using temporary - - storage. The FilePath is encoded using the filesystem encoding, - - reversing the decoding that should have been done when the FilePath - - was obtained. -} -withFilePath :: FilePath -> (CString -> IO a) -> IO a -withFilePath fp f = Encoding.getFileSystemEncoding - >>= \enc -> GHC.withCString enc fp f - -{- Encodes a FilePath into a String, applying the filesystem encoding. - - - - There are very few things it makes sense to do with such an encoded - - string. It's not a legal filename; it should not be displayed. - - So this function is not exported, but instead used by the few functions - - that can usefully consume it. - - - - This use of unsafePerformIO is belived to be safe; GHC's interface - - only allows doing this conversion with CStrings, and the CString buffer - - is allocated, used, and deallocated within the call, with no side - - effects. - -} -{-# NOINLINE _encodeFilePath #-} -_encodeFilePath :: FilePath -> String -_encodeFilePath fp = unsafePerformIO $ do - enc <- Encoding.getFileSystemEncoding - GHC.withCString enc fp $ GHC.peekCString Encoding.char8 - -{- Encodes a FilePath into a Md5.Str, applying the filesystem encoding. -} -md5FilePath :: FilePath -> MD5.Str -md5FilePath = MD5.Str . _encodeFilePath - -{- Decodes a ByteString into a FilePath, applying the filesystem encoding. -} -decodeBS :: L.ByteString -> FilePath -#ifndef mingw32_HOST_OS -decodeBS = encodeW8 . L.unpack -#else -{- On Windows, we assume that the ByteString is utf-8, since Windows - - only uses unicode for filenames. -} -decodeBS = L8.toString -#endif - -{- Converts a [Word8] to a FilePath, encoding using the filesystem encoding. - - - - w82c produces a String, which may contain Chars that are invalid - - unicode. From there, this is really a simple matter of applying the - - file system encoding, only complicated by GHC's interface to doing so. - -} -{-# NOINLINE encodeW8 #-} -encodeW8 :: [Word8] -> FilePath -encodeW8 w8 = unsafePerformIO $ do - enc <- Encoding.getFileSystemEncoding - GHC.withCString Encoding.char8 (w82s w8) $ GHC.peekCString enc - -{- Useful when you want the actual number of bytes that will be used to - - represent the FilePath on disk. -} -decodeW8 :: FilePath -> [Word8] -decodeW8 = s2w8 . _encodeFilePath - -{- Truncates a FilePath to the given number of bytes (or less), - - as represented on disk. - - - - Avoids returning an invalid part of a unicode byte sequence, at the - - cost of efficiency when running on a large FilePath. - -} -truncateFilePath :: Int -> FilePath -> FilePath -#ifndef mingw32_HOST_OS -truncateFilePath n = go . reverse - where - go f = - let bytes = decodeW8 f - in if length bytes <= n - then reverse f - else go (drop 1 f) -#else -{- On Windows, count the number of bytes used by each utf8 character. -} -truncateFilePath n = reverse . go [] n . L8.fromString - where - go coll cnt bs - | cnt <= 0 = coll - | otherwise = case L8.decode bs of - Just (c, x) | c /= L8.replacement_char -> - let x' = fromIntegral x - in if cnt - x' < 0 - then coll - else go (c:coll) (cnt - x') (L8.drop 1 bs) - _ -> coll -#endif diff --git a/Utility/LinuxMkLibs.hs b/Utility/LinuxMkLibs.hs deleted file mode 100644 index 1dc4e1ea..00000000 --- a/Utility/LinuxMkLibs.hs +++ /dev/null @@ -1,61 +0,0 @@ -{- Linux library copier and binary shimmer - - - - Copyright 2013 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.LinuxMkLibs where - -import Control.Applicative -import Data.Maybe -import System.Directory -import Data.List.Utils -import System.Posix.Files -import Data.Char -import Control.Monad.IfElse - -import Utility.PartialPrelude -import Utility.Directory -import Utility.Process -import Utility.Monad -import Utility.Path - -{- Installs a library. If the library is a symlink to another file, - - install the file it links to, and update the symlink to be relative. -} -installLib :: (FilePath -> FilePath -> IO ()) -> FilePath -> FilePath -> IO (Maybe FilePath) -installLib installfile top lib = ifM (doesFileExist lib) - ( do - installfile top lib - checksymlink lib - return $ Just $ parentDir lib - , return Nothing - ) - where - checksymlink f = whenM (isSymbolicLink <$> getSymbolicLinkStatus (inTop top f)) $ do - l <- readSymbolicLink (inTop top f) - let absl = absPathFrom (parentDir f) l - let target = relPathDirToFile (parentDir f) absl - installfile top absl - nukeFile (top ++ f) - createSymbolicLink target (inTop top f) - checksymlink absl - --- Note that f is not relative, so cannot use -inTop :: FilePath -> FilePath -> FilePath -inTop top f = top ++ f - -{- Parse ldd output, getting all the libraries that the input files - - link to. Note that some of the libraries may not exist - - (eg, linux-vdso.so) -} -parseLdd :: String -> [FilePath] -parseLdd = mapMaybe (getlib . dropWhile isSpace) . lines - where - getlib l = headMaybe . words =<< lastMaybe (split " => " l) - -{- Get all glibc libs and other support files, including gconv files - - - - XXX Debian specific. -} -glibcLibs :: IO [FilePath] -glibcLibs = lines <$> readProcess "sh" - ["-c", "dpkg -L libc6:$(dpkg --print-architecture) libgcc1:$(dpkg --print-architecture) | egrep '\\.so|gconv'"] diff --git a/Utility/Misc.hs b/Utility/Misc.hs deleted file mode 100644 index 949f41e7..00000000 --- a/Utility/Misc.hs +++ /dev/null @@ -1,148 +0,0 @@ -{- misc utility functions - - - - Copyright 2010-2011 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.Misc where - -import System.IO -import Control.Monad -import Foreign -import Data.Char -import Data.List -import Control.Applicative -import System.Exit -#ifndef mingw32_HOST_OS -import System.Posix.Process (getAnyProcessStatus) -import Utility.Exception -#endif - -import Utility.FileSystemEncoding -import Utility.Monad - -{- A version of hgetContents that is not lazy. Ensures file is - - all read before it gets closed. -} -hGetContentsStrict :: Handle -> IO String -hGetContentsStrict = hGetContents >=> \s -> length s `seq` return s - -{- A version of readFile that is not lazy. -} -readFileStrict :: FilePath -> IO String -readFileStrict = readFile >=> \s -> length s `seq` return s - -{- Reads a file strictly, and using the FileSystemEncoding, so it will - - never crash on a badly encoded file. -} -readFileStrictAnyEncoding :: FilePath -> IO String -readFileStrictAnyEncoding f = withFile f ReadMode $ \h -> do - fileEncoding h - hClose h `after` hGetContentsStrict h - -{- Writes a file, using the FileSystemEncoding so it will never crash - - on a badly encoded content string. -} -writeFileAnyEncoding :: FilePath -> String -> IO () -writeFileAnyEncoding f content = withFile f WriteMode $ \h -> do - fileEncoding h - hPutStr h content - -{- Like break, but the item matching the condition is not included - - in the second result list. - - - - separate (== ':') "foo:bar" = ("foo", "bar") - - separate (== ':') "foobar" = ("foobar", "") - -} -separate :: (a -> Bool) -> [a] -> ([a], [a]) -separate c l = unbreak $ break c l - where - unbreak r@(a, b) - | null b = r - | otherwise = (a, tail b) - -{- Breaks out the first line. -} -firstLine :: String -> String -firstLine = takeWhile (/= '\n') - -{- Splits a list into segments that are delimited by items matching - - a predicate. (The delimiters are not included in the segments.) - - Segments may be empty. -} -segment :: (a -> Bool) -> [a] -> [[a]] -segment p l = map reverse $ go [] [] l - where - go c r [] = reverse $ c:r - go c r (i:is) - | p i = go [] (c:r) is - | otherwise = go (i:c) r is - -prop_segment_regressionTest :: Bool -prop_segment_regressionTest = all id - -- Even an empty list is a segment. - [ segment (== "--") [] == [[]] - -- There are two segements in this list, even though the first is empty. - , segment (== "--") ["--", "foo", "bar"] == [[],["foo","bar"]] - ] - -{- Includes the delimiters as segments of their own. -} -segmentDelim :: (a -> Bool) -> [a] -> [[a]] -segmentDelim p l = map reverse $ go [] [] l - where - go c r [] = reverse $ c:r - go c r (i:is) - | p i = go [] ([i]:c:r) is - | otherwise = go (i:c) r is - -{- Replaces multiple values in a string. - - - - Takes care to skip over just-replaced values, so that they are not - - mangled. For example, massReplace [("foo", "new foo")] does not - - replace the "new foo" with "new new foo". - -} -massReplace :: [(String, String)] -> String -> String -massReplace vs = go [] vs - where - - go acc _ [] = concat $ reverse acc - go acc [] (c:cs) = go ([c]:acc) vs cs - go acc ((val, replacement):rest) s - | val `isPrefixOf` s = - go (replacement:acc) vs (drop (length val) s) - | otherwise = go acc rest s - -{- Wrapper around hGetBufSome that returns a String. - - - - The null string is returned on eof, otherwise returns whatever - - data is currently available to read from the handle, or waits for - - data to be written to it if none is currently available. - - - - Note on encodings: The normal encoding of the Handle is ignored; - - each byte is converted to a Char. Not unicode clean! - -} -hGetSomeString :: Handle -> Int -> IO String -hGetSomeString h sz = do - fp <- mallocForeignPtrBytes sz - len <- withForeignPtr fp $ \buf -> hGetBufSome h buf sz - map (chr . fromIntegral) <$> withForeignPtr fp (peekbytes len) - where - peekbytes :: Int -> Ptr Word8 -> IO [Word8] - peekbytes len buf = mapM (peekElemOff buf) [0..pred len] - -{- Reaps any zombie git processes. - - - - Warning: Not thread safe. Anything that was expecting to wait - - on a process and get back an exit status is going to be confused - - if this reap gets there first. -} -reapZombies :: IO () -#ifndef mingw32_HOST_OS -reapZombies = do - -- throws an exception when there are no child processes - catchDefaultIO Nothing (getAnyProcessStatus False True) - >>= maybe (return ()) (const reapZombies) - -#else -reapZombies = return () -#endif - -exitBool :: Bool -> IO a -exitBool False = exitFailure -exitBool True = exitSuccess diff --git a/Utility/Monad.hs b/Utility/Monad.hs deleted file mode 100644 index eba3c428..00000000 --- a/Utility/Monad.hs +++ /dev/null @@ -1,69 +0,0 @@ -{- monadic stuff - - - - Copyright 2010-2012 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.Monad where - -import Data.Maybe -import Control.Monad - -{- Return the first value from a list, if any, satisfying the given - - predicate -} -firstM :: Monad m => (a -> m Bool) -> [a] -> m (Maybe a) -firstM _ [] = return Nothing -firstM p (x:xs) = ifM (p x) (return $ Just x , firstM p xs) - -{- Runs the action on values from the list until it succeeds, returning - - its result. -} -getM :: Monad m => (a -> m (Maybe b)) -> [a] -> m (Maybe b) -getM _ [] = return Nothing -getM p (x:xs) = maybe (getM p xs) (return . Just) =<< p x - -{- Returns true if any value in the list satisfies the predicate, - - stopping once one is found. -} -anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool -anyM p = liftM isJust . firstM p - -allM :: Monad m => (a -> m Bool) -> [a] -> m Bool -allM _ [] = return True -allM p (x:xs) = p x <&&> allM p xs - -{- Runs an action on values from a list until it succeeds. -} -untilTrue :: Monad m => [a] -> (a -> m Bool) -> m Bool -untilTrue = flip anyM - -{- if with a monadic conditional. -} -ifM :: Monad m => m Bool -> (m a, m a) -> m a -ifM cond (thenclause, elseclause) = do - c <- cond - if c then thenclause else elseclause - -{- short-circuiting monadic || -} -(<||>) :: Monad m => m Bool -> m Bool -> m Bool -ma <||> mb = ifM ma ( return True , mb ) - -{- short-circuiting monadic && -} -(<&&>) :: Monad m => m Bool -> m Bool -> m Bool -ma <&&> mb = ifM ma ( mb , return False ) - -{- Same fixity as && and || -} -infixr 3 <&&> -infixr 2 <||> - -{- Runs an action, passing its value to an observer before returning it. -} -observe :: Monad m => (a -> m b) -> m a -> m a -observe observer a = do - r <- a - _ <- observer r - return r - -{- b `after` a runs first a, then b, and returns the value of a -} -after :: Monad m => m b -> m a -> m a -after = observe . const - -{- do nothing -} -noop :: Monad m => m () -noop = return () diff --git a/Utility/PartialPrelude.hs b/Utility/PartialPrelude.hs deleted file mode 100644 index 6efa093f..00000000 --- a/Utility/PartialPrelude.hs +++ /dev/null @@ -1,68 +0,0 @@ -{- Parts of the Prelude are partial functions, which are a common source of - - bugs. - - - - This exports functions that conflict with the prelude, which avoids - - them being accidentially used. - -} - -module Utility.PartialPrelude where - -import qualified Data.Maybe - -{- read should be avoided, as it throws an error - - Instead, use: readish -} -read :: Read a => String -> a -read = Prelude.read - -{- head is a partial function; head [] is an error - - Instead, use: take 1 or headMaybe -} -head :: [a] -> a -head = Prelude.head - -{- tail is also partial - - Instead, use: drop 1 -} -tail :: [a] -> [a] -tail = Prelude.tail - -{- init too - - Instead, use: beginning -} -init :: [a] -> [a] -init = Prelude.init - -{- last too - - Instead, use: end or lastMaybe -} -last :: [a] -> a -last = Prelude.last - -{- Attempts to read a value from a String. - - - - Ignores leading/trailing whitespace, and throws away any trailing - - text after the part that can be read. - - - - readMaybe is available in Text.Read in new versions of GHC, - - but that one requires the entire string to be consumed. - -} -readish :: Read a => String -> Maybe a -readish s = case reads s of - ((x,_):_) -> Just x - _ -> Nothing - -{- Like head but Nothing on empty list. -} -headMaybe :: [a] -> Maybe a -headMaybe = Data.Maybe.listToMaybe - -{- Like last but Nothing on empty list. -} -lastMaybe :: [a] -> Maybe a -lastMaybe [] = Nothing -lastMaybe v = Just $ Prelude.last v - -{- All but the last element of a list. - - (Like init, but no error on an empty list.) -} -beginning :: [a] -> [a] -beginning [] = [] -beginning l = Prelude.init l - -{- Like last, but no error on an empty list. -} -end :: [a] -> [a] -end [] = [] -end l = [Prelude.last l] diff --git a/Utility/Path.hs b/Utility/Path.hs deleted file mode 100644 index 99c9438b..00000000 --- a/Utility/Path.hs +++ /dev/null @@ -1,293 +0,0 @@ -{- path manipulation - - - - Copyright 2010-2014 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE PackageImports, CPP #-} - -module Utility.Path where - -import Data.String.Utils -import System.FilePath -import System.Directory -import Data.List -import Data.Maybe -import Data.Char -import Control.Applicative - -#ifdef mingw32_HOST_OS -import qualified System.FilePath.Posix as Posix -#else -import System.Posix.Files -#endif - -import qualified "MissingH" System.Path as MissingH -import Utility.Monad -import Utility.UserInfo - -{- Simplifies a path, removing any ".." or ".", and removing the trailing - - path separator. - - - - On Windows, preserves whichever style of path separator might be used in - - the input FilePaths. This is done because some programs in Windows - - demand a particular path separator -- and which one actually varies! - - - - This does not guarantee that two paths that refer to the same location, - - and are both relative to the same location (or both absolute) will - - yeild the same result. Run both through normalise from System.FilePath - - to ensure that. - -} -simplifyPath :: FilePath -> FilePath -simplifyPath path = dropTrailingPathSeparator $ - joinDrive drive $ joinPath $ norm [] $ splitPath path' - where - (drive, path') = splitDrive path - - norm c [] = reverse c - norm c (p:ps) - | p' == ".." = norm (drop 1 c) ps - | p' == "." = norm c ps - | otherwise = norm (p:c) ps - where - p' = dropTrailingPathSeparator p - -{- Makes a path absolute. - - - - The first parameter is a base directory (ie, the cwd) to use if the path - - is not already absolute. - - - - Does not attempt to deal with edge cases or ensure security with - - untrusted inputs. - -} -absPathFrom :: FilePath -> FilePath -> FilePath -absPathFrom dir path = simplifyPath (combine dir path) - -{- On Windows, this converts the paths to unix-style, in order to run - - MissingH's absNormPath on them. Resulting path will use / separators. -} -absNormPathUnix :: FilePath -> FilePath -> Maybe FilePath -#ifndef mingw32_HOST_OS -absNormPathUnix dir path = MissingH.absNormPath dir path -#else -absNormPathUnix dir path = todos <$> MissingH.absNormPath (fromdos dir) (fromdos path) - where - fromdos = replace "\\" "/" - todos = replace "/" "\\" -#endif - -{- Returns the parent directory of a path. - - - - To allow this to be easily used in loops, which terminate upon reaching the - - top, the parent of / is "" -} -parentDir :: FilePath -> FilePath -parentDir dir - | null dirs = "" - | otherwise = joinDrive drive (join s $ init dirs) - where - -- on Unix, the drive will be "/" when the dir is absolute, otherwise "" - (drive, path) = splitDrive dir - dirs = filter (not . null) $ split s path - s = [pathSeparator] - -prop_parentDir_basics :: FilePath -> Bool -prop_parentDir_basics dir - | null dir = True - | dir == "/" = parentDir dir == "" - | otherwise = p /= dir - where - p = parentDir dir - -{- Checks if the first FilePath is, or could be said to contain the second. - - For example, "foo/" contains "foo/bar". Also, "foo", "./foo", "foo/" etc - - are all equivilant. - -} -dirContains :: FilePath -> FilePath -> Bool -dirContains a b = a == b || a' == b' || (addTrailingPathSeparator a') `isPrefixOf` b' - where - a' = norm a - b' = norm b - norm = normalise . simplifyPath - -{- Converts a filename into an absolute path. - - - - Unlike Directory.canonicalizePath, this does not require the path - - already exists. -} -absPath :: FilePath -> IO FilePath -absPath file = do - cwd <- getCurrentDirectory - return $ absPathFrom cwd file - -{- Constructs a relative path from the CWD to a file. - - - - For example, assuming CWD is /tmp/foo/bar: - - relPathCwdToFile "/tmp/foo" == ".." - - relPathCwdToFile "/tmp/foo/bar" == "" - -} -relPathCwdToFile :: FilePath -> IO FilePath -relPathCwdToFile f = relPathDirToFile <$> getCurrentDirectory <*> absPath f - -{- Constructs a relative path from a directory to a file. - - - - Both must be absolute, and cannot contain .. etc. (eg use absPath first). - -} -relPathDirToFile :: FilePath -> FilePath -> FilePath -relPathDirToFile from to = join s $ dotdots ++ uncommon - where - s = [pathSeparator] - pfrom = split s from - pto = split s to - common = map fst $ takeWhile same $ zip pfrom pto - same (c,d) = c == d - uncommon = drop numcommon pto - dotdots = replicate (length pfrom - numcommon) ".." - numcommon = length common - -prop_relPathDirToFile_basics :: FilePath -> FilePath -> Bool -prop_relPathDirToFile_basics from to - | from == to = null r - | otherwise = not (null r) - where - r = relPathDirToFile from to - -prop_relPathDirToFile_regressionTest :: Bool -prop_relPathDirToFile_regressionTest = same_dir_shortcurcuits_at_difference - where - {- Two paths have the same directory component at the same - - location, but it's not really the same directory. - - Code used to get this wrong. -} - same_dir_shortcurcuits_at_difference = - relPathDirToFile (joinPath [pathSeparator : "tmp", "r", "lll", "xxx", "yyy", "18"]) - (joinPath [pathSeparator : "tmp", "r", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"]) - == joinPath ["..", "..", "..", "..", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"] - -{- Given an original list of paths, and an expanded list derived from it, - - generates a list of lists, where each sublist corresponds to one of the - - original paths. When the original path is a directory, any items - - in the expanded list that are contained in that directory will appear in - - its segment. - -} -segmentPaths :: [FilePath] -> [FilePath] -> [[FilePath]] -segmentPaths [] new = [new] -segmentPaths [_] new = [new] -- optimisation -segmentPaths (l:ls) new = [found] ++ segmentPaths ls rest - where - (found, rest)=partition (l `dirContains`) new - -{- This assumes that it's cheaper to call segmentPaths on the result, - - than it would be to run the action separately with each path. In - - the case of git file list commands, that assumption tends to hold. - -} -runSegmentPaths :: ([FilePath] -> IO [FilePath]) -> [FilePath] -> IO [[FilePath]] -runSegmentPaths a paths = segmentPaths paths <$> a paths - -{- Converts paths in the home directory to use ~/ -} -relHome :: FilePath -> IO String -relHome path = do - home <- myHomeDir - return $ if dirContains home path - then "~/" ++ relPathDirToFile home path - else path - -{- Checks if a command is available in PATH. - - - - The command may be fully-qualified, in which case, this succeeds as - - long as it exists. -} -inPath :: String -> IO Bool -inPath command = isJust <$> searchPath command - -{- Finds a command in PATH and returns the full path to it. - - - - The command may be fully qualified already, in which case it will - - be returned if it exists. - -} -searchPath :: String -> IO (Maybe FilePath) -searchPath command - | isAbsolute command = check command - | otherwise = getSearchPath >>= getM indir - where - indir d = check $ d command - check f = firstM doesFileExist -#ifdef mingw32_HOST_OS - [f, f ++ ".exe"] -#else - [f] -#endif - -{- Checks if a filename is a unix dotfile. All files inside dotdirs - - count as dotfiles. -} -dotfile :: FilePath -> Bool -dotfile file - | f == "." = False - | f == ".." = False - | f == "" = False - | otherwise = "." `isPrefixOf` f || dotfile (takeDirectory file) - where - f = takeFileName file - -{- Converts a DOS style path to a Cygwin style path. Only on Windows. - - Any trailing '\' is preserved as a trailing '/' -} -toCygPath :: FilePath -> FilePath -#ifndef mingw32_HOST_OS -toCygPath = id -#else -toCygPath p - | null drive = recombine parts - | otherwise = recombine $ "/cygdrive" : driveletter drive : parts - where - (drive, p') = splitDrive p - parts = splitDirectories p' - driveletter = map toLower . takeWhile (/= ':') - recombine = fixtrailing . Posix.joinPath - fixtrailing s - | hasTrailingPathSeparator p = Posix.addTrailingPathSeparator s - | otherwise = s -#endif - -{- Maximum size to use for a file in a specified directory. - - - - Many systems have a 255 byte limit to the name of a file, - - so that's taken as the max if the system has a larger limit, or has no - - limit. - -} -fileNameLengthLimit :: FilePath -> IO Int -#ifdef mingw32_HOST_OS -fileNameLengthLimit _ = return 255 -#else -fileNameLengthLimit dir = do - l <- fromIntegral <$> getPathVar dir FileNameLimit - if l <= 0 - then return 255 - else return $ minimum [l, 255] - where -#endif - -{- Given a string that we'd like to use as the basis for FilePath, but that - - was provided by a third party and is not to be trusted, returns the closest - - sane FilePath. - - - - All spaces and punctuation and other wacky stuff are replaced - - with '_', except for '.' "../" will thus turn into ".._", which is safe. - -} -sanitizeFilePath :: String -> FilePath -sanitizeFilePath = map sanitize - where - sanitize c - | c == '.' = c - | isSpace c || isPunctuation c || isSymbol c || isControl c || c == '/' = '_' - | otherwise = c - -{- Similar to splitExtensions, but knows that some things in FilePaths - - after a dot are too long to be extensions. -} -splitShortExtensions :: FilePath -> (FilePath, [String]) -splitShortExtensions = splitShortExtensions' 5 -- enough for ".jpeg" -splitShortExtensions' :: Int -> FilePath -> (FilePath, [String]) -splitShortExtensions' maxextension = go [] - where - go c f - | len > 0 && len <= maxextension && not (null base) = - go (ext:c) base - | otherwise = (f, c) - where - (base, ext) = splitExtension f - len = length ext diff --git a/Utility/PosixFiles.hs b/Utility/PosixFiles.hs deleted file mode 100644 index 5abbb578..00000000 --- a/Utility/PosixFiles.hs +++ /dev/null @@ -1,33 +0,0 @@ -{- POSIX files (and compatablity wrappers). - - - - This is like System.PosixCompat.Files, except with a fixed rename. - - - - Copyright 2014 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.PosixFiles ( - module X, - rename -) where - -import System.PosixCompat.Files as X hiding (rename) - -#ifndef mingw32_HOST_OS -import System.Posix.Files (rename) -#else -import qualified System.Win32.File as Win32 -#endif - -{- System.PosixCompat.Files.rename on Windows calls renameFile, - - so cannot rename directories. - - - - Instead, use Win32 moveFile, which can. It needs to be told to overwrite - - any existing file. -} -#ifdef mingw32_HOST_OS -rename :: FilePath -> FilePath -> IO () -rename src dest = Win32.moveFileEx src dest Win32.mOVEFILE_REPLACE_EXISTING -#endif diff --git a/Utility/Process.hs b/Utility/Process.hs deleted file mode 100644 index 549ae570..00000000 --- a/Utility/Process.hs +++ /dev/null @@ -1,364 +0,0 @@ -{- System.Process enhancements, including additional ways of running - - processes, and logging. - - - - Copyright 2012 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP, Rank2Types #-} - -module Utility.Process ( - module X, - CreateProcess, - StdHandle(..), - readProcess, - readProcessEnv, - writeReadProcessEnv, - forceSuccessProcess, - checkSuccessProcess, - ignoreFailureProcess, - createProcessSuccess, - createProcessChecked, - createBackgroundProcess, - processTranscript, - processTranscript', - withHandle, - withBothHandles, - withQuietOutput, - createProcess, - startInteractiveProcess, - stdinHandle, - stdoutHandle, - stderrHandle, - processHandle, - devNull, -) where - -import qualified System.Process -import System.Process as X hiding (CreateProcess(..), createProcess, runInteractiveProcess, readProcess, readProcessWithExitCode, system, rawSystem, runInteractiveCommand, runProcess) -import System.Process hiding (createProcess, readProcess) -import System.Exit -import System.IO -import System.Log.Logger -import Control.Concurrent -import qualified Control.Exception as E -import Control.Monad -#ifndef mingw32_HOST_OS -import System.Posix.IO -#else -import Control.Applicative -#endif -import Data.Maybe - -import Utility.Misc -import Utility.Exception - -type CreateProcessRunner = forall a. CreateProcess -> ((Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO a) -> IO a - -data StdHandle = StdinHandle | StdoutHandle | StderrHandle - deriving (Eq) - -{- Normally, when reading from a process, it does not need to be fed any - - standard input. -} -readProcess :: FilePath -> [String] -> IO String -readProcess cmd args = readProcessEnv cmd args Nothing - -readProcessEnv :: FilePath -> [String] -> Maybe [(String, String)] -> IO String -readProcessEnv cmd args environ = - withHandle StdoutHandle createProcessSuccess p $ \h -> do - output <- hGetContentsStrict h - hClose h - return output - where - p = (proc cmd args) - { std_out = CreatePipe - , env = environ - } - -{- Runs an action to write to a process on its stdin, - - returns its output, and also allows specifying the environment. - -} -writeReadProcessEnv - :: FilePath - -> [String] - -> Maybe [(String, String)] - -> (Maybe (Handle -> IO ())) - -> (Maybe (Handle -> IO ())) - -> IO String -writeReadProcessEnv cmd args environ writestdin adjusthandle = do - (Just inh, Just outh, _, pid) <- createProcess p - - maybe (return ()) (\a -> a inh) adjusthandle - maybe (return ()) (\a -> a outh) adjusthandle - - -- fork off a thread to start consuming the output - output <- hGetContents outh - outMVar <- newEmptyMVar - _ <- forkIO $ E.evaluate (length output) >> putMVar outMVar () - - -- now write and flush any input - maybe (return ()) (\a -> a inh >> hFlush inh) writestdin - hClose inh -- done with stdin - - -- wait on the output - takeMVar outMVar - hClose outh - - -- wait on the process - forceSuccessProcess p pid - - return output - - where - p = (proc cmd args) - { std_in = CreatePipe - , std_out = CreatePipe - , std_err = Inherit - , env = environ - } - -{- Waits for a ProcessHandle, and throws an IOError if the process - - did not exit successfully. -} -forceSuccessProcess :: CreateProcess -> ProcessHandle -> IO () -forceSuccessProcess p pid = do - code <- waitForProcess pid - case code of - ExitSuccess -> return () - ExitFailure n -> fail $ showCmd p ++ " exited " ++ show n - -{- Waits for a ProcessHandle and returns True if it exited successfully. - - Note that using this with createProcessChecked will throw away - - the Bool, and is only useful to ignore the exit code of a process, - - while still waiting for it. -} -checkSuccessProcess :: ProcessHandle -> IO Bool -checkSuccessProcess pid = do - code <- waitForProcess pid - return $ code == ExitSuccess - -ignoreFailureProcess :: ProcessHandle -> IO Bool -ignoreFailureProcess pid = do - void $ waitForProcess pid - return True - -{- Runs createProcess, then an action on its handles, and then - - forceSuccessProcess. -} -createProcessSuccess :: CreateProcessRunner -createProcessSuccess p a = createProcessChecked (forceSuccessProcess p) p a - -{- Runs createProcess, then an action on its handles, and then - - a checker action on its exit code, which must wait for the process. -} -createProcessChecked :: (ProcessHandle -> IO b) -> CreateProcessRunner -createProcessChecked checker p a = do - t@(_, _, _, pid) <- createProcess p - r <- tryNonAsync $ a t - _ <- checker pid - either E.throw return r - -{- Leaves the process running, suitable for lazy streaming. - - Note: Zombies will result, and must be waited on. -} -createBackgroundProcess :: CreateProcessRunner -createBackgroundProcess p a = a =<< createProcess p - -{- Runs a process, optionally feeding it some input, and - - returns a transcript combining its stdout and stderr, and - - whether it succeeded or failed. -} -processTranscript :: String -> [String] -> (Maybe String) -> IO (String, Bool) -processTranscript cmd opts input = processTranscript' cmd opts Nothing input - -processTranscript' :: String -> [String] -> Maybe [(String, String)] -> (Maybe String) -> IO (String, Bool) -#ifndef mingw32_HOST_OS -{- This implementation interleves stdout and stderr in exactly the order - - the process writes them. -} -processTranscript' cmd opts environ input = do - (readf, writef) <- createPipe - readh <- fdToHandle readf - writeh <- fdToHandle writef - p@(_, _, _, pid) <- createProcess $ - (proc cmd opts) - { std_in = if isJust input then CreatePipe else Inherit - , std_out = UseHandle writeh - , std_err = UseHandle writeh - , env = environ - } - hClose writeh - - get <- mkreader readh - - -- now write and flush any input - case input of - Just s -> do - let inh = stdinHandle p - unless (null s) $ do - hPutStr inh s - hFlush inh - hClose inh - Nothing -> return () - - transcript <- get - - ok <- checkSuccessProcess pid - return (transcript, ok) -#else -{- This implementation for Windows puts stderr after stdout. -} -processTranscript' cmd opts environ input = do - p@(_, _, _, pid) <- createProcess $ - (proc cmd opts) - { std_in = if isJust input then CreatePipe else Inherit - , std_out = CreatePipe - , std_err = CreatePipe - , env = environ - } - - getout <- mkreader (stdoutHandle p) - geterr <- mkreader (stderrHandle p) - - case input of - Just s -> do - let inh = stdinHandle p - unless (null s) $ do - hPutStr inh s - hFlush inh - hClose inh - Nothing -> return () - - transcript <- (++) <$> getout <*> geterr - ok <- checkSuccessProcess pid - return (transcript, ok) -#endif - where - mkreader h = do - s <- hGetContents h - v <- newEmptyMVar - void $ forkIO $ do - void $ E.evaluate (length s) - putMVar v () - return $ do - takeMVar v - return s - -{- Runs a CreateProcessRunner, on a CreateProcess structure, that - - is adjusted to pipe only from/to a single StdHandle, and passes - - the resulting Handle to an action. -} -withHandle - :: StdHandle - -> CreateProcessRunner - -> CreateProcess - -> (Handle -> IO a) - -> IO a -withHandle h creator p a = creator p' $ a . select - where - base = p - { std_in = Inherit - , std_out = Inherit - , std_err = Inherit - } - (select, p') - | h == StdinHandle = - (stdinHandle, base { std_in = CreatePipe }) - | h == StdoutHandle = - (stdoutHandle, base { std_out = CreatePipe }) - | h == StderrHandle = - (stderrHandle, base { std_err = CreatePipe }) - -{- Like withHandle, but passes (stdin, stdout) handles to the action. -} -withBothHandles - :: CreateProcessRunner - -> CreateProcess - -> ((Handle, Handle) -> IO a) - -> IO a -withBothHandles creator p a = creator p' $ a . bothHandles - where - p' = p - { std_in = CreatePipe - , std_out = CreatePipe - , std_err = Inherit - } - -{- Forces the CreateProcessRunner to run quietly; - - both stdout and stderr are discarded. -} -withQuietOutput - :: CreateProcessRunner - -> CreateProcess - -> IO () -withQuietOutput creator p = withFile devNull WriteMode $ \nullh -> do - let p' = p - { std_out = UseHandle nullh - , std_err = UseHandle nullh - } - creator p' $ const $ return () - -devNull :: FilePath -#ifndef mingw32_HOST_OS -devNull = "/dev/null" -#else -devNull = "NUL" -#endif - -{- Extract a desired handle from createProcess's tuple. - - These partial functions are safe as long as createProcess is run - - with appropriate parameters to set up the desired handle. - - Get it wrong and the runtime crash will always happen, so should be - - easily noticed. -} -type HandleExtractor = (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> Handle -stdinHandle :: HandleExtractor -stdinHandle (Just h, _, _, _) = h -stdinHandle _ = error "expected stdinHandle" -stdoutHandle :: HandleExtractor -stdoutHandle (_, Just h, _, _) = h -stdoutHandle _ = error "expected stdoutHandle" -stderrHandle :: HandleExtractor -stderrHandle (_, _, Just h, _) = h -stderrHandle _ = error "expected stderrHandle" -bothHandles :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> (Handle, Handle) -bothHandles (Just hin, Just hout, _, _) = (hin, hout) -bothHandles _ = error "expected bothHandles" - -processHandle :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> ProcessHandle -processHandle (_, _, _, pid) = pid - -{- Debugging trace for a CreateProcess. -} -debugProcess :: CreateProcess -> IO () -debugProcess p = do - debugM "Utility.Process" $ unwords - [ action ++ ":" - , showCmd p - ] - where - action - | piped (std_in p) && piped (std_out p) = "chat" - | piped (std_in p) = "feed" - | piped (std_out p) = "read" - | otherwise = "call" - piped Inherit = False - piped _ = True - -{- Shows the command that a CreateProcess will run. -} -showCmd :: CreateProcess -> String -showCmd = go . cmdspec - where - go (ShellCommand s) = s - go (RawCommand c ps) = c ++ " " ++ show ps - -{- Starts an interactive process. Unlike runInteractiveProcess in - - System.Process, stderr is inherited. -} -startInteractiveProcess - :: FilePath - -> [String] - -> Maybe [(String, String)] - -> IO (ProcessHandle, Handle, Handle) -startInteractiveProcess cmd args environ = do - let p = (proc cmd args) - { std_in = CreatePipe - , std_out = CreatePipe - , std_err = Inherit - , env = environ - } - (Just from, Just to, _, pid) <- createProcess p - return (pid, to, from) - -{- Wrapper around System.Process function that does debug logging. -} -createProcess :: CreateProcess -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -createProcess p = do - debugProcess p - System.Process.createProcess p diff --git a/Utility/QuickCheck.hs b/Utility/QuickCheck.hs deleted file mode 100644 index a498ee61..00000000 --- a/Utility/QuickCheck.hs +++ /dev/null @@ -1,52 +0,0 @@ -{- QuickCheck with additional instances - - - - Copyright 2012-2014 Joey Hess - - - - License: BSD-2-clause - -} - -{-# OPTIONS_GHC -fno-warn-orphans #-} -{-# LANGUAGE TypeSynonymInstances #-} - -module Utility.QuickCheck - ( module X - , module Utility.QuickCheck - ) where - -import Test.QuickCheck as X -import Data.Time.Clock.POSIX -import System.Posix.Types -import qualified Data.Map as M -import qualified Data.Set as S -import Control.Applicative - -instance (Arbitrary k, Arbitrary v, Eq k, Ord k) => Arbitrary (M.Map k v) where - arbitrary = M.fromList <$> arbitrary - -instance (Arbitrary v, Eq v, Ord v) => Arbitrary (S.Set v) where - arbitrary = S.fromList <$> arbitrary - -{- Times before the epoch are excluded. -} -instance Arbitrary POSIXTime where - arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral - -instance Arbitrary EpochTime where - arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral - -{- Pids are never negative, or 0. -} -instance Arbitrary ProcessID where - arbitrary = arbitrarySizedBoundedIntegral `suchThat` (> 0) - -{- Inodes are never negative. -} -instance Arbitrary FileID where - arbitrary = nonNegative arbitrarySizedIntegral - -{- File sizes are never negative. -} -instance Arbitrary FileOffset where - arbitrary = nonNegative arbitrarySizedIntegral - -nonNegative :: (Num a, Ord a) => Gen a -> Gen a -nonNegative g = g `suchThat` (>= 0) - -positive :: (Num a, Ord a) => Gen a -> Gen a -positive g = g `suchThat` (> 0) diff --git a/Utility/SafeCommand.hs b/Utility/SafeCommand.hs deleted file mode 100644 index 04fcf390..00000000 --- a/Utility/SafeCommand.hs +++ /dev/null @@ -1,120 +0,0 @@ -{- safely running shell commands - - - - Copyright 2010-2013 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.SafeCommand where - -import System.Exit -import Utility.Process -import System.Process (env) -import Data.String.Utils -import Control.Applicative -import System.FilePath -import Data.Char - -{- A type for parameters passed to a shell command. A command can - - be passed either some Params (multiple parameters can be included, - - whitespace-separated, or a single Param (for when parameters contain - - whitespace), or a File. - -} -data CommandParam = Params String | Param String | File FilePath - deriving (Eq, Show, Ord) - -{- Used to pass a list of CommandParams to a function that runs - - a command and expects Strings. -} -toCommand :: [CommandParam] -> [String] -toCommand = concatMap unwrap - where - unwrap (Param s) = [s] - unwrap (Params s) = filter (not . null) (split " " s) - -- Files that start with a non-alphanumeric that is not a path - -- separator are modified to avoid the command interpreting them as - -- options or other special constructs. - unwrap (File s@(h:_)) - | isAlphaNum h || h `elem` pathseps = [s] - | otherwise = ["./" ++ s] - unwrap (File s) = [s] - -- '/' is explicitly included because it's an alternative - -- path separator on Windows. - pathseps = pathSeparator:"./" - -{- Run a system command, and returns True or False - - if it succeeded or failed. - -} -boolSystem :: FilePath -> [CommandParam] -> IO Bool -boolSystem command params = boolSystemEnv command params Nothing - -boolSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO Bool -boolSystemEnv command params environ = dispatch <$> safeSystemEnv command params environ - where - dispatch ExitSuccess = True - dispatch _ = False - -{- Runs a system command, returning the exit status. -} -safeSystem :: FilePath -> [CommandParam] -> IO ExitCode -safeSystem command params = safeSystemEnv command params Nothing - -safeSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO ExitCode -safeSystemEnv command params environ = do - (_, _, _, pid) <- createProcess (proc command $ toCommand params) - { env = environ } - waitForProcess pid - -{- Wraps a shell command line inside sh -c, allowing it to be run in a - - login shell that may not support POSIX shell, eg csh. -} -shellWrap :: String -> String -shellWrap cmdline = "sh -c " ++ shellEscape cmdline - -{- Escapes a filename or other parameter to be safely able to be exposed to - - the shell. - - - - This method works for POSIX shells, as well as other shells like csh. - -} -shellEscape :: String -> String -shellEscape f = "'" ++ escaped ++ "'" - where - -- replace ' with '"'"' - escaped = join "'\"'\"'" $ split "'" f - -{- Unescapes a set of shellEscaped words or filenames. -} -shellUnEscape :: String -> [String] -shellUnEscape [] = [] -shellUnEscape s = word : shellUnEscape rest - where - (word, rest) = findword "" s - findword w [] = (w, "") - findword w (c:cs) - | c == ' ' = (w, cs) - | c == '\'' = inquote c w cs - | c == '"' = inquote c w cs - | otherwise = findword (w++[c]) cs - inquote _ w [] = (w, "") - inquote q w (c:cs) - | c == q = findword w cs - | otherwise = inquote q (w++[c]) cs - -{- For quickcheck. -} -prop_idempotent_shellEscape :: String -> Bool -prop_idempotent_shellEscape s = [s] == (shellUnEscape . shellEscape) s -prop_idempotent_shellEscape_multiword :: [String] -> Bool -prop_idempotent_shellEscape_multiword s = s == (shellUnEscape . unwords . map shellEscape) s - -{- Segements a list of filenames into groups that are all below the manximum - - command-line length limit. Does not preserve order. -} -segmentXargs :: [FilePath] -> [[FilePath]] -segmentXargs l = go l [] 0 [] - where - go [] c _ r = c:r - go (f:fs) c accumlen r - | len < maxlen && newlen > maxlen = go (f:fs) [] 0 (c:r) - | otherwise = go fs (f:c) newlen r - where - len = length f - newlen = accumlen + len - - {- 10k of filenames per command, well under Linux's 20k limit; - - allows room for other parameters etc. -} - maxlen = 10240 diff --git a/Utility/Scheduled.hs b/Utility/Scheduled.hs deleted file mode 100644 index 305410c5..00000000 --- a/Utility/Scheduled.hs +++ /dev/null @@ -1,396 +0,0 @@ -{- scheduled activities - - - - Copyright 2013-2014 Joey Hess - - - - License: BSD-2-clause - -} - -module Utility.Scheduled ( - Schedule(..), - Recurrance(..), - ScheduledTime(..), - NextTime(..), - WeekDay, - MonthDay, - YearDay, - nextTime, - calcNextTime, - startTime, - fromSchedule, - fromScheduledTime, - toScheduledTime, - fromRecurrance, - toRecurrance, - toSchedule, - parseSchedule, - prop_schedule_roundtrips, - prop_past_sane, -) where - -import Utility.Data -import Utility.QuickCheck -import Utility.PartialPrelude -import Utility.Misc - -import Control.Applicative -import Data.List -import Data.Time.Clock -import Data.Time.LocalTime -import Data.Time.Calendar -import Data.Time.Calendar.WeekDate -import Data.Time.Calendar.OrdinalDate -import Data.Tuple.Utils -import Data.Char - -{- Some sort of scheduled event. -} -data Schedule = Schedule Recurrance ScheduledTime - deriving (Eq, Read, Show, Ord) - -data Recurrance - = Daily - | Weekly (Maybe WeekDay) - | Monthly (Maybe MonthDay) - | Yearly (Maybe YearDay) - | Divisible Int Recurrance - -- ^ Days, Weeks, or Months of the year evenly divisible by a number. - -- (Divisible Year is years evenly divisible by a number.) - deriving (Eq, Read, Show, Ord) - -type WeekDay = Int -type MonthDay = Int -type YearDay = Int - -data ScheduledTime - = AnyTime - | SpecificTime Hour Minute - deriving (Eq, Read, Show, Ord) - -type Hour = Int -type Minute = Int - --- | Next time a Schedule should take effect. The NextTimeWindow is used --- when a Schedule is allowed to start at some point within the window. -data NextTime - = NextTimeExactly LocalTime - | NextTimeWindow LocalTime LocalTime - deriving (Eq, Read, Show) - -startTime :: NextTime -> LocalTime -startTime (NextTimeExactly t) = t -startTime (NextTimeWindow t _) = t - -nextTime :: Schedule -> Maybe LocalTime -> IO (Maybe NextTime) -nextTime schedule lasttime = do - now <- getCurrentTime - tz <- getTimeZone now - return $ calcNextTime schedule lasttime $ utcToLocalTime tz now - --- | Calculate the next time that fits a Schedule, based on the --- last time it occurred, and the current time. -calcNextTime :: Schedule -> Maybe LocalTime -> LocalTime -> Maybe NextTime -calcNextTime schedule@(Schedule recurrance scheduledtime) lasttime currenttime - | scheduledtime == AnyTime = do - next <- findfromtoday True - return $ case next of - NextTimeWindow _ _ -> next - NextTimeExactly t -> window (localDay t) (localDay t) - | otherwise = NextTimeExactly . startTime <$> findfromtoday False - where - findfromtoday anytime = findfrom recurrance afterday today - where - today = localDay currenttime - afterday = sameaslastrun || toolatetoday - toolatetoday = not anytime && localTimeOfDay currenttime >= nexttime - sameaslastrun = lastrun == Just today - lastrun = localDay <$> lasttime - nexttime = case scheduledtime of - AnyTime -> TimeOfDay 0 0 0 - SpecificTime h m -> TimeOfDay h m 0 - exactly d = NextTimeExactly $ LocalTime d nexttime - window startd endd = NextTimeWindow - (LocalTime startd nexttime) - (LocalTime endd (TimeOfDay 23 59 0)) - findfrom r afterday candidate - | ynum candidate > (ynum (localDay currenttime)) + 100 = - -- avoid possible infinite recusion - error $ "bug: calcNextTime did not find a time within 100 years to run " ++ - show (schedule, lasttime, currenttime) - | otherwise = findfromChecked r afterday candidate - findfromChecked r afterday candidate = case r of - Daily - | afterday -> Just $ exactly $ addDays 1 candidate - | otherwise -> Just $ exactly candidate - Weekly Nothing - | afterday -> skip 1 - | otherwise -> case (wday <$> lastrun, wday candidate) of - (Nothing, _) -> Just $ window candidate (addDays 6 candidate) - (Just old, curr) - | old == curr -> Just $ window candidate (addDays 6 candidate) - | otherwise -> skip 1 - Monthly Nothing - | afterday -> skip 1 - | maybe True (candidate `oneMonthPast`) lastrun -> - Just $ window candidate (endOfMonth candidate) - | otherwise -> skip 1 - Yearly Nothing - | afterday -> skip 1 - | maybe True (candidate `oneYearPast`) lastrun -> - Just $ window candidate (endOfYear candidate) - | otherwise -> skip 1 - Weekly (Just w) - | w < 0 || w > maxwday -> Nothing - | w == wday candidate -> if afterday - then Just $ exactly $ addDays 7 candidate - else Just $ exactly candidate - | otherwise -> Just $ exactly $ - addDays (fromIntegral $ (w - wday candidate) `mod` 7) candidate - Monthly (Just m) - | m < 0 || m > maxmday -> Nothing - -- TODO can be done more efficiently than recursing - | m == mday candidate -> if afterday - then skip 1 - else Just $ exactly candidate - | otherwise -> skip 1 - Yearly (Just y) - | y < 0 || y > maxyday -> Nothing - | y == yday candidate -> if afterday - then skip 365 - else Just $ exactly candidate - | otherwise -> skip 1 - Divisible n r'@Daily -> handlediv n r' yday (Just maxyday) - Divisible n r'@(Weekly _) -> handlediv n r' wnum (Just maxwnum) - Divisible n r'@(Monthly _) -> handlediv n r' mnum (Just maxmnum) - Divisible n r'@(Yearly _) -> handlediv n r' ynum Nothing - Divisible _ r'@(Divisible _ _) -> findfrom r' afterday candidate - where - skip n = findfrom r False (addDays n candidate) - handlediv n r' getval mmax - | n > 0 && maybe True (n <=) mmax = - findfromwhere r' (divisible n . getval) afterday candidate - | otherwise = Nothing - findfromwhere r p afterday candidate - | maybe True (p . getday) next = next - | otherwise = maybe Nothing (findfromwhere r p True . getday) next - where - next = findfrom r afterday candidate - getday = localDay . startTime - divisible n v = v `rem` n == 0 - --- Check if the new Day occurs one month or more past the old Day. -oneMonthPast :: Day -> Day -> Bool -new `oneMonthPast` old = fromGregorian y (m+1) d <= new - where - (y,m,d) = toGregorian old - --- Check if the new Day occurs one year or more past the old Day. -oneYearPast :: Day -> Day -> Bool -new `oneYearPast` old = fromGregorian (y+1) m d <= new - where - (y,m,d) = toGregorian old - -endOfMonth :: Day -> Day -endOfMonth day = - let (y,m,_d) = toGregorian day - in fromGregorian y m (gregorianMonthLength y m) - -endOfYear :: Day -> Day -endOfYear day = - let (y,_m,_d) = toGregorian day - in endOfMonth (fromGregorian y maxmnum 1) - --- extracting various quantities from a Day -wday :: Day -> Int -wday = thd3 . toWeekDate -wnum :: Day -> Int -wnum = snd3 . toWeekDate -mday :: Day -> Int -mday = thd3 . toGregorian -mnum :: Day -> Int -mnum = snd3 . toGregorian -yday :: Day -> Int -yday = snd . toOrdinalDate -ynum :: Day -> Int -ynum = fromIntegral . fst . toOrdinalDate - --- Calendar max values. -maxyday :: Int -maxyday = 366 -- with leap days -maxwnum :: Int -maxwnum = 53 -- some years have more than 52 -maxmday :: Int -maxmday = 31 -maxmnum :: Int -maxmnum = 12 -maxwday :: Int -maxwday = 7 - -fromRecurrance :: Recurrance -> String -fromRecurrance (Divisible n r) = - fromRecurrance' (++ "s divisible by " ++ show n) r -fromRecurrance r = fromRecurrance' ("every " ++) r - -fromRecurrance' :: (String -> String) -> Recurrance -> String -fromRecurrance' a Daily = a "day" -fromRecurrance' a (Weekly n) = onday n (a "week") -fromRecurrance' a (Monthly n) = onday n (a "month") -fromRecurrance' a (Yearly n) = onday n (a "year") -fromRecurrance' a (Divisible _n r) = fromRecurrance' a r -- not used - -onday :: Maybe Int -> String -> String -onday (Just n) s = "on day " ++ show n ++ " of " ++ s -onday Nothing s = s - -toRecurrance :: String -> Maybe Recurrance -toRecurrance s = case words s of - ("every":"day":[]) -> Just Daily - ("on":"day":sd:"of":"every":something:[]) -> withday sd something - ("every":something:[]) -> noday something - ("days":"divisible":"by":sn:[]) -> - Divisible <$> getdivisor sn <*> pure Daily - ("on":"day":sd:"of":something:"divisible":"by":sn:[]) -> - Divisible - <$> getdivisor sn - <*> withday sd something - ("every":something:"divisible":"by":sn:[]) -> - Divisible - <$> getdivisor sn - <*> noday something - (something:"divisible":"by":sn:[]) -> - Divisible - <$> getdivisor sn - <*> noday something - _ -> Nothing - where - constructor "week" = Just Weekly - constructor "month" = Just Monthly - constructor "year" = Just Yearly - constructor u - | "s" `isSuffixOf` u = constructor $ reverse $ drop 1 $ reverse u - | otherwise = Nothing - withday sd u = do - c <- constructor u - d <- readish sd - Just $ c (Just d) - noday u = do - c <- constructor u - Just $ c Nothing - getdivisor sn = do - n <- readish sn - if n > 0 - then Just n - else Nothing - -fromScheduledTime :: ScheduledTime -> String -fromScheduledTime AnyTime = "any time" -fromScheduledTime (SpecificTime h m) = - show h' ++ (if m > 0 then ":" ++ pad 2 (show m) else "") ++ " " ++ ampm - where - pad n s = take (n - length s) (repeat '0') ++ s - (h', ampm) - | h == 0 = (12, "AM") - | h < 12 = (h, "AM") - | h == 12 = (h, "PM") - | otherwise = (h - 12, "PM") - -toScheduledTime :: String -> Maybe ScheduledTime -toScheduledTime "any time" = Just AnyTime -toScheduledTime v = case words v of - (s:ampm:[]) - | map toUpper ampm == "AM" -> - go s h0 - | map toUpper ampm == "PM" -> - go s (\h -> (h0 h) + 12) - | otherwise -> Nothing - (s:[]) -> go s id - _ -> Nothing - where - h0 h - | h == 12 = 0 - | otherwise = h - go :: String -> (Int -> Int) -> Maybe ScheduledTime - go s adjust = - let (h, m) = separate (== ':') s - in SpecificTime - <$> (adjust <$> readish h) - <*> if null m then Just 0 else readish m - -fromSchedule :: Schedule -> String -fromSchedule (Schedule recurrance scheduledtime) = unwords - [ fromRecurrance recurrance - , "at" - , fromScheduledTime scheduledtime - ] - -toSchedule :: String -> Maybe Schedule -toSchedule = eitherToMaybe . parseSchedule - -parseSchedule :: String -> Either String Schedule -parseSchedule s = do - r <- maybe (Left $ "bad recurrance: " ++ recurrance) Right - (toRecurrance recurrance) - t <- maybe (Left $ "bad time of day: " ++ scheduledtime) Right - (toScheduledTime scheduledtime) - Right $ Schedule r t - where - (rws, tws) = separate (== "at") (words s) - recurrance = unwords rws - scheduledtime = unwords tws - -instance Arbitrary Schedule where - arbitrary = Schedule <$> arbitrary <*> arbitrary - -instance Arbitrary ScheduledTime where - arbitrary = oneof - [ pure AnyTime - , SpecificTime - <$> choose (0, 23) - <*> choose (1, 59) - ] - -instance Arbitrary Recurrance where - arbitrary = oneof - [ pure Daily - , Weekly <$> arbday - , Monthly <$> arbday - , Yearly <$> arbday - , Divisible - <$> positive arbitrary - <*> oneof -- no nested Divisibles - [ pure Daily - , Weekly <$> arbday - , Monthly <$> arbday - , Yearly <$> arbday - ] - ] - where - arbday = oneof - [ Just <$> nonNegative arbitrary - , pure Nothing - ] - -prop_schedule_roundtrips :: Schedule -> Bool -prop_schedule_roundtrips s = toSchedule (fromSchedule s) == Just s - -prop_past_sane :: Bool -prop_past_sane = and - [ all (checksout oneMonthPast) (mplus1 ++ yplus1) - , all (not . (checksout oneMonthPast)) (map swap (mplus1 ++ yplus1)) - , all (checksout oneYearPast) yplus1 - , all (not . (checksout oneYearPast)) (map swap yplus1) - ] - where - mplus1 = -- new date old date, 1+ months before it - [ (fromGregorian 2014 01 15, fromGregorian 2013 12 15) - , (fromGregorian 2014 01 15, fromGregorian 2013 02 15) - , (fromGregorian 2014 02 15, fromGregorian 2013 01 15) - , (fromGregorian 2014 03 01, fromGregorian 2013 01 15) - , (fromGregorian 2014 03 01, fromGregorian 2013 12 15) - , (fromGregorian 2015 01 01, fromGregorian 2010 01 01) - ] - yplus1 = -- new date old date, 1+ years before it - [ (fromGregorian 2014 01 15, fromGregorian 2012 01 16) - , (fromGregorian 2014 01 15, fromGregorian 2013 01 14) - , (fromGregorian 2022 12 31, fromGregorian 2000 01 01) - ] - checksout cmp (new, old) = new `cmp` old - swap (a,b) = (b,a) diff --git a/Utility/ThreadScheduler.hs b/Utility/ThreadScheduler.hs deleted file mode 100644 index fc026d7e..00000000 --- a/Utility/ThreadScheduler.hs +++ /dev/null @@ -1,75 +0,0 @@ -{- thread scheduling - - - - Copyright 2012, 2013 Joey Hess - - Copyright 2011 Bas van Dijk & Roel van Dijk - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.ThreadScheduler where - -import Control.Monad -import Control.Concurrent -#ifndef mingw32_HOST_OS -import Control.Monad.IfElse -import System.Posix.IO -#endif -#ifndef mingw32_HOST_OS -import System.Posix.Signals -#ifndef __ANDROID__ -import System.Posix.Terminal -#endif -#endif - -newtype Seconds = Seconds { fromSeconds :: Int } - deriving (Eq, Ord, Show) - -type Microseconds = Integer - -{- Runs an action repeatedly forever, sleeping at least the specified number - - of seconds in between. -} -runEvery :: Seconds -> IO a -> IO a -runEvery n a = forever $ do - threadDelaySeconds n - a - -threadDelaySeconds :: Seconds -> IO () -threadDelaySeconds (Seconds n) = unboundDelay (fromIntegral n * oneSecond) - -{- Like threadDelay, but not bounded by an Int. - - - - There is no guarantee that the thread will be rescheduled promptly when the - - delay has expired, but the thread will never continue to run earlier than - - specified. - - - - Taken from the unbounded-delay package to avoid a dependency for 4 lines - - of code. - -} -unboundDelay :: Microseconds -> IO () -unboundDelay time = do - let maxWait = min time $ toInteger (maxBound :: Int) - threadDelay $ fromInteger maxWait - when (maxWait /= time) $ unboundDelay (time - maxWait) - -{- Pauses the main thread, letting children run until program termination. -} -waitForTermination :: IO () -waitForTermination = do -#ifdef mingw32_HOST_OS - runEvery (Seconds 600) $ - void getLine -#else - lock <- newEmptyMVar - let check sig = void $ - installHandler sig (CatchOnce $ putMVar lock ()) Nothing - check softwareTermination -#ifndef __ANDROID__ - whenM (queryTerminal stdInput) $ - check keyboardSignal -#endif - takeMVar lock -#endif - -oneSecond :: Microseconds -oneSecond = 1000000 diff --git a/Utility/Tmp.hs b/Utility/Tmp.hs deleted file mode 100644 index 0dc9f2c0..00000000 --- a/Utility/Tmp.hs +++ /dev/null @@ -1,100 +0,0 @@ -{- Temporary files and directories. - - - - Copyright 2010-2013 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.Tmp where - -import Control.Exception (bracket) -import System.IO -import System.Directory -import Control.Monad.IfElse -import System.FilePath - -import Utility.Exception -import Utility.FileSystemEncoding -import Utility.PosixFiles - -type Template = String - -{- Runs an action like writeFile, writing to a temp file first and - - then moving it into place. The temp file is stored in the same - - directory as the final file to avoid cross-device renames. -} -viaTmp :: (FilePath -> String -> IO ()) -> FilePath -> String -> IO () -viaTmp a file content = do - let (dir, base) = splitFileName file - createDirectoryIfMissing True dir - (tmpfile, handle) <- openTempFile dir (base ++ ".tmp") - hClose handle - a tmpfile content - rename tmpfile file - -{- Runs an action with a tmp file located in the system's tmp directory - - (or in "." if there is none) then removes the file. -} -withTmpFile :: Template -> (FilePath -> Handle -> IO a) -> IO a -withTmpFile template a = do - tmpdir <- catchDefaultIO "." getTemporaryDirectory - withTmpFileIn tmpdir template a - -{- Runs an action with a tmp file located in the specified directory, - - then removes the file. -} -withTmpFileIn :: FilePath -> Template -> (FilePath -> Handle -> IO a) -> IO a -withTmpFileIn tmpdir template a = bracket create remove use - where - create = openTempFile tmpdir template - remove (name, handle) = do - hClose handle - catchBoolIO (removeFile name >> return True) - use (name, handle) = a name handle - -{- Runs an action with a tmp directory located within the system's tmp - - directory (or within "." if there is none), then removes the tmp - - directory and all its contents. -} -withTmpDir :: Template -> (FilePath -> IO a) -> IO a -withTmpDir template a = do - tmpdir <- catchDefaultIO "." getTemporaryDirectory - withTmpDirIn tmpdir template a - -{- Runs an action with a tmp directory located within a specified directory, - - then removes the tmp directory and all its contents. -} -withTmpDirIn :: FilePath -> Template -> (FilePath -> IO a) -> IO a -withTmpDirIn tmpdir template = bracket create remove - where - remove d = whenM (doesDirectoryExist d) $ do -#if mingw32_HOST_OS - -- Windows will often refuse to delete a file - -- after a process has just written to it and exited. - -- Because it's crap, presumably. So, ignore failure - -- to delete the temp directory. - _ <- tryIO $ removeDirectoryRecursive d - return () -#else - removeDirectoryRecursive d -#endif - create = do - createDirectoryIfMissing True tmpdir - makenewdir (tmpdir template) (0 :: Int) - makenewdir t n = do - let dir = t ++ "." ++ show n - either (const $ makenewdir t $ n + 1) (const $ return dir) - =<< tryIO (createDirectory dir) - -{- It's not safe to use a FilePath of an existing file as the template - - for openTempFile, because if the FilePath is really long, the tmpfile - - will be longer, and may exceed the maximum filename length. - - - - This generates a template that is never too long. - - (Well, it allocates 20 characters for use in making a unique temp file, - - anyway, which is enough for the current implementation and any - - likely implementation.) - -} -relatedTemplate :: FilePath -> FilePath -relatedTemplate f - | len > 20 = truncateFilePath (len - 20) f - | otherwise = f - where - len = length f diff --git a/Utility/UserInfo.hs b/Utility/UserInfo.hs deleted file mode 100644 index 617c3e94..00000000 --- a/Utility/UserInfo.hs +++ /dev/null @@ -1,55 +0,0 @@ -{- user info - - - - Copyright 2012 Joey Hess - - - - License: BSD-2-clause - -} - -{-# LANGUAGE CPP #-} - -module Utility.UserInfo ( - myHomeDir, - myUserName, - myUserGecos, -) where - -import Control.Applicative -import System.PosixCompat - -import Utility.Env - -{- Current user's home directory. - - - - getpwent will fail on LDAP or NIS, so use HOME if set. -} -myHomeDir :: IO FilePath -myHomeDir = myVal env homeDirectory - where -#ifndef mingw32_HOST_OS - env = ["HOME"] -#else - env = ["USERPROFILE", "HOME"] -- HOME is used in Cygwin -#endif - -{- Current user's user name. -} -myUserName :: IO String -myUserName = myVal env userName - where -#ifndef mingw32_HOST_OS - env = ["USER", "LOGNAME"] -#else - env = ["USERNAME", "USER", "LOGNAME"] -#endif - -myUserGecos :: IO String -#ifdef __ANDROID__ -myUserGecos = return "" -- userGecos crashes on Android -#else -myUserGecos = myVal [] userGecos -#endif - -myVal :: [String] -> (UserEntry -> String) -> IO String -myVal envvars extract = maybe (extract <$> getpwent) return =<< check envvars - where - check [] = return Nothing - check (v:vs) = maybe (check vs) (return . Just) =<< getEnv v - getpwent = getUserEntryForID =<< getEffectiveUserID diff --git a/propellor.cabal b/propellor.cabal index 507a0d4b..55b7eb60 100644 --- a/propellor.cabal +++ b/propellor.cabal @@ -33,9 +33,10 @@ Description: . It is configured using haskell. -Executable propellor +Executable wrapper Main-Is: wrapper.hs GHC-Options: -Wall -threaded + Hs-Source-Dirs: src Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, containers, network, async, time, QuickCheck, mtl, @@ -47,6 +48,7 @@ Executable propellor Executable config Main-Is: config.hs GHC-Options: -Wall -threaded + Hs-Source-Dirs: src Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, containers, network, async, time, QuickCheck, mtl, @@ -57,6 +59,7 @@ Executable config Library GHC-Options: -Wall + Hs-Source-Dirs: src Build-Depends: MissingH, directory, filepath, base >= 4.5, base < 5, IfElse, process, bytestring, hslogger, unix-compat, ansi-terminal, containers, network, async, time, QuickCheck, mtl, diff --git a/src/Propellor.hs b/src/Propellor.hs new file mode 100644 index 00000000..e6312248 --- /dev/null +++ b/src/Propellor.hs @@ -0,0 +1,77 @@ +{-# LANGUAGE PackageImports #-} + +-- | Pulls in lots of useful modules for building and using Properties. +-- +-- When propellor runs on a Host, it ensures that its list of Properties +-- is satisfied, taking action as necessary when a Property is not +-- currently satisfied. +-- +-- A simple propellor program example: +-- +-- > import Propellor +-- > import Propellor.CmdLine +-- > import qualified Propellor.Property.File as File +-- > import qualified Propellor.Property.Apt as Apt +-- > +-- > main :: IO () +-- > main = defaultMain hosts +-- > +-- > hosts :: [Host] +-- > hosts = +-- > [ host "example.com" +-- > & Apt.installed ["mydaemon"] +-- > & "/etc/mydaemon.conf" `File.containsLine` "secure=1" +-- > `onChange` cmdProperty "service" ["mydaemon", "restart"] +-- > ! Apt.installed ["unwantedpackage"] +-- > ] +-- +-- See config.hs for a more complete example, and clone Propellor's +-- git repository for a deployable system using Propellor: +-- git clone + +module Propellor ( + module Propellor.Types + , module Propellor.Property + , module Propellor.Property.Cmd + , module Propellor.Attr + , module Propellor.PrivData + , module Propellor.Engine + , module Propellor.Exception + , module Propellor.Message + , localdir + + , module X +) where + +import Propellor.Types +import Propellor.Property +import Propellor.Engine +import Propellor.Property.Cmd +import Propellor.PrivData +import Propellor.Message +import Propellor.Exception +import Propellor.Attr + +import Utility.PartialPrelude as X +import Utility.Process as X +import Utility.Exception as X +import Utility.Env as X +import Utility.Directory as X +import Utility.Tmp as X +import Utility.Monad as X +import Utility.Misc as X + +import System.Directory as X +import System.IO as X +import System.FilePath as X +import Data.Maybe as X +import Data.Either as X +import Control.Applicative as X +import Control.Monad as X +import Data.Monoid as X +import Control.Monad.IfElse as X +import "mtl" Control.Monad.Reader as X + +-- | This is where propellor installs itself when deploying a host. +localdir :: FilePath +localdir = "/usr/local/propellor" diff --git a/src/Propellor/Attr.hs b/src/Propellor/Attr.hs new file mode 100644 index 00000000..98cfc64d --- /dev/null +++ b/src/Propellor/Attr.hs @@ -0,0 +1,111 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Attr where + +import Propellor.Types +import Propellor.Types.Attr + +import "mtl" Control.Monad.Reader +import qualified Data.Set as S +import qualified Data.Map as M +import Data.Maybe +import Control.Applicative + +pureAttrProperty :: Desc -> SetAttr -> Property +pureAttrProperty desc = Property ("has " ++ desc) (return NoChange) + +hostname :: HostName -> Property +hostname name = pureAttrProperty ("hostname " ++ name) $ + \d -> d { _hostname = name } + +getHostName :: Propellor HostName +getHostName = asks _hostname + +os :: System -> Property +os system = pureAttrProperty ("Operating " ++ show system) $ + \d -> d { _os = Just system } + +getOS :: Propellor (Maybe System) +getOS = asks _os + +-- | Indidate that a host has an A record in the DNS. +-- +-- TODO check at run time if the host really has this address. +-- (Can't change the host's address, but as a sanity check.) +ipv4 :: String -> Property +ipv4 addr = pureAttrProperty ("ipv4 " ++ addr) + (addDNS $ Address $ IPv4 addr) + +-- | Indidate that a host has an AAAA record in the DNS. +ipv6 :: String -> Property +ipv6 addr = pureAttrProperty ("ipv6 " ++ addr) + (addDNS $ Address $ IPv6 addr) + +-- | Indicates another name for the host in the DNS. +alias :: Domain -> Property +alias domain = pureAttrProperty ("alias " ++ domain) + (addDNS $ CNAME $ AbsDomain domain) + +addDNS :: Record -> SetAttr +addDNS record d = d { _dns = S.insert record (_dns d) } + +-- | Adds a DNS NamedConf stanza. +-- +-- Note that adding a Master stanza for a domain always overrides an +-- existing Secondary stanza, while a Secondary stanza is only added +-- when there is no existing Master stanza. +addNamedConf :: NamedConf -> SetAttr +addNamedConf conf d = d { _namedconf = new } + where + m = _namedconf d + domain = confDomain conf + new = case (confDnsServerType conf, confDnsServerType <$> M.lookup domain m) of + (Secondary, Just Master) -> m + _ -> M.insert domain conf m + +getNamedConf :: Propellor (M.Map Domain NamedConf) +getNamedConf = asks _namedconf + +sshPubKey :: String -> Property +sshPubKey k = pureAttrProperty ("ssh pubkey known") $ + \d -> d { _sshPubKey = Just k } + +getSshPubKey :: Propellor (Maybe String) +getSshPubKey = asks _sshPubKey + +hostnameless :: Attr +hostnameless = newAttr (error "hostname Attr not specified") + +hostAttr :: Host -> Attr +hostAttr (Host _ mkattrs) = mkattrs hostnameless + +hostProperties :: Host -> [Property] +hostProperties (Host ps _) = ps + +hostMap :: [Host] -> M.Map HostName Host +hostMap l = M.fromList $ zip (map (_hostname . hostAttr) l) l + +hostAttrMap :: [Host] -> M.Map HostName Attr +hostAttrMap l = M.fromList $ zip (map _hostname attrs) attrs + where + attrs = map hostAttr l + +findHost :: [Host] -> HostName -> Maybe Host +findHost l hn = M.lookup hn (hostMap l) + +getAddresses :: Attr -> [IPAddr] +getAddresses = mapMaybe getIPAddr . S.toList . _dns + +hostAddresses :: HostName -> [Host] -> [IPAddr] +hostAddresses hn hosts = case hostAttr <$> findHost hosts hn of + Nothing -> [] + Just attr -> mapMaybe getIPAddr $ S.toList $ _dns attr + +-- | Lifts an action into a different host. +-- +-- For example, `fromHost hosts "otherhost" getSshPubKey` +fromHost :: [Host] -> HostName -> Propellor a -> Propellor (Maybe a) +fromHost l hn getter = case findHost l hn of + Nothing -> return Nothing + Just h -> liftIO $ Just <$> + runReaderT (runWithAttr getter) (hostAttr h) diff --git a/src/Propellor/CmdLine.hs b/src/Propellor/CmdLine.hs new file mode 100644 index 00000000..ab1d7f9e --- /dev/null +++ b/src/Propellor/CmdLine.hs @@ -0,0 +1,392 @@ +module Propellor.CmdLine where + +import System.Environment (getArgs) +import Data.List +import System.Exit +import System.Log.Logger +import System.Log.Formatter +import System.Log.Handler (setFormatter, LogHandler) +import System.Log.Handler.Simple +import System.PosixCompat +import Control.Exception (bracket) +import System.Posix.IO +import Data.Time.Clock.POSIX + +import Propellor +import qualified Propellor.Property.Docker as Docker +import qualified Propellor.Property.Docker.Shim as DockerShim +import Utility.FileMode +import Utility.SafeCommand +import Utility.UserInfo + +usage :: IO a +usage = do + putStrLn $ unlines + [ "Usage:" + , " propellor" + , " propellor hostname" + , " propellor --spin hostname" + , " propellor --set hostname field" + , " propellor --add-key keyid" + ] + exitFailure + +processCmdLine :: IO CmdLine +processCmdLine = go =<< getArgs + where + go ("--help":_) = usage + go ("--spin":h:[]) = return $ Spin h + go ("--boot":h:[]) = return $ Boot h + go ("--add-key":k:[]) = return $ AddKey k + go ("--set":h:f:[]) = case readish f of + Just pf -> return $ Set h pf + Nothing -> errorMessage $ "Unknown privdata field " ++ f + go ("--continue":s:[]) = case readish s of + Just cmdline -> return $ Continue cmdline + Nothing -> errorMessage "--continue serialization failure" + go ("--chain":h:[]) = return $ Chain h + go ("--docker":h:[]) = return $ Docker h + go (h:[]) + | "--" `isPrefixOf` h = usage + | otherwise = return $ Run h + go [] = do + s <- takeWhile (/= '\n') <$> readProcess "hostname" ["-f"] + if null s + then errorMessage "Cannot determine hostname! Pass it on the command line." + else return $ Run s + go _ = usage + +defaultMain :: [Host] -> IO () +defaultMain hostlist = do + DockerShim.cleanEnv + checkDebugMode + cmdline <- processCmdLine + debug ["command line: ", show cmdline] + go True cmdline + where + go _ (Continue cmdline) = go False cmdline + go _ (Set hn field) = setPrivData hn field + go _ (AddKey keyid) = addKey keyid + go _ (Chain hn) = withprops hn $ \attr ps -> do + r <- runPropellor attr $ ensureProperties ps + putStrLn $ "\n" ++ show r + go _ (Docker hn) = Docker.chain hn + go True cmdline@(Spin _) = buildFirst cmdline $ go False cmdline + go True cmdline = updateFirst cmdline $ go False cmdline + go False (Spin hn) = withprops hn $ const . const $ spin hn + go False (Run hn) = ifM ((==) 0 <$> getRealUserID) + ( onlyProcess $ withprops hn mainProperties + , go True (Spin hn) + ) + go False (Boot hn) = onlyProcess $ withprops hn boot + + withprops :: HostName -> (Attr -> [Property] -> IO ()) -> IO () + withprops hn a = maybe + (unknownhost hn) + (\h -> a (hostAttr h) (hostProperties h)) + (findHost hostlist hn) + +onlyProcess :: IO a -> IO a +onlyProcess a = bracket lock unlock (const a) + where + lock = do + l <- createFile lockfile stdFileMode + setLock l (WriteLock, AbsoluteSeek, 0, 0) + `catchIO` const alreadyrunning + return l + unlock = closeFd + alreadyrunning = error "Propellor is already running on this host!" + lockfile = localdir ".lock" + +unknownhost :: HostName -> IO a +unknownhost h = errorMessage $ unlines + [ "Propellor does not know about host: " ++ h + , "(Perhaps you should specify the real hostname on the command line?)" + , "(Or, edit propellor's config.hs to configure this host)" + ] + +buildFirst :: CmdLine -> IO () -> IO () +buildFirst cmdline next = do + oldtime <- getmtime + ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) + ( do + newtime <- getmtime + if newtime == oldtime + then next + else void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] + , errorMessage "Propellor build failed!" + ) + where + getmtime = catchMaybeIO $ getModificationTime "propellor" + +getCurrentBranch :: IO String +getCurrentBranch = takeWhile (/= '\n') + <$> readProcess "git" ["symbolic-ref", "--short", "HEAD"] + +updateFirst :: CmdLine -> IO () -> IO () +updateFirst cmdline next = do + branchref <- getCurrentBranch + let originbranch = "origin" branchref + + void $ actionMessage "Git fetch" $ boolSystem "git" [Param "fetch"] + + whenM (doesFileExist keyring) $ do + {- To verify origin branch commit's signature, have to + - convince gpg to use our keyring. While running git log. + - Which has no way to pass options to gpg. + - Argh! -} + let gpgconf = privDataDir "gpg.conf" + writeFile gpgconf $ unlines + [ " keyring " ++ keyring + , "no-auto-check-trustdb" + ] + -- gpg is picky about perms + modifyFileMode privDataDir (removeModes otherGroupModes) + s <- readProcessEnv "git" ["log", "-n", "1", "--format=%G?", originbranch] + (Just [("GNUPGHOME", privDataDir)]) + nukeFile $ privDataDir "trustdb.gpg" + nukeFile $ privDataDir "pubring.gpg" + nukeFile $ privDataDir "gpg.conf" + if s == "U\n" || s == "G\n" + then do + putStrLn $ "git branch " ++ originbranch ++ " gpg signature verified; merging" + hFlush stdout + else errorMessage $ "git branch " ++ originbranch ++ " is not signed with a trusted gpg key; refusing to deploy it!" + + oldsha <- getCurrentGitSha1 branchref + void $ boolSystem "git" [Param "merge", Param originbranch] + newsha <- getCurrentGitSha1 branchref + + if oldsha == newsha + then next + else ifM (actionMessage "Propellor build" $ boolSystem "make" [Param "build"]) + ( void $ boolSystem "./propellor" [Param "--continue", Param (show cmdline)] + , errorMessage "Propellor build failed!" + ) + +getCurrentGitSha1 :: String -> IO String +getCurrentGitSha1 branchref = readProcess "git" ["show-ref", "--hash", branchref] + +spin :: HostName -> IO () +spin hn = do + url <- getUrl + void $ gitCommit [Param "--allow-empty", Param "-a", Param "-m", Param "propellor spin"] + void $ boolSystem "git" [Param "push"] + cacheparams <- toCommand <$> sshCachingParams hn + go cacheparams url =<< gpgDecrypt (privDataFile hn) + where + go cacheparams url privdata = withBothHandles createProcessSuccess (proc "ssh" $ cacheparams ++ [user, bootstrapcmd]) $ \(toh, fromh) -> do + let finish = do + senddata toh (privDataFile hn) privDataMarker privdata + hClose toh + + -- Display remaining output. + void $ tryIO $ forever $ + showremote =<< hGetLine fromh + hClose fromh + status <- getstatus fromh `catchIO` (const $ errorMessage "protocol error (perhaps the remote propellor failed to run?)") + case status of + Ready -> finish + NeedGitClone -> do + hClose toh + hClose fromh + sendGitClone hn url + go cacheparams url privdata + + user = "root@"++hn + + bootstrapcmd = shellWrap $ intercalate " ; " + [ "if [ ! -d " ++ localdir ++ " ]" + , "then " ++ intercalate " && " + [ "apt-get --no-install-recommends --no-upgrade -y install git make" + , "echo " ++ toMarked statusMarker (show NeedGitClone) + ] + , "else " ++ intercalate " && " + [ "cd " ++ localdir + , "if ! test -x ./propellor; then make deps build; fi" + , "./propellor --boot " ++ hn + ] + , "fi" + ] + + getstatus :: Handle -> IO BootStrapStatus + getstatus h = do + l <- hGetLine h + case readish =<< fromMarked statusMarker l of + Nothing -> do + showremote l + getstatus h + Just status -> return status + + showremote s = putStrLn s + senddata toh f marker s = void $ + actionMessage ("Sending " ++ f ++ " (" ++ show (length s) ++ " bytes) to " ++ hn) $ do + sendMarked toh marker s + return True + +sendGitClone :: HostName -> String -> IO () +sendGitClone hn url = void $ actionMessage ("Pushing git repository to " ++ hn) $ do + branch <- getCurrentBranch + cacheparams <- sshCachingParams hn + withTmpFile "propellor.git" $ \tmp _ -> allM id + [ boolSystem "git" [Param "bundle", Param "create", File tmp, Param "HEAD"] + , boolSystem "scp" $ cacheparams ++ [File tmp, Param ("root@"++hn++":"++remotebundle)] + , boolSystem "ssh" $ cacheparams ++ [Param ("root@"++hn), Param $ unpackcmd branch] + ] + where + remotebundle = "/usr/local/propellor.git" + unpackcmd branch = shellWrap $ intercalate " && " + [ "git clone " ++ remotebundle ++ " " ++ localdir + , "cd " ++ localdir + , "git checkout -b " ++ branch + , "git remote rm origin" + , "rm -f " ++ remotebundle + , "git remote add origin " ++ url + -- same as --set-upstream-to, except origin branch + -- has not been pulled yet + , "git config branch."++branch++".remote origin" + , "git config branch."++branch++".merge refs/heads/"++branch + ] + +data BootStrapStatus = Ready | NeedGitClone + deriving (Read, Show, Eq) + +type Marker = String +type Marked = String + +statusMarker :: Marker +statusMarker = "STATUS" + +privDataMarker :: String +privDataMarker = "PRIVDATA " + +toMarked :: Marker -> String -> String +toMarked marker = intercalate "\n" . map (marker ++) . lines + +sendMarked :: Handle -> Marker -> String -> IO () +sendMarked h marker s = do + -- Prefix string with newline because sometimes a + -- incomplete line is output. + hPutStrLn h ("\n" ++ toMarked marker s) + hFlush h + +fromMarked :: Marker -> Marked -> Maybe String +fromMarked marker s + | null matches = Nothing + | otherwise = Just $ intercalate "\n" $ + map (drop len) matches + where + len = length marker + matches = filter (marker `isPrefixOf`) $ lines s + +boot :: Attr -> [Property] -> IO () +boot attr ps = do + sendMarked stdout statusMarker $ show Ready + reply <- hGetContentsStrict stdin + + makePrivDataDir + maybe noop (writeFileProtected privDataLocal) $ + fromMarked privDataMarker reply + mainProperties attr ps + +addKey :: String -> IO () +addKey keyid = exitBool =<< allM id [ gpg, gitadd, gitconfig, gitcommit ] + where + gpg = do + createDirectoryIfMissing True privDataDir + boolSystem "sh" + [ Param "-c" + , Param $ "gpg --export " ++ keyid ++ " | gpg " ++ + unwords (gpgopts ++ ["--import"]) + ] + gitadd = boolSystem "git" + [ Param "add" + , File keyring + ] + + gitconfig = boolSystem "git" + [ Param "config" + , Param "user.signingkey" + , Param keyid + ] + + gitcommit = gitCommit + [ File keyring + , Param "-m" + , Param "propellor addkey" + ] + +{- Automatically sign the commit if there'a a keyring. -} +gitCommit :: [CommandParam] -> IO Bool +gitCommit ps = do + k <- doesFileExist keyring + boolSystem "git" $ catMaybes $ + [ Just (Param "commit") + , if k then Just (Param "--gpg-sign") else Nothing + ] ++ map Just ps + +keyring :: FilePath +keyring = privDataDir "keyring.gpg" + +gpgopts :: [String] +gpgopts = ["--options", "/dev/null", "--no-default-keyring", "--keyring", keyring] + +getUrl :: IO String +getUrl = maybe nourl return =<< getM get urls + where + urls = ["remote.deploy.url", "remote.origin.url"] + nourl = errorMessage $ "Cannot find deploy url in " ++ show urls + get u = do + v <- catchMaybeIO $ + takeWhile (/= '\n') + <$> readProcess "git" ["config", u] + return $ case v of + Just url | not (null url) -> Just url + _ -> Nothing + +checkDebugMode :: IO () +checkDebugMode = go =<< getEnv "PROPELLOR_DEBUG" + where + go (Just s) + | s == "1" = do + f <- setFormatter + <$> streamHandler stderr DEBUG + <*> pure (simpleLogFormatter "[$time] $msg") + updateGlobalLogger rootLoggerName $ + setLevel DEBUG . setHandlers [f] + go _ = noop + +-- Parameters can be passed to both ssh and scp, to enable a ssh connection +-- caching socket. +-- +-- If the socket already exists, check if its mtime is older than 10 +-- minutes, and if so stop that ssh process, in order to not try to +-- use an old stale connection. (atime would be nicer, but there's +-- a good chance a laptop uses noatime) +sshCachingParams :: HostName -> IO [CommandParam] +sshCachingParams hn = do + home <- myHomeDir + let cachedir = home ".ssh" "propellor" + createDirectoryIfMissing False cachedir + let socketfile = cachedir hn ++ ".sock" + let ps = + [ Param "-o", Param ("ControlPath=" ++ socketfile) + , Params "-o ControlMaster=auto -o ControlPersist=yes" + ] + + maybe noop (expireold ps socketfile) + =<< catchMaybeIO (getFileStatus socketfile) + + return ps + + where + expireold ps f s = do + now <- truncate <$> getPOSIXTime :: IO Integer + if modificationTime s > fromIntegral now - tenminutes + then touchFile f + else do + void $ boolSystem "ssh" $ + [ Params "-O stop" ] ++ ps ++ + [ Param "localhost" ] + nukeFile f + tenminutes = 600 diff --git a/src/Propellor/Engine.hs b/src/Propellor/Engine.hs new file mode 100644 index 00000000..55ce7f77 --- /dev/null +++ b/src/Propellor/Engine.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Engine where + +import System.Exit +import System.IO +import Data.Monoid +import System.Console.ANSI +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Message +import Propellor.Exception + +runPropellor :: Attr -> Propellor a -> IO a +runPropellor attr a = runReaderT (runWithAttr a) attr + +mainProperties :: Attr -> [Property] -> IO () +mainProperties attr ps = do + r <- runPropellor attr $ + ensureProperties [Property "overall" (ensureProperties ps) id] + setTitle "propellor: done" + hFlush stdout + case r of + FailedChange -> exitWith (ExitFailure 1) + _ -> exitWith ExitSuccess + +ensureProperties :: [Property] -> Propellor Result +ensureProperties ps = ensure ps NoChange + where + ensure [] rs = return rs + ensure (l:ls) rs = do + r <- actionMessage (propertyDesc l) (ensureProperty l) + ensure ls (r <> rs) + +ensureProperty :: Property -> Propellor Result +ensureProperty = catchPropellor . propertySatisfy diff --git a/src/Propellor/Exception.hs b/src/Propellor/Exception.hs new file mode 100644 index 00000000..f6fd15f1 --- /dev/null +++ b/src/Propellor/Exception.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Exception where + +import qualified "MonadCatchIO-transformers" Control.Monad.CatchIO as M +import Control.Exception + +import Propellor.Types +import Propellor.Message + +-- | Catches IO exceptions and returns FailedChange. +catchPropellor :: Propellor Result -> Propellor Result +catchPropellor a = either err return =<< tryPropellor a + where + err e = warningMessage (show e) >> return FailedChange + +tryPropellor :: Propellor a -> Propellor (Either IOException a) +tryPropellor = M.try diff --git a/src/Propellor/Message.hs b/src/Propellor/Message.hs new file mode 100644 index 00000000..780471c3 --- /dev/null +++ b/src/Propellor/Message.hs @@ -0,0 +1,51 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Message where + +import System.Console.ANSI +import System.IO +import System.Log.Logger +import "mtl" Control.Monad.Reader + +import Propellor.Types + +-- | Shows a message while performing an action, with a colored status +-- display. +actionMessage :: (MonadIO m, ActionResult r) => Desc -> m r -> m r +actionMessage desc a = do + liftIO $ do + setTitle $ "propellor: " ++ desc + hFlush stdout + + r <- a + + liftIO $ do + setTitle "propellor: running" + let (msg, intensity, color) = getActionResult r + putStr $ desc ++ " ... " + colorLine intensity color msg + hFlush stdout + + return r + +warningMessage :: MonadIO m => String -> m () +warningMessage s = liftIO $ colorLine Vivid Magenta $ "** warning: " ++ s + +colorLine :: ColorIntensity -> Color -> String -> IO () +colorLine intensity color msg = do + setSGR [SetColor Foreground intensity color] + putStr msg + setSGR [] + -- Note this comes after the color is reset, so that + -- the color set and reset happen in the same line. + putStrLn "" + hFlush stdout + +errorMessage :: String -> IO a +errorMessage s = do + liftIO $ colorLine Vivid Red $ "** error: " ++ s + error "Cannot continue!" + +-- | Causes a debug message to be displayed when PROPELLOR_DEBUG=1 +debug :: [String] -> IO () +debug = debugM "propellor" . unwords diff --git a/src/Propellor/PrivData.hs b/src/Propellor/PrivData.hs new file mode 100644 index 00000000..ad2c8d22 --- /dev/null +++ b/src/Propellor/PrivData.hs @@ -0,0 +1,91 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.PrivData where + +import qualified Data.Map as M +import Control.Applicative +import System.FilePath +import System.IO +import System.Directory +import Data.Maybe +import Data.List +import Control.Monad +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Attr +import Propellor.Message +import Utility.Monad +import Utility.PartialPrelude +import Utility.Exception +import Utility.Process +import Utility.Tmp +import Utility.SafeCommand +import Utility.Misc + +-- | When the specified PrivDataField is available on the host Propellor +-- is provisioning, it provies the data to the action. Otherwise, it prints +-- a message to help the user make the necessary private data available. +withPrivData :: PrivDataField -> (String -> Propellor Result) -> Propellor Result +withPrivData field a = maybe missing a =<< liftIO (getPrivData field) + where + missing = do + host <- getHostName + let host' = if ".docker" `isSuffixOf` host + then "$parent_host" + else host + liftIO $ do + warningMessage $ "Missing privdata " ++ show field + putStrLn $ "Fix this by running: propellor --set "++host'++" '" ++ show field ++ "'" + return FailedChange + +getPrivData :: PrivDataField -> IO (Maybe String) +getPrivData field = do + m <- catchDefaultIO Nothing $ readish <$> readFile privDataLocal + return $ maybe Nothing (M.lookup field) m + +setPrivData :: HostName -> PrivDataField -> IO () +setPrivData host field = do + putStrLn "Enter private data on stdin; ctrl-D when done:" + value <- chomp <$> hGetContentsStrict stdin + makePrivDataDir + let f = privDataFile host + m <- fromMaybe M.empty . readish <$> gpgDecrypt f + let m' = M.insert field value m + gpgEncrypt f (show m') + putStrLn "Private data set." + void $ boolSystem "git" [Param "add", File f] + where + chomp s + | end s == "\n" = chomp (beginning s) + | otherwise = s + +makePrivDataDir :: IO () +makePrivDataDir = createDirectoryIfMissing False privDataDir + +privDataDir :: FilePath +privDataDir = "privdata" + +privDataFile :: HostName -> FilePath +privDataFile host = privDataDir host ++ ".gpg" + +privDataLocal :: FilePath +privDataLocal = privDataDir "local" + +gpgDecrypt :: FilePath -> IO String +gpgDecrypt f = ifM (doesFileExist f) + ( readProcess "gpg" ["--decrypt", f] + , return "" + ) + +gpgEncrypt :: FilePath -> String -> IO () +gpgEncrypt f s = do + encrypted <- writeReadProcessEnv "gpg" + [ "--default-recipient-self" + , "--armor" + , "--encrypt" + ] + Nothing + (Just $ flip hPutStr s) + Nothing + viaTmp writeFile f encrypted diff --git a/src/Propellor/Property.hs b/src/Propellor/Property.hs new file mode 100644 index 00000000..24494654 --- /dev/null +++ b/src/Propellor/Property.hs @@ -0,0 +1,163 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Property where + +import System.Directory +import Control.Monad +import Data.Monoid +import Data.List +import Control.Monad.IfElse +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Types.Attr +import Propellor.Attr +import Propellor.Engine +import Utility.Monad +import System.FilePath + +-- Constructs a Property. +property :: Desc -> Propellor Result -> Property +property d s = Property d s id + +-- | Combines a list of properties, resulting in a single property +-- that when run will run each property in the list in turn, +-- and print out the description of each as it's run. Does not stop +-- on failure; does propigate overall success/failure. +propertyList :: Desc -> [Property] -> Property +propertyList desc ps = Property desc (ensureProperties ps) (combineSetAttrs ps) + +-- | Combines a list of properties, resulting in one property that +-- ensures each in turn, stopping on failure. +combineProperties :: Desc -> [Property] -> Property +combineProperties desc ps = Property desc (go ps NoChange) (combineSetAttrs ps) + where + go [] rs = return rs + go (l:ls) rs = do + r <- ensureProperty l + case r of + FailedChange -> return FailedChange + _ -> go ls (r <> rs) + +-- | Combines together two properties, resulting in one property +-- that ensures the first, and if the first succeeds, ensures the second. +-- The property uses the description of the first property. +before :: Property -> Property -> Property +p1 `before` p2 = p2 `requires` p1 + `describe` (propertyDesc p1) + +-- | Makes a perhaps non-idempotent Property be idempotent by using a flag +-- file to indicate whether it has run before. +-- Use with caution. +flagFile :: Property -> FilePath -> Property +flagFile p = flagFile' p . return + +flagFile' :: Property -> IO FilePath -> Property +flagFile' p getflagfile = adjustProperty p $ \satisfy -> do + flagfile <- liftIO getflagfile + go satisfy flagfile =<< liftIO (doesFileExist flagfile) + where + go _ _ True = return NoChange + go satisfy flagfile False = do + r <- satisfy + when (r == MadeChange) $ liftIO $ + unlessM (doesFileExist flagfile) $ do + createDirectoryIfMissing True (takeDirectory flagfile) + writeFile flagfile "" + return r + +--- | Whenever a change has to be made for a Property, causes a hook +-- Property to also be run, but not otherwise. +onChange :: Property -> Property -> Property +p `onChange` hook = Property (propertyDesc p) satisfy (combineSetAttr p hook) + where + satisfy = do + r <- ensureProperty p + case r of + MadeChange -> do + r' <- ensureProperty hook + return $ r <> r' + _ -> return r + +(==>) :: Desc -> Property -> Property +(==>) = flip describe +infixl 1 ==> + +-- | Makes a Property only need to do anything when a test succeeds. +check :: IO Bool -> Property -> Property +check c p = adjustProperty p $ \satisfy -> ifM (liftIO c) + ( satisfy + , return NoChange + ) + +-- | Marks a Property as trivial. It can only return FailedChange or +-- NoChange. +-- +-- Useful when it's just as expensive to check if a change needs +-- to be made as it is to just idempotently assure the property is +-- satisfied. For example, chmodding a file. +trivial :: Property -> Property +trivial p = adjustProperty p $ \satisfy -> do + r <- satisfy + if r == MadeChange + then return NoChange + else return r + +-- | Makes a property that is satisfied differently depending on the host's +-- operating system. +-- +-- Note that the operating system may not be declared for some hosts. +withOS :: Desc -> (Maybe System -> Propellor Result) -> Property +withOS desc a = property desc $ a =<< getOS + +boolProperty :: Desc -> IO Bool -> Property +boolProperty desc a = property desc $ ifM (liftIO a) + ( return MadeChange + , return FailedChange + ) + +-- | Undoes the effect of a property. +revert :: RevertableProperty -> RevertableProperty +revert (RevertableProperty p1 p2) = RevertableProperty p2 p1 + +-- | Starts accumulating the properties of a Host. +-- +-- > host "example.com" +-- > & someproperty +-- > ! oldproperty +-- > & otherproperty +host :: HostName -> Host +host hn = Host [] (\_ -> newAttr hn) + +-- | Adds a property to a Host +-- +-- Can add Properties and RevertableProperties +(&) :: IsProp p => Host -> p -> Host +(Host ps as) & p = Host (ps ++ [toProp p]) (setAttr p . as) + +infixl 1 & + +-- | Adds a property to the Host in reverted form. +(!) :: Host -> RevertableProperty -> Host +(Host ps as) ! p = Host (ps ++ [toProp q]) (setAttr q . as) + where + q = revert p + +infixl 1 ! + +-- Changes the action that is performed to satisfy a property. +adjustProperty :: Property -> (Propellor Result -> Propellor Result) -> Property +adjustProperty p f = p { propertySatisfy = f (propertySatisfy p) } + +-- Combines the Attr settings of two properties. +combineSetAttr :: (IsProp p, IsProp q) => p -> q -> SetAttr +combineSetAttr p q = setAttr p . setAttr q + +combineSetAttrs :: IsProp p => [p] -> SetAttr +combineSetAttrs = foldl' (.) id . map setAttr + +makeChange :: IO () -> Propellor Result +makeChange a = liftIO a >> return MadeChange + +noChange :: Propellor Result +noChange = return NoChange diff --git a/src/Propellor/Property/Apache.hs b/src/Propellor/Property/Apache.hs new file mode 100644 index 00000000..cf3e62cc --- /dev/null +++ b/src/Propellor/Property/Apache.hs @@ -0,0 +1,62 @@ +module Propellor.Property.Apache where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service + +type ConfigFile = [String] + +siteEnabled :: HostName -> ConfigFile -> RevertableProperty +siteEnabled hn cf = RevertableProperty enable disable + where + enable = trivial $ cmdProperty "a2ensite" ["--quiet", hn] + `describe` ("apache site enabled " ++ hn) + `requires` siteAvailable hn cf + `requires` installed + `onChange` reloaded + disable = trivial $ File.notPresent (siteCfg hn) + `describe` ("apache site disabled " ++ hn) + `onChange` cmdProperty "a2dissite" ["--quiet", hn] + `requires` installed + `onChange` reloaded + +siteAvailable :: HostName -> ConfigFile -> Property +siteAvailable hn cf = siteCfg hn `File.hasContent` (comment:cf) + `describe` ("apache site available " ++ hn) + where + comment = "# deployed with propellor, do not modify" + +modEnabled :: String -> RevertableProperty +modEnabled modname = RevertableProperty enable disable + where + enable = trivial $ cmdProperty "a2enmod" ["--quiet", modname] + `describe` ("apache module enabled " ++ modname) + `requires` installed + `onChange` reloaded + disable = trivial $ cmdProperty "a2dismod" ["--quiet", modname] + `describe` ("apache module disabled " ++ modname) + `requires` installed + `onChange` reloaded + +siteCfg :: HostName -> FilePath +siteCfg hn = "/etc/apache2/sites-available/" ++ hn + +installed :: Property +installed = Apt.installed ["apache2"] + +restarted :: Property +restarted = cmdProperty "service" ["apache2", "restart"] + +reloaded :: Property +reloaded = Service.reloaded "apache2" + +-- | Configure apache to use SNI to differentiate between +-- https hosts. +multiSSL :: Property +multiSSL = "/etc/apache2/conf.d/ssl" `File.hasContent` + [ "NameVirtualHost *:443" + , "SSLStrictSNIVHostCheck off" + ] + `describe` "apache SNI enabled" + `onChange` reloaded diff --git a/src/Propellor/Property/Apt.hs b/src/Propellor/Property/Apt.hs new file mode 100644 index 00000000..7329c7a8 --- /dev/null +++ b/src/Propellor/Property/Apt.hs @@ -0,0 +1,256 @@ +module Propellor.Property.Apt where + +import Data.Maybe +import Control.Applicative +import Data.List +import System.IO +import Control.Monad + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Service as Service +import Propellor.Property.File (Line) + +sourcesList :: FilePath +sourcesList = "/etc/apt/sources.list" + +type Url = String +type Section = String + +type SourcesGenerator = DebianSuite -> [Line] + +showSuite :: DebianSuite -> String +showSuite Stable = "stable" +showSuite Testing = "testing" +showSuite Unstable = "unstable" +showSuite Experimental = "experimental" +showSuite (DebianRelease r) = r + +backportSuite :: String +backportSuite = showSuite stableRelease ++ "-backports" + +debLine :: String -> Url -> [Section] -> Line +debLine suite mirror sections = unwords $ + ["deb", mirror, suite] ++ sections + +srcLine :: Line -> Line +srcLine l = case words l of + ("deb":rest) -> unwords $ "deb-src" : rest + _ -> "" + +stdSections :: [Section] +stdSections = ["main", "contrib", "non-free"] + +binandsrc :: String -> SourcesGenerator +binandsrc url suite + | isStable suite = [l, srcLine l, bl, srcLine bl] + | otherwise = [l, srcLine l] + where + l = debLine (showSuite suite) url stdSections + bl = debLine backportSuite url stdSections + +debCdn :: SourcesGenerator +debCdn = binandsrc "http://cdn.debian.net/debian" + +kernelOrg :: SourcesGenerator +kernelOrg = binandsrc "http://mirrors.kernel.org/debian" + +-- | Only available for Stable and Testing +securityUpdates :: SourcesGenerator +securityUpdates suite + | isStable suite || suite == Testing = + let l = "deb http://security.debian.org/ " ++ showSuite suite ++ "/updates " ++ unwords stdSections + in [l, srcLine l] + | otherwise = [] + +-- | Makes sources.list have a standard content using the mirror CDN, +-- with a particular DebianSuite. +-- +-- Since the CDN is sometimes unreliable, also adds backup lines using +-- kernel.org. +stdSourcesList :: DebianSuite -> Property +stdSourcesList suite = stdSourcesList' suite [] + +-- | Adds additional sources.list generators. +-- +-- Note that if a Property needs to enable an apt source, it's better +-- to do so via a separate file in /etc/apt/sources.list.d/ +stdSourcesList' :: DebianSuite -> [SourcesGenerator] -> Property +stdSourcesList' suite more = setSourcesList + (concatMap (\gen -> gen suite) generators) + `describe` ("standard sources.list for " ++ show suite) + where + generators = [debCdn, kernelOrg, securityUpdates] ++ more + +setSourcesList :: [Line] -> Property +setSourcesList ls = sourcesList `File.hasContent` ls `onChange` update + +setSourcesListD :: [Line] -> FilePath -> Property +setSourcesListD ls basename = f `File.hasContent` ls `onChange` update + where + f = "/etc/apt/sources.list.d/" ++ basename ++ ".list" + +runApt :: [String] -> Property +runApt ps = cmdProperty' "apt-get" ps noninteractiveEnv + +noninteractiveEnv :: [(String, String)] +noninteractiveEnv = + [ ("DEBIAN_FRONTEND", "noninteractive") + , ("APT_LISTCHANGES_FRONTEND", "none") + ] + +update :: Property +update = runApt ["update"] + `describe` "apt update" + +upgrade :: Property +upgrade = runApt ["-y", "dist-upgrade"] + `describe` "apt dist-upgrade" + +type Package = String + +installed :: [Package] -> Property +installed = installed' ["-y"] + +installed' :: [String] -> [Package] -> Property +installed' params ps = robustly $ check (isInstallable ps) go + `describe` (unwords $ "apt installed":ps) + where + go = runApt $ params ++ ["install"] ++ ps + +installedBackport :: [Package] -> Property +installedBackport ps = trivial $ withOS desc $ \o -> case o of + Nothing -> error "cannot install backports; os not declared" + (Just (System (Debian suite) _)) + | isStable suite -> + ensureProperty $ runApt $ + ["install", "-t", backportSuite, "-y"] ++ ps + _ -> error $ "backports not supported on " ++ show o + where + desc = (unwords $ "apt installed backport":ps) + +-- | Minimal install of package, without recommends. +installedMin :: [Package] -> Property +installedMin = installed' ["--no-install-recommends", "-y"] + +removed :: [Package] -> Property +removed ps = check (or <$> isInstalled' ps) go + `describe` (unwords $ "apt removed":ps) + where + go = runApt $ ["-y", "remove"] ++ ps + +buildDep :: [Package] -> Property +buildDep ps = robustly go + `describe` (unwords $ "apt build-dep":ps) + where + go = runApt $ ["-y", "build-dep"] ++ ps + +-- | Installs the build deps for the source package unpacked +-- in the specifed directory, with a dummy package also +-- installed so that autoRemove won't remove them. +buildDepIn :: FilePath -> Property +buildDepIn dir = go `requires` installedMin ["devscripts", "equivs"] + where + go = cmdProperty' "sh" ["-c", "cd '" ++ dir ++ "' && mk-build-deps debian/control --install --tool 'apt-get -y --no-install-recommends' --remove"] + noninteractiveEnv + +-- | Package installation may fail becuse the archive has changed. +-- Run an update in that case and retry. +robustly :: Property -> Property +robustly p = adjustProperty p $ \satisfy -> do + r <- satisfy + if r == FailedChange + then ensureProperty $ p `requires` update + else return r + +isInstallable :: [Package] -> IO Bool +isInstallable ps = do + l <- isInstalled' ps + return $ any (== False) l && not (null l) + +isInstalled :: Package -> IO Bool +isInstalled p = (== [True]) <$> isInstalled' [p] + +-- | Note that the order of the returned list will not always +-- correspond to the order of the input list. The number of items may +-- even vary. If apt does not know about a package at all, it will not +-- be included in the result list. +isInstalled' :: [Package] -> IO [Bool] +isInstalled' ps = catMaybes . map parse . lines + <$> readProcess "apt-cache" ("policy":ps) + where + parse l + | "Installed: (none)" `isInfixOf` l = Just False + | "Installed: " `isInfixOf` l = Just True + | otherwise = Nothing + +autoRemove :: Property +autoRemove = runApt ["-y", "autoremove"] + `describe` "apt autoremove" + +-- | Enables unattended upgrades. Revert to disable. +unattendedUpgrades :: RevertableProperty +unattendedUpgrades = RevertableProperty enable disable + where + enable = setup True + `before` Service.running "cron" + `before` configure + disable = setup False + + setup enabled = (if enabled then installed else removed) ["unattended-upgrades"] + `onChange` reConfigure "unattended-upgrades" + [("unattended-upgrades/enable_auto_updates" , "boolean", v)] + `describe` ("unattended upgrades " ++ v) + where + v + | enabled = "true" + | otherwise = "false" + + configure = withOS "unattended upgrades configured" $ \o -> + case o of + -- the package defaults to only upgrading stable + (Just (System (Debian suite) _)) + | not (isStable suite) -> ensureProperty $ + "/etc/apt/apt.conf.d/50unattended-upgrades" + `File.containsLine` + ("\t\"o=Debian,a="++showSuite suite++"\";") + _ -> noChange + +-- | Preseeds debconf values and reconfigures the package so it takes +-- effect. +reConfigure :: Package -> [(String, String, String)] -> Property +reConfigure package vals = reconfigure `requires` setselections + `describe` ("reconfigure " ++ package) + where + setselections = property "preseed" $ makeChange $ + withHandle StdinHandle createProcessSuccess + (proc "debconf-set-selections" []) $ \h -> do + forM_ vals $ \(tmpl, tmpltype, value) -> + hPutStrLn h $ unwords [package, tmpl, tmpltype, value] + hClose h + reconfigure = cmdProperty' "dpkg-reconfigure" ["-fnone", package] noninteractiveEnv + +-- | Ensures that a service is installed and running. +-- +-- Assumes that there is a 1:1 mapping between service names and apt +-- package names. +serviceInstalledRunning :: Package -> Property +serviceInstalledRunning svc = Service.running svc `requires` installed [svc] + +data AptKey = AptKey + { keyname :: String + , pubkey :: String + } + +trustsKey :: AptKey -> RevertableProperty +trustsKey k = RevertableProperty trust untrust + where + desc = "apt trusts key " ++ keyname k + f = "/etc/apt/trusted.gpg.d" keyname k ++ ".gpg" + untrust = File.notPresent f + trust = check (not <$> doesFileExist f) $ property desc $ makeChange $ do + withHandle StdinHandle createProcessSuccess + (proc "gpg" ["--no-default-keyring", "--keyring", f, "--import", "-"]) $ \h -> do + hPutStr h (pubkey k) + hClose h + nukeFile $ f ++ "~" -- gpg dropping diff --git a/src/Propellor/Property/Cmd.hs b/src/Propellor/Property/Cmd.hs new file mode 100644 index 00000000..bcd08246 --- /dev/null +++ b/src/Propellor/Property/Cmd.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE PackageImports #-} + +module Propellor.Property.Cmd ( + cmdProperty, + cmdProperty', + scriptProperty, + userScriptProperty, +) where + +import Control.Applicative +import Data.List +import "mtl" Control.Monad.Reader + +import Propellor.Types +import Propellor.Property +import Utility.Monad +import Utility.SafeCommand +import Utility.Env + +-- | A property that can be satisfied by running a command. +-- +-- The command must exit 0 on success. +cmdProperty :: String -> [String] -> Property +cmdProperty cmd params = cmdProperty' cmd params [] + +-- | A property that can be satisfied by running a command, +-- with added environment. +cmdProperty' :: String -> [String] -> [(String, String)] -> Property +cmdProperty' cmd params env = property desc $ liftIO $ do + env' <- addEntries env <$> getEnvironment + ifM (boolSystemEnv cmd (map Param params) (Just env')) + ( return MadeChange + , return FailedChange + ) + where + desc = unwords $ cmd : params + +-- | A property that can be satisfied by running a series of shell commands. +scriptProperty :: [String] -> Property +scriptProperty script = cmdProperty "sh" ["-c", shellcmd] + where + shellcmd = intercalate " ; " ("set -e" : script) + +-- | A property that can satisfied by running a series of shell commands, +-- as user (cd'd to their home directory). +userScriptProperty :: UserName -> [String] -> Property +userScriptProperty user script = cmdProperty "su" ["-c", shellcmd, user] + where + shellcmd = intercalate " ; " ("set -e" : "cd" : script) diff --git a/src/Propellor/Property/Cron.hs b/src/Propellor/Property/Cron.hs new file mode 100644 index 00000000..5b070eff --- /dev/null +++ b/src/Propellor/Property/Cron.hs @@ -0,0 +1,49 @@ +module Propellor.Property.Cron where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import Utility.SafeCommand + +import Data.Char + +type CronTimes = String + +-- | Installs a cron job, run as a specified user, in a particular +-- directory. Note that the Desc must be unique, as it is used for the +-- cron.d/ filename. +-- +-- Only one instance of the cron job is allowed to run at a time, no matter +-- how long it runs. This is accomplished using flock locking of the cron +-- job file. +-- +-- The cron job's output will only be emailed if it exits nonzero. +job :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property +job desc times user cddir command = cronjobfile `File.hasContent` + [ "# Generated by propellor" + , "" + , "SHELL=/bin/sh" + , "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin" + , "" + , times ++ "\t" ++ user ++ "\t" + ++ "chronic flock -n " ++ shellEscape cronjobfile + ++ " sh -c " ++ shellEscape cmdline + ] + `requires` Apt.serviceInstalledRunning "cron" + `requires` Apt.installed ["util-linux", "moreutils"] + `describe` ("cronned " ++ desc) + where + cmdline = "cd " ++ cddir ++ " && ( " ++ command ++ " )" + cronjobfile = "/etc/cron.d/" ++ map sanitize desc + sanitize c + | isAlphaNum c = c + | otherwise = '_' + +-- | Installs a cron job, and runs it niced and ioniced. +niceJob :: Desc -> CronTimes -> UserName -> FilePath -> String -> Property +niceJob desc times user cddir command = job desc times user cddir + ("nice ionice -c 3 " ++ command) + +-- | Installs a cron job to run propellor. +runPropellor :: CronTimes -> Property +runPropellor times = niceJob "propellor" times "root" localdir "make" diff --git a/src/Propellor/Property/Dns.hs b/src/Propellor/Property/Dns.hs new file mode 100644 index 00000000..5c3162cb --- /dev/null +++ b/src/Propellor/Property/Dns.hs @@ -0,0 +1,405 @@ +module Propellor.Property.Dns ( + module Propellor.Types.Dns, + primary, + secondary, + secondaryFor, + mkSOA, + writeZoneFile, + nextSerialNumber, + adjustSerialNumber, + serialNumberOffset, + WarningMessage, + genZone, +) where + +import Propellor +import Propellor.Types.Dns +import Propellor.Property.File +import Propellor.Types.Attr +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service +import Utility.Applicative + +import qualified Data.Map as M +import qualified Data.Set as S +import Data.List + +-- | Primary dns server for a domain. +-- +-- Most of the content of the zone file is configured by setting properties +-- of hosts. For example, +-- +-- > host "foo.example.com" +-- > & ipv4 "192.168.1.1" +-- > & alias "mail.exmaple.com" +-- +-- Will cause that hostmame and its alias to appear in the zone file, +-- with the configured IP address. +-- +-- The [(BindDomain, Record)] list can be used for additional records +-- that cannot be configured elsewhere. This often includes NS records, +-- TXT records and perhaps CNAMEs pointing at hosts that propellor does +-- not control. +-- +-- The primary server is configured to only allow zone transfers to +-- secondary dns servers. These are determined in two ways: +-- +-- 1. By looking at the properties of other hosts, to find hosts that +-- are configured as the secondary dns server. +-- +-- 2. By looking for NS Records in the passed list of records. +-- +-- In either case, the secondary dns server Host should have an ipv4 and/or +-- ipv6 property defined. +primary :: [Host] -> Domain -> SOA -> [(BindDomain, Record)] -> RevertableProperty +primary hosts domain soa rs = RevertableProperty setup cleanup + where + setup = withwarnings (check needupdate baseprop) + `requires` servingZones + `onChange` Service.reloaded "bind9" + cleanup = check (doesFileExist zonefile) $ + property ("removed dns primary for " ++ domain) + (makeChange $ removeZoneFile zonefile) + `requires` namedConfWritten + `onChange` Service.reloaded "bind9" + + (partialzone, zonewarnings) = genZone hosts domain soa + zone = partialzone { zHosts = zHosts partialzone ++ rs } + zonefile = "/etc/bind/propellor/db." ++ domain + baseprop = Property ("dns primary for " ++ domain) + (makeChange $ writeZoneFile zone zonefile) + (addNamedConf conf) + withwarnings p = adjustProperty p $ \satisfy -> do + mapM_ warningMessage $ zonewarnings ++ secondarywarnings + satisfy + conf = NamedConf + { confDomain = domain + , confDnsServerType = Master + , confFile = zonefile + , confMasters = [] + , confAllowTransfer = nub $ + concatMap (\h -> hostAddresses h hosts) $ + secondaries ++ nssecondaries + , confLines = [] + } + secondaries = otherServers Secondary hosts domain + secondarywarnings = map (\h -> "No IP address defined for DNS seconary " ++ h) $ + filter (\h -> null (hostAddresses h hosts)) secondaries + nssecondaries = mapMaybe (domainHostName <=< getNS) rootRecords + rootRecords = map snd $ + filter (\(d, _r) -> d == RootDomain || d == AbsDomain domain) rs + needupdate = do + v <- readZonePropellorFile zonefile + return $ case v of + Nothing -> True + Just oldzone -> + -- compare everything except serial + let oldserial = sSerialĀ (zSOA oldzone) + z = zone { zSOA = (zSOA zone) { sSerial = oldserial } } + in z /= oldzone || oldserial < sSerial (zSOA zone) + +-- | Secondary dns server for a domain. +-- +-- The primary server is determined by looking at the properties of other +-- hosts to find which one is configured as the primary. +-- +-- Note that if a host is declared to be a primary and a secondary dns +-- server for the same domain, the primary server config always wins. +secondary :: [Host] -> Domain -> RevertableProperty +secondary hosts domain = secondaryFor (otherServers Master hosts domain) hosts domain + +-- | This variant is useful if the primary server does not have its DNS +-- configured via propellor. +secondaryFor :: [HostName] -> [Host] -> Domain -> RevertableProperty +secondaryFor masters hosts domain = RevertableProperty setup cleanup + where + setup = pureAttrProperty desc (addNamedConf conf) + `requires` servingZones + cleanup = namedConfWritten + + desc = "dns secondary for " ++ domain + conf = NamedConf + { confDomain = domain + , confDnsServerType = Secondary + , confFile = "db." ++ domain + , confMasters = concatMap (\m -> hostAddresses m hosts) masters + , confAllowTransfer = [] + , confLines = [] + } + +otherServers :: DnsServerType -> [Host] -> Domain -> [HostName] +otherServers wantedtype hosts domain = + M.keys $ M.filter wanted $ hostAttrMap hosts + where + wanted attr = case M.lookup domain (_namedconf attr) of + Nothing -> False + Just conf -> confDnsServerType conf == wantedtype + && confDomain conf == domain + +-- | Rewrites the whole named.conf.local file to serve the zones +-- configured by `primary` and `secondary`, and ensures that bind9 is +-- running. +servingZones :: Property +servingZones = namedConfWritten + `onChange` Service.reloaded "bind9" + `requires` Apt.serviceInstalledRunning "bind9" + +namedConfWritten :: Property +namedConfWritten = property "named.conf configured" $ do + zs <- getNamedConf + ensureProperty $ + hasContent namedConfFile $ + concatMap confStanza $ M.elems zs + +confStanza :: NamedConf -> [Line] +confStanza c = + [ "// automatically generated by propellor" + , "zone \"" ++ confDomain c ++ "\" {" + , cfgline "type" (if confDnsServerType c == Master then "master" else "slave") + , cfgline "file" ("\"" ++ confFile c ++ "\"") + ] ++ + mastersblock ++ + allowtransferblock ++ + (map (\l -> "\t" ++ l ++ ";") (confLines c)) ++ + [ "};" + , "" + ] + where + cfgline f v = "\t" ++ f ++ " " ++ v ++ ";" + ipblock name l = + [ "\t" ++ name ++ " {" ] ++ + (map (\ip -> "\t\t" ++ fromIPAddr ip ++ ";") l) ++ + [ "\t};" ] + mastersblock + | null (confMasters c) = [] + | otherwise = ipblock "masters" (confMasters c) + -- an empty block prohibits any transfers + allowtransferblock = ipblock "allow-transfer" (confAllowTransfer c) + +namedConfFile :: FilePath +namedConfFile = "/etc/bind/named.conf.local" + +-- | Generates a SOA with some fairly sane numbers in it. +-- +-- The Domain is the domain to use in the SOA record. Typically +-- something like ns1.example.com. So, not the domain that this is the SOA +-- record for. +-- +-- The SerialNumber can be whatever serial number was used by the domain +-- before propellor started managing it. Or 0 if the domain has only ever +-- been managed by propellor. +-- +-- You do not need to increment the SerialNumber when making changes! +-- Propellor will automatically add the number of commits in the git +-- repository to the SerialNumber. +mkSOA :: Domain -> SerialNumber -> SOA +mkSOA d sn = SOA + { sDomain = AbsDomain d + , sSerial = sn + , sRefresh = hours 4 + , sRetry = hours 1 + , sExpire = 2419200 -- 4 weeks + , sNegativeCacheTTL = hours 8 + } + where + hours n = n * 60 * 60 + +dValue :: BindDomain -> String +dValue (RelDomain d) = d +dValue (AbsDomain d) = d ++ "." +dValue (RootDomain) = "@" + +rField :: Record -> String +rField (Address (IPv4 _)) = "A" +rField (Address (IPv6 _)) = "AAAA" +rField (CNAME _) = "CNAME" +rField (MX _ _) = "MX" +rField (NS _) = "NS" +rField (TXT _) = "TXT" +rField (SRV _ _ _ _) = "SRV" + +rValue :: Record -> String +rValue (Address (IPv4 addr)) = addr +rValue (Address (IPv6 addr)) = addr +rValue (CNAME d) = dValue d +rValue (MX pri d) = show pri ++ " " ++ dValue d +rValue (NS d) = dValue d +rValue (SRV priority weight port target) = unwords + [ show priority + , show weight + , show port + , dValue target + ] +rValue (TXT s) = [q] ++ filter (/= q) s ++ [q] + where + q = '"' + +-- | Adjusts the serial number of the zone to always be larger +-- than the serial number in the Zone record, +-- and always be larger than the passed SerialNumber. +nextSerialNumber :: Zone -> SerialNumber -> Zone +nextSerialNumber z serial = adjustSerialNumber z $ \sn -> succ $ max sn serial + +adjustSerialNumber :: Zone -> (SerialNumber -> SerialNumber) -> Zone +adjustSerialNumber (Zone d soa l) f = Zone d soa' l + where + soa' = soa { sSerial = f (sSerial soa) } + +-- | Count the number of git commits made to the current branch. +serialNumberOffset :: IO SerialNumber +serialNumberOffset = fromIntegral . length . lines + <$> readProcess "git" ["log", "--pretty=%H"] + +-- | Write a Zone out to a to a file. +-- +-- The serial number in the Zone automatically has the serialNumberOffset +-- added to it. Also, just in case, the old serial number used in the zone +-- file is checked, and if it is somehow larger, its succ is used. +writeZoneFile :: Zone -> FilePath -> IO () +writeZoneFile z f = do + oldserial <- oldZoneFileSerialNumber f + offset <- serialNumberOffset + let z' = nextSerialNumber + (adjustSerialNumber z (+ offset)) + oldserial + createDirectoryIfMissing True (takeDirectory f) + writeFile f (genZoneFile z') + writeZonePropellorFile f z' + +removeZoneFile :: FilePath -> IO () +removeZoneFile f = do + nukeFile f + nukeFile (zonePropellorFile f) + +-- | Next to the zone file, is a ".propellor" file, which contains +-- the serialized Zone. This saves the bother of parsing +-- the horrible bind zone file format. +zonePropellorFile :: FilePath -> FilePath +zonePropellorFile f = f ++ ".propellor" + +oldZoneFileSerialNumber :: FilePath -> IO SerialNumber +oldZoneFileSerialNumber = maybe 0 (sSerial . zSOA) <$$> readZonePropellorFile + +writeZonePropellorFile :: FilePath -> Zone -> IO () +writeZonePropellorFile f z = writeFile (zonePropellorFile f) (show z) + +readZonePropellorFile :: FilePath -> IO (Maybe Zone) +readZonePropellorFile f = catchDefaultIO Nothing $ + readish <$> readFileStrict (zonePropellorFile f) + +-- | Generating a zone file. +genZoneFile :: Zone -> String +genZoneFile (Zone zdomain soa rs) = unlines $ + header : genSOA soa ++ map (genRecord zdomain) rs + where + header = com $ "BIND zone file for " ++ zdomain ++ ". Generated by propellor, do not edit." + +genRecord :: Domain -> (BindDomain, Record) -> String +genRecord zdomain (domain, record) = intercalate "\t" + [ domainHost zdomain domain + , "IN" + , rField record + , rValue record + ] + +genSOA :: SOA -> [String] +genSOA soa = + -- "@ IN SOA ns1.example.com. root (" + [ intercalate "\t" + [ dValue RootDomain + , "IN" + , "SOA" + , dValue (sDomain soa) + , "root" + , "(" + ] + , headerline sSerial "Serial" + , headerline sRefresh "Refresh" + , headerline sRetry "Retry" + , headerline sExpire "Expire" + , headerline sNegativeCacheTTL "Negative Cache TTL" + , inheader ")" + ] + where + headerline r comment = inheader $ show (r soa) ++ "\t\t" ++ com comment + inheader l = "\t\t\t" ++ l + +-- | Comment line in a zone file. +com :: String -> String +com s = "; " ++ s + +type WarningMessage = String + +-- | Generates a Zone for a particular Domain from the DNS properies of all +-- hosts that propellor knows about that are in that Domain. +genZone :: [Host] -> Domain -> SOA -> (Zone, [WarningMessage]) +genZone hosts zdomain soa = + let (warnings, zhosts) = partitionEithers $ concat $ map concat + [ map hostips inzdomain + , map hostrecords inzdomain + , map addcnames (M.elems m) + ] + in (Zone zdomain soa (nub zhosts), warnings) + where + m = hostAttrMap hosts + -- Known hosts with hostname located in the zone's domain. + inzdomain = M.elems $ M.filterWithKey (\hn _ -> inDomain zdomain $ AbsDomain $ hn) m + + -- Each host with a hostname located in the zdomain + -- should have 1 or more IPAddrs in its Attr. + -- + -- If a host lacks any IPAddr, it's probably a misconfiguration, + -- so warn. + hostips :: Attr -> [Either WarningMessage (BindDomain, Record)] + hostips attr + | null l = [Left $ "no IP address defined for host " ++ _hostname attr] + | otherwise = map Right l + where + l = zip (repeat $ AbsDomain $ _hostname attr) + (map Address $ getAddresses attr) + + -- Any host, whether its hostname is in the zdomain or not, + -- may have cnames which are in the zdomain. The cname may even be + -- the same as the root of the zdomain, which is a nice way to + -- specify IP addresses for a SOA record. + -- + -- Add Records for those.. But not actually, usually, cnames! + -- Why not? Well, using cnames doesn't allow doing some things, + -- including MX and round robin DNS, and certianly CNAMES + -- shouldn't be used in SOA records. + -- + -- We typically know the host's IPAddrs anyway. + -- So we can just use the IPAddrs. + addcnames :: Attr -> [Either WarningMessage (BindDomain, Record)] + addcnames attr = concatMap gen $ filter (inDomain zdomain) $ + mapMaybe getCNAME $ S.toList (_dns attr) + where + gen c = case getAddresses attr of + [] -> [ret (CNAME c)] + l -> map (ret . Address) l + where + ret record = Right (c, record) + + -- Adds any other DNS records for a host located in the zdomain. + hostrecords :: Attr -> [Either WarningMessage (BindDomain, Record)] + hostrecords attr = map Right l + where + l = zip (repeat $ AbsDomain $ _hostname attr) + (S.toList $ S.filter (\r -> isNothing (getIPAddr r) && isNothing (getCNAME r)) (_dns attr)) + +inDomain :: Domain -> BindDomain -> Bool +inDomain domain (AbsDomain d) = domain == d || ('.':domain) `isSuffixOf` d +inDomain _ _ = False -- can't tell, so assume not + +-- | Gets the hostname of the second domain, relative to the first domain, +-- suitable for using in a zone file. +domainHost :: Domain -> BindDomain -> String +domainHost _ (RelDomain d) = d +domainHost _ RootDomain = "@" +domainHost base (AbsDomain d) + | dotbase `isSuffixOf` d = take (length d - length dotbase) d + | base == d = "@" + | otherwise = d + where + dotbase = '.':base + diff --git a/src/Propellor/Property/Docker.hs b/src/Propellor/Property/Docker.hs new file mode 100644 index 00000000..09d7d6a4 --- /dev/null +++ b/src/Propellor/Property/Docker.hs @@ -0,0 +1,456 @@ +{-# LANGUAGE BangPatterns #-} + +-- | Docker support for propellor +-- +-- The existance of a docker container is just another Property of a system, +-- which propellor can set up. See config.hs for an example. + +module Propellor.Property.Docker where + +import Propellor +import Propellor.SimpleSh +import Propellor.Types.Attr +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Docker.Shim as Shim +import Utility.SafeCommand +import Utility.Path + +import Control.Concurrent.Async +import System.Posix.Directory +import System.Posix.Process +import Data.List +import Data.List.Utils + +-- | Configures docker with an authentication file, so that images can be +-- pushed to index.docker.io. +configured :: Property +configured = property "docker configured" go `requires` installed + where + go = withPrivData DockerAuthentication $ \cfg -> ensureProperty $ + "/root/.dockercfg" `File.hasContent` (lines cfg) + +installed :: Property +installed = Apt.installed ["docker.io"] + +-- | A short descriptive name for a container. +-- Should not contain whitespace or other unusual characters, +-- only [a-zA-Z0-9_-] are allowed +type ContainerName = String + +-- | Starts accumulating the properties of a Docker container. +-- +-- > container "web-server" "debian" +-- > & publish "80:80" +-- > & Apt.installed {"apache2"] +-- > & ... +container :: ContainerName -> Image -> Host +container cn image = Host [] (\_ -> attr) + where + attr = (newAttr (cn2hn cn)) { _dockerImage = Just image } + +cn2hn :: ContainerName -> HostName +cn2hn cn = cn ++ ".docker" + +-- | Ensures that a docker container is set up and running. The container +-- has its own Properties which are handled by running propellor +-- inside the container. +-- +-- Reverting this property ensures that the container is stopped and +-- removed. +docked + :: [Host] + -> ContainerName + -> RevertableProperty +docked hosts cn = RevertableProperty (go "docked" setup) (go "undocked" teardown) + where + go desc a = property (desc ++ " " ++ cn) $ do + hn <- getHostName + let cid = ContainerId hn cn + ensureProperties [findContainer hosts cid cn $ a cid] + + setup cid (Container image runparams) = + provisionContainer cid + `requires` + runningContainer cid image runparams + `requires` + installed + + teardown cid (Container image _runparams) = + combineProperties ("undocked " ++ fromContainerId cid) + [ stoppedContainer cid + , property ("cleaned up " ++ fromContainerId cid) $ + liftIO $ report <$> mapM id + [ removeContainer cid + , removeImage image + ] + ] + +findContainer + :: [Host] + -> ContainerId + -> ContainerName + -> (Container -> Property) + -> Property +findContainer hosts cid cn mk = case findHost hosts (cn2hn cn) of + Nothing -> cantfind + Just h -> maybe cantfind mk (mkContainer cid h) + where + cantfind = containerDesc cid $ property "" $ do + liftIO $ warningMessage $ + "missing definition for docker container \"" ++ cn2hn cn + return FailedChange + +mkContainer :: ContainerId -> Host -> Maybe Container +mkContainer cid@(ContainerId hn _cn) h = Container + <$> _dockerImage attr + <*> pure (map (\a -> a hn) (_dockerRunParams attr)) + where + attr = hostAttr h' + h' = h + -- expose propellor directory inside the container + & volume (localdir++":"++localdir) + -- name the container in a predictable way so we + -- and the user can easily find it later + & name (fromContainerId cid) + +-- | Causes *any* docker images that are not in use by running containers to +-- be deleted. And deletes any containers that propellor has set up +-- before that are not currently running. Does not delete any containers +-- that were not set up using propellor. +-- +-- Generally, should come after the properties for the desired containers. +garbageCollected :: Property +garbageCollected = propertyList "docker garbage collected" + [ gccontainers + , gcimages + ] + where + gccontainers = property "docker containers garbage collected" $ + liftIO $ report <$> (mapM removeContainer =<< listContainers AllContainers) + gcimages = property "docker images garbage collected" $ do + liftIO $ report <$> (mapM removeImage =<< listImages) + +data Container = Container Image [RunParam] + +-- | Parameters to pass to `docker run` when creating a container. +type RunParam = String + +-- | A docker image, that can be used to run a container. +type Image = String + +-- | Set custom dns server for container. +dns :: String -> Property +dns = runProp "dns" + +-- | Set container host name. +hostname :: String -> Property +hostname = runProp "hostname" + +-- | Set name for container. (Normally done automatically.) +name :: String -> Property +name = runProp "name" + +-- | Publish a container's port to the host +-- (format: ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort) +publish :: String -> Property +publish = runProp "publish" + +-- | Username or UID for container. +user :: String -> Property +user = runProp "user" + +-- | Mount a volume +-- Create a bind mount with: [host-dir]:[container-dir]:[rw|ro] +-- With just a directory, creates a volume in the container. +volume :: String -> Property +volume = runProp "volume" + +-- | Mount a volume from the specified container into the current +-- container. +volumes_from :: ContainerName -> Property +volumes_from cn = genProp "volumes-from" $ \hn -> + fromContainerId (ContainerId hn cn) + +-- | Work dir inside the container. +workdir :: String -> Property +workdir = runProp "workdir" + +-- | Memory limit for container. +--Format: , where unit = b, k, m or g +memory :: String -> Property +memory = runProp "memory" + +-- | Link with another container on the same host. +link :: ContainerName -> ContainerAlias -> Property +link linkwith calias = genProp "link" $ \hn -> + fromContainerId (ContainerId hn linkwith) ++ ":" ++ calias + +-- | A short alias for a linked container. +-- Each container has its own alias namespace. +type ContainerAlias = String + +-- | A container is identified by its name, and the host +-- on which it's deployed. +data ContainerId = ContainerId HostName ContainerName + deriving (Eq, Read, Show) + +-- | Two containers with the same ContainerIdent were started from +-- the same base image (possibly a different version though), and +-- with the same RunParams. +data ContainerIdent = ContainerIdent Image HostName ContainerName [RunParam] + deriving (Read, Show, Eq) + +ident2id :: ContainerIdent -> ContainerId +ident2id (ContainerIdent _ hn cn _) = ContainerId hn cn + +toContainerId :: String -> Maybe ContainerId +toContainerId s + | myContainerSuffix `isSuffixOf` s = case separate (== '.') (desuffix s) of + (cn, hn) + | null hn || null cn -> Nothing + | otherwise -> Just $ ContainerId hn cn + | otherwise = Nothing + where + desuffix = reverse . drop len . reverse + len = length myContainerSuffix + +fromContainerId :: ContainerId -> String +fromContainerId (ContainerId hn cn) = cn++"."++hn++myContainerSuffix + +containerHostName :: ContainerId -> HostName +containerHostName (ContainerId _ cn) = cn2hn cn + +myContainerSuffix :: String +myContainerSuffix = ".propellor" + +containerDesc :: ContainerId -> Property -> Property +containerDesc cid p = p `describe` desc + where + desc = "[" ++ fromContainerId cid ++ "] " ++ propertyDesc p + +runningContainer :: ContainerId -> Image -> [RunParam] -> Property +runningContainer cid@(ContainerId hn cn) image runps = containerDesc cid $ property "running" $ do + l <- liftIO $ listContainers RunningContainers + if cid `elem` l + then do + -- Check if the ident has changed; if so the + -- parameters of the container differ and it must + -- be restarted. + runningident <- liftIO $ getrunningident + if runningident == Just ident + then noChange + else do + void $ liftIO $ stopContainer cid + restartcontainer + else ifM (liftIO $ elem cid <$> listContainers AllContainers) + ( restartcontainer + , go image + ) + where + ident = ContainerIdent image hn cn runps + + restartcontainer = do + oldimage <- liftIO $ fromMaybe image <$> commitContainer cid + void $ liftIO $ removeContainer cid + go oldimage + + getrunningident :: IO (Maybe ContainerIdent) + getrunningident = simpleShClient (namedPipe cid) "cat" [propellorIdent] $ \rs -> do + let !v = extractident rs + return v + + extractident :: [Resp] -> Maybe ContainerIdent + extractident = headMaybe . catMaybes . map readish . catMaybes . map getStdout + + go img = do + liftIO $ do + clearProvisionedFlag cid + createDirectoryIfMissing True (takeDirectory $ identFile cid) + shim <- liftIO $ Shim.setup (localdir "propellor") (localdir shimdir cid) + liftIO $ writeFile (identFile cid) (show ident) + ensureProperty $ boolProperty "run" $ runContainer img + (runps ++ ["-i", "-d", "-t"]) + [shim, "--docker", fromContainerId cid] + +-- | Called when propellor is running inside a docker container. +-- The string should be the container's ContainerId. +-- +-- This process is effectively init inside the container. +-- It even needs to wait on zombie processes! +-- +-- Fork a thread to run the SimpleSh server in the background. +-- In the foreground, run an interactive bash (or sh) shell, +-- so that the user can interact with it when attached to the container. +-- +-- When the system reboots, docker restarts the container, and this is run +-- again. So, to make the necessary services get started on boot, this needs +-- to provision the container then. However, if the container is already +-- being provisioned by the calling propellor, it would be redundant and +-- problimatic to also provisoon it here. +-- +-- The solution is a flag file. If the flag file exists, then the container +-- was already provisioned. So, it must be a reboot, and time to provision +-- again. If the flag file doesn't exist, don't provision here. +chain :: String -> IO () +chain s = case toContainerId s of + Nothing -> error $ "Invalid ContainerId: " ++ s + Just cid -> do + changeWorkingDirectory localdir + writeFile propellorIdent . show =<< readIdentFile cid + -- Run boot provisioning before starting simpleSh, + -- to avoid ever provisioning twice at the same time. + whenM (checkProvisionedFlag cid) $ do + let shim = Shim.file (localdir "propellor") (localdir shimdir cid) + unlessM (boolSystem shim [Param "--continue", Param $ show $ Chain $ containerHostName cid]) $ + warningMessage "Boot provision failed!" + void $ async $ job reapzombies + void $ async $ job $ simpleSh $ namedPipe cid + job $ do + void $ tryIO $ ifM (inPath "bash") + ( boolSystem "bash" [Param "-l"] + , boolSystem "/bin/sh" [] + ) + putStrLn "Container is still running. Press ^P^Q to detach." + where + job = forever . void . tryIO + reapzombies = void $ getAnyProcessStatus True False + +-- | Once a container is running, propellor can be run inside +-- it to provision it. +-- +-- Note that there is a race here, between the simplesh +-- server starting up in the container, and this property +-- being run. So, retry connections to the client for up to +-- 1 minute. +provisionContainer :: ContainerId -> Property +provisionContainer cid = containerDesc cid $ property "provision" $ liftIO $ do + let shim = Shim.file (localdir "propellor") (localdir shimdir cid) + r <- simpleShClientRetry 60 (namedPipe cid) shim params (go Nothing) + when (r /= FailedChange) $ + setProvisionedFlag cid + return r + where + params = ["--continue", show $ Chain $ containerHostName cid] + + go lastline (v:rest) = case v of + StdoutLine s -> do + maybe noop putStrLn lastline + hFlush stdout + go (Just s) rest + StderrLine s -> do + maybe noop putStrLn lastline + hFlush stdout + hPutStrLn stderr s + hFlush stderr + go Nothing rest + Done -> ret lastline + go lastline [] = ret lastline + + ret lastline = pure $ fromMaybe FailedChange $ readish =<< lastline + +stopContainer :: ContainerId -> IO Bool +stopContainer cid = boolSystem dockercmd [Param "stop", Param $ fromContainerId cid ] + +stoppedContainer :: ContainerId -> Property +stoppedContainer cid = containerDesc cid $ property desc $ + ifM (liftIO $ elem cid <$> listContainers RunningContainers) + ( liftIO cleanup `after` ensureProperty + (boolProperty desc $ stopContainer cid) + , return NoChange + ) + where + desc = "stopped" + cleanup = do + nukeFile $ namedPipe cid + nukeFile $ identFile cid + removeDirectoryRecursive $ shimdir cid + clearProvisionedFlag cid + +removeContainer :: ContainerId -> IO Bool +removeContainer cid = catchBoolIO $ + snd <$> processTranscript dockercmd ["rm", fromContainerId cid ] Nothing + +removeImage :: Image -> IO Bool +removeImage image = catchBoolIO $ + snd <$> processTranscript dockercmd ["rmi", image ] Nothing + +runContainer :: Image -> [RunParam] -> [String] -> IO Bool +runContainer image ps cmd = boolSystem dockercmd $ map Param $ + "run" : (ps ++ image : cmd) + +commitContainer :: ContainerId -> IO (Maybe Image) +commitContainer cid = catchMaybeIO $ + takeWhile (/= '\n') + <$> readProcess dockercmd ["commit", fromContainerId cid] + +data ContainerFilter = RunningContainers | AllContainers + deriving (Eq) + +-- | Only lists propellor managed containers. +listContainers :: ContainerFilter -> IO [ContainerId] +listContainers status = + catMaybes . map toContainerId . concat . map (split ",") + . catMaybes . map (lastMaybe . words) . lines + <$> readProcess dockercmd ps + where + ps + | status == AllContainers = baseps ++ ["--all"] + | otherwise = baseps + baseps = ["ps", "--no-trunc"] + +listImages :: IO [Image] +listImages = lines <$> readProcess dockercmd ["images", "--all", "--quiet"] + +runProp :: String -> RunParam -> Property +runProp field val = pureAttrProperty (param) $ \attr -> + attr { _dockerRunParams = _dockerRunParams attr ++ [\_ -> "--"++param] } + where + param = field++"="++val + +genProp :: String -> (HostName -> RunParam) -> Property +genProp field mkval = pureAttrProperty field $ \attr -> + attr { _dockerRunParams = _dockerRunParams attr ++ [\hn -> "--"++field++"=" ++ mkval hn] } + +-- | The ContainerIdent of a container is written to +-- /.propellor-ident inside it. This can be checked to see if +-- the container has the same ident later. +propellorIdent :: FilePath +propellorIdent = "/.propellor-ident" + +-- | Named pipe used for communication with the container. +namedPipe :: ContainerId -> FilePath +namedPipe cid = "docker" fromContainerId cid + +provisionedFlag :: ContainerId -> FilePath +provisionedFlag cid = "docker" fromContainerId cid ++ ".provisioned" + +clearProvisionedFlag :: ContainerId -> IO () +clearProvisionedFlag = nukeFile . provisionedFlag + +setProvisionedFlag :: ContainerId -> IO () +setProvisionedFlag cid = do + createDirectoryIfMissing True (takeDirectory (provisionedFlag cid)) + writeFile (provisionedFlag cid) "1" + +checkProvisionedFlag :: ContainerId -> IO Bool +checkProvisionedFlag = doesFileExist . provisionedFlag + +shimdir :: ContainerId -> FilePath +shimdir cid = "docker" fromContainerId cid ++ ".shim" + +identFile :: ContainerId -> FilePath +identFile cid = "docker" fromContainerId cid ++ ".ident" + +readIdentFile :: ContainerId -> IO ContainerIdent +readIdentFile cid = fromMaybe (error "bad ident in identFile") + . readish <$> readFile (identFile cid) + +dockercmd :: String +dockercmd = "docker.io" + +report :: [Bool] -> Result +report rmed + | or rmed = MadeChange + | otherwise = NoChange + diff --git a/src/Propellor/Property/Docker/Shim.hs b/src/Propellor/Property/Docker/Shim.hs new file mode 100644 index 00000000..c2f35d0c --- /dev/null +++ b/src/Propellor/Property/Docker/Shim.hs @@ -0,0 +1,61 @@ +-- | Support for running propellor, as built outside a docker container, +-- inside the container. +-- +-- Note: This is currently Debian specific, due to glibcLibs. + +module Propellor.Property.Docker.Shim (setup, cleanEnv, file) where + +import Propellor +import Utility.LinuxMkLibs +import Utility.SafeCommand +import Utility.Path +import Utility.FileMode + +import Data.List +import System.Posix.Files + +-- | Sets up a shimmed version of the program, in a directory, and +-- returns its path. +setup :: FilePath -> FilePath -> IO FilePath +setup propellorbin dest = do + createDirectoryIfMissing True dest + + libs <- parseLdd <$> readProcess "ldd" [propellorbin] + glibclibs <- glibcLibs + let libs' = nub $ libs ++ glibclibs + libdirs <- map (dest ++) . nub . catMaybes + <$> mapM (installLib installFile dest) libs' + + let linker = (dest ++) $ + fromMaybe (error "cannot find ld-linux linker") $ + headMaybe $ filter ("ld-linux" `isInfixOf`) libs' + let gconvdir = (dest ++) $ parentDir $ + fromMaybe (error "cannot find gconv directory") $ + headMaybe $ filter ("/gconv/" `isInfixOf`) glibclibs + let linkerparams = ["--library-path", intercalate ":" libdirs ] + let shim = file propellorbin dest + writeFile shim $ unlines + [ "#!/bin/sh" + , "GCONV_PATH=" ++ shellEscape gconvdir + , "export GCONV_PATH" + , "exec " ++ unwords (map shellEscape $ linker : linkerparams) ++ + " " ++ shellEscape propellorbin ++ " \"$@\"" + ] + modifyFileMode shim (addModes executeModes) + return shim + +cleanEnv :: IO () +cleanEnv = void $ unsetEnv "GCONV_PATH" + +file :: FilePath -> FilePath -> FilePath +file propellorbin dest = dest takeFileName propellorbin + +installFile :: FilePath -> FilePath -> IO () +installFile top f = do + createDirectoryIfMissing True destdir + nukeFile dest + createLink f dest `catchIO` (const copy) + where + copy = void $ boolSystem "cp" [Param "-a", Param f, Param dest] + destdir = inTop top $ parentDir f + dest = inTop top f diff --git a/src/Propellor/Property/File.hs b/src/Propellor/Property/File.hs new file mode 100644 index 00000000..0b060177 --- /dev/null +++ b/src/Propellor/Property/File.hs @@ -0,0 +1,94 @@ +module Propellor.Property.File where + +import Propellor +import Utility.FileMode + +import System.Posix.Files +import System.PosixCompat.Types + +type Line = String + +-- | Replaces all the content of a file. +hasContent :: FilePath -> [Line] -> Property +f `hasContent` newcontent = fileProperty ("replace " ++ f) + (\_oldcontent -> newcontent) f + +-- | Ensures a file has contents that comes from PrivData. +-- +-- The file's permissions are preserved if the file already existed. +-- Otherwise, they're set to 600. +hasPrivContent :: FilePath -> Property +hasPrivContent f = property desc $ withPrivData (PrivFile f) $ \privcontent -> + ensureProperty $ fileProperty' writeFileProtectedĀ desc + (\_oldcontent -> lines privcontent) f + where + desc = "privcontent " ++ f + +-- | Leaves the file world-readable. +hasPrivContentExposed :: FilePath -> Property +hasPrivContentExposed f = hasPrivContent f `onChange` + mode f (combineModes (ownerWriteMode:readModes)) + +-- | Ensures that a line is present in a file, adding it to the end if not. +containsLine :: FilePath -> Line -> Property +f `containsLine` l = f `containsLines` [l] + +containsLines :: FilePath -> [Line] -> Property +f `containsLines` l = fileProperty (f ++ " contains:" ++ show l) go f + where + go ls + | all (`elem` ls) l = ls + | otherwise = ls++l + +-- | Ensures that a line is not present in a file. +-- Note that the file is ensured to exist, so if it doesn't, an empty +-- file will be written. +lacksLine :: FilePath -> Line -> Property +f `lacksLine` l = fileProperty (f ++ " remove: " ++ l) (filter (/= l)) f + +-- | Removes a file. Does not remove symlinks or non-plain-files. +notPresent :: FilePath -> Property +notPresent f = check (doesFileExist f) $ property (f ++ " not present") $ + makeChange $ nukeFile f + +fileProperty :: Desc -> ([Line] -> [Line]) -> FilePath -> Property +fileProperty = fileProperty' writeFile +fileProperty' :: (FilePath -> String -> IO ()) -> Desc -> ([Line] -> [Line]) -> FilePath -> Property +fileProperty' writer desc a f = property desc $ go =<< liftIO (doesFileExist f) + where + go True = do + ls <- liftIO $ lines <$> readFile f + let ls' = a ls + if ls' == ls + then noChange + else makeChange $ viaTmp updatefile f (unlines ls') + go False = makeChange $ writer f (unlines $ a []) + + -- viaTmp makes the temp file mode 600. + -- Replicate the original file's owner and mode. + updatefile f' content = do + writer f' content + s <- getFileStatus f + setFileMode f' (fileMode s) + setOwnerAndGroup f' (fileOwner s) (fileGroup s) + +-- | Ensures a directory exists. +dirExists :: FilePath -> Property +dirExists d = check (not <$> doesDirectoryExist d) $ property (d ++ " exists") $ + makeChange $ createDirectoryIfMissing True d + +-- | Ensures that a file/dir has the specified owner and group. +ownerGroup :: FilePath -> UserName -> GroupName -> Property +ownerGroup f owner group = property (f ++ " owner " ++ og) $ do + r <- ensureProperty $ cmdProperty "chown" [og, f] + if r == FailedChange + then return r + else noChange + where + og = owner ++ ":" ++ group + +-- | Ensures that a file/dir has the specfied mode. +mode :: FilePath -> FileMode -> Property +mode f v = property (f ++ " mode " ++ show v) $ do + liftIO $ modifyFileMode f (\_old -> v) + noChange diff --git a/src/Propellor/Property/Git.hs b/src/Propellor/Property/Git.hs new file mode 100644 index 00000000..e5df7e48 --- /dev/null +++ b/src/Propellor/Property/Git.hs @@ -0,0 +1,93 @@ +module Propellor.Property.Git where + +import Propellor +import Propellor.Property.File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service +import Utility.SafeCommand + +import Data.List + +-- | Exports all git repos in a directory (that user nobody can read) +-- using git-daemon, run from inetd. +-- +-- Note that reverting this property does not remove or stop inetd. +daemonRunning :: FilePath -> RevertableProperty +daemonRunning exportdir = RevertableProperty setup unsetup + where + setup = containsLine conf (mkl "tcp4") + `requires` + containsLine conf (mkl "tcp6") + `requires` + dirExists exportdir + `requires` + Apt.serviceInstalledRunning "openbsd-inetd" + `onChange` + Service.running "openbsd-inetd" + `describe` ("git-daemon exporting " ++ exportdir) + unsetup = lacksLine conf (mkl "tcp4") + `requires` + lacksLine conf (mkl "tcp6") + `onChange` + Service.reloaded "openbsd-inetd" + + conf = "/etc/inetd.conf" + + mkl tcpv = intercalate "\t" + [ "git" + , "stream" + , tcpv + , "nowait" + , "nobody" + , "/usr/bin/git" + , "git" + , "daemon" + , "--inetd" + , "--export-all" + , "--base-path=" ++ exportdir + , exportdir + ] + +installed :: Property +installed = Apt.installed ["git"] + +type RepoUrl = String + +type Branch = String + +-- | Specified git repository is cloned to the specified directory. +-- +-- If the firectory exists with some other content, it will be recursively +-- deleted. +-- +-- A branch can be specified, to check out. +cloned :: UserName -> RepoUrl -> FilePath -> Maybe Branch -> Property +cloned owner url dir mbranch = check originurl (property desc checkout) + `requires` installed + where + desc = "git cloned " ++ url ++ " to " ++ dir + gitconfig = dir ".git/config" + originurl = ifM (doesFileExist gitconfig) + ( do + v <- catchDefaultIO Nothing $ headMaybe . lines <$> + readProcess "git" ["config", "--file", gitconfig, "remote.origin.url"] + return (v /= Just url) + , return True + ) + checkout = do + liftIO $ do + whenM (doesDirectoryExist dir) $ + removeDirectoryRecursive dir + createDirectoryIfMissing True (takeDirectory dir) + ensureProperty $ userScriptProperty owner $ catMaybes + -- The mbranch + -- In case this repo is exposted via the web, + -- although the hook to do this ongoing is not + -- installed here. + , Just "git update-server-info" + ] diff --git a/src/Propellor/Property/Gpg.hs b/src/Propellor/Property/Gpg.hs new file mode 100644 index 00000000..64ea9fea --- /dev/null +++ b/src/Propellor/Property/Gpg.hs @@ -0,0 +1,41 @@ +module Propellor.Property.Gpg where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import Utility.FileSystemEncoding + +import System.PosixCompat + +installed :: Property +installed = Apt.installed ["gnupg"] + +-- | Sets up a user with a gpg key from the privdata. +-- +-- Note that if a secret key is exported using gpg -a --export-secret-key, +-- the public key is also included. Or just a public key could be +-- exported, and this would set it up just as well. +-- +-- Recommend only using this for low-value dedicated role keys. +-- No attempt has been made to scrub the key out of memory once it's used. +-- +-- The GpgKeyId does not have to be a numeric id; it can just as easily +-- be a description of the key. +keyImported :: GpgKeyId -> UserName -> Property +keyImported keyid user = flagFile' (property desc go) genflag + `requires` installed + where + desc = user ++ " has gpg key " ++ show keyid + genflag = do + d <- dotDir user + return $ d ".propellor-imported-keyid-" ++ keyid + go = withPrivData (GpgKey keyid) $ \key -> makeChange $ + withHandle StdinHandle createProcessSuccess + (proc "su" ["-c", "gpg --import", user]) $ \h -> do + fileEncoding h + hPutStr h key + hClose h + +dotDir :: UserName -> IO FilePath +dotDir user = do + home <- homeDirectory <$> getUserEntryForName user + return $ home ".gnupg" diff --git a/src/Propellor/Property/Hostname.hs b/src/Propellor/Property/Hostname.hs new file mode 100644 index 00000000..031abb9d --- /dev/null +++ b/src/Propellor/Property/Hostname.hs @@ -0,0 +1,33 @@ +module Propellor.Property.Hostname where + +import Propellor +import qualified Propellor.Property.File as File + +-- | Ensures that the hostname is set to the HostAttr value. +-- Configures /etc/hostname and the current hostname. +-- +-- A FQDN also configures /etc/hosts, with an entry for 127.0.1.1, which is +-- standard at least on Debian to set the FDQN (127.0.0.1 is localhost). +sane :: Property +sane = property ("sane hostname") (ensureProperty . setTo =<< getHostName) + +setTo :: HostName -> Property +setTo hn = combineProperties desc go + `onChange` cmdProperty "hostname" [basehost] + where + desc = "hostname " ++ hn + (basehost, domain) = separate (== '.') hn + + go = catMaybes + [ Just $ "/etc/hostname" `File.hasContent` [basehost] + , if null domain + then Nothing + else Just $ File.filePropertyĀ desc + addhostline "/etc/hosts" + ] + + hostip = "127.0.1.1" + hostline = hostip ++ "\t" ++ hn ++ " " ++ basehost + + addhostline ls = hostline : filter (not . hashostip) ls + hashostip l = headMaybe (words l) == Just hostip diff --git a/src/Propellor/Property/Network.hs b/src/Propellor/Property/Network.hs new file mode 100644 index 00000000..6009778a --- /dev/null +++ b/src/Propellor/Property/Network.hs @@ -0,0 +1,30 @@ +module Propellor.Property.Network where + +import Propellor +import Propellor.Property.File + +interfaces :: FilePath +interfaces = "/etc/network/interfaces" + +-- | 6to4 ipv6 connection, should work anywhere +ipv6to4 :: Property +ipv6to4 = fileProperty "ipv6to4" go interfaces + `onChange` ifUp "sit0" + where + go ls + | all (`elem` ls) stanza = ls + | otherwise = ls ++ stanza + stanza = + [ "# Automatically added by propeller" + , "iface sit0 inet6 static" + , "\taddress 2002:5044:5531::1" + , "\tnetmask 64" + , "\tgateway ::192.88.99.1" + , "auto sit0" + , "# End automatically added by propeller" + ] + +type Interface = String + +ifUp :: Interface -> Property +ifUp iface = cmdProperty "ifup" [iface] diff --git a/src/Propellor/Property/Obnam.hs b/src/Propellor/Property/Obnam.hs new file mode 100644 index 00000000..32374b57 --- /dev/null +++ b/src/Propellor/Property/Obnam.hs @@ -0,0 +1,155 @@ +module Propellor.Property.Obnam where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Cron as Cron +import Utility.SafeCommand + +import Data.List + +type ObnamParam = String + +-- | An obnam repository can be used by multiple clients. Obnam uses +-- locking to allow only one client to write at a time. Since stale lock +-- files can prevent backups from happening, it's more robust, if you know +-- a repository has only one client, to force the lock before starting a +-- backup. Using OnlyClient allows propellor to do so when running obnam. +data NumClients = OnlyClient | MultipleClients + deriving (Eq) + +-- | Installs a cron job that causes a given directory to be backed +-- up, by running obnam with some parameters. +-- +-- If the directory does not exist, or exists but is completely empty, +-- this Property will immediately restore it from an existing backup. +-- +-- So, this property can be used to deploy a directory of content +-- to a host, while also ensuring any changes made to it get backed up. +-- And since Obnam encrypts, just make this property depend on a gpg +-- key, and tell obnam to use the key, and your data will be backed +-- up securely. For example: +-- +-- > & Obnam.backup "/srv/git" "33 3 * * *" +-- > [ "--repository=sftp://2318@usw-s002.rsync.net/~/mygitrepos.obnam" +-- > , "--encrypt-with=1B169BE1" +-- > ] Obnam.OnlyClient +-- > `requires` Gpg.keyImported "1B169BE1" "root" +-- > `requires` Ssh.keyImported SshRsa "root" +-- +-- How awesome is that? +backup :: FilePath -> Cron.CronTimes -> [ObnamParam] -> NumClients -> Property +backup dir crontimes params numclients = cronjob `describe` desc + `requires` restored dir params + where + desc = dir ++ " backed up by obnam" + cronjob = Cron.niceJob ("obnam_backup" ++ dir) crontimes "root" "/" $ + intercalate ";" $ catMaybes + [ if numclients == OnlyClient + then Just $ unwords $ + [ "obnam" + , "force-lock" + ] ++ map shellEscape params + else Nothing + , Just $ unwords $ + [ "obnam" + , "backup" + , shellEscape dir + ] ++ map shellEscape params + ] + +-- | Restores a directory from an obnam backup. +-- +-- Only does anything if the directory does not exist, or exists, +-- but is completely empty. +-- +-- The restore is performed atomically; restoring to a temp directory +-- and then moving it to the directory. +restored :: FilePath -> [ObnamParam] -> Property +restored dir params = property (dir ++ " restored by obnam") go + `requires` installed + where + go = ifM (liftIO needsRestore) + ( do + warningMessage $ dir ++ " is empty/missing; restoring from backup ..." + liftIO restore + , noChange + ) + + needsRestore = null <$> catchDefaultIO [] (dirContents dir) + + restore = withTmpDirIn (takeDirectory dir) "obnam-restore" $ \tmpdir -> do + ok <- boolSystem "obnam" $ + [ Param "restore" + , Param "--to" + , Param tmpdir + ] ++ map Param params + let restoreddir = tmpdir ++ "/" ++ dir + ifM (pure ok <&&> doesDirectoryExist restoreddir) + ( do + void $ tryIO $ removeDirectory dir + renameDirectory restoreddir dir + return MadeChange + , return FailedChange + ) + +installed :: Property +installed = Apt.installed ["obnam"] + +-- | Ensures that a recent version of obnam gets installed. +-- +-- Only does anything for Debian Stable. +latestVersion :: Property +latestVersion = withOS "obnam latest version" $ \o -> case o of + (Just (System (Debian suite) _)) | isStable suite -> ensureProperty $ + Apt.setSourcesListD (sources suite) "obnam" + `requires` toProp (Apt.trustsKey key) + _ -> noChange + where + sources suite = + [ "deb http://code.liw.fi/debian " ++ Apt.showSuite suite ++ " main" + ] + -- gpg key used by the code.liw.fi repository. + key = Apt.AptKey "obnam" $ unlines + [ "-----BEGIN PGP PUBLIC KEY BLOCK-----" + , "Version: GnuPG v1.4.9 (GNU/Linux)" + , "" + , "mQGiBEfzuTgRBACcVNG/H6QJqLx5qiQs2zmPe6D6BWOWHfgNgG4IWzNstm21YDxb" + , "KqwFG0gxcnZJGHkXAhkSfqTokYd0lc5eBemcA1pkceNjzMEX8wwiZ810HzJD4eEH" + , "sjoWR8+qKrZeixzZqReAfqztcXoBGKQ0u1R1vpg1txUa75OM4BUqaUbsmwCgmS4x" + , "DjMxSaUSPuu6vQ7ZGZBXSP0D/RQw8DBHMfsv3DiaqFqk8tkuUkpMFPIekHidSHlO" + , "EACbncqbbyHksyCpFNVNcQIDHrOLjOZK9BAXkSd8I3ww7U+nLdDcCblrW8CZnJtm" + , "ZYrxfaXaHZ/It9/RCAsQ+c8xtmyUPjsf//4Vf8olxNQHzgBSe5/LJRi4Vd53he+K" + , "YP4LA/9IZbjvVmm8+8Y0pQrTHlI6nTImtzdBXHc4+T3lLBj9XODHLozC2kSBOQky" + , "q/EisTITHTXL8vYg4NsKm5RTbPAuBwdtxcny8CXfOqKtGOdrebmKotGllTozzdPv" + , "9p53cuce6oJ2oMUodc074JOGTWwDSgLiJX4nViGcU1wy/vtQnrQkY29kZS5saXcu" + , "ZmkgYXJjaGl2ZSBrZXkgPGxpd0BsaXcuZmk+iGAEExECACAFAkfzuTgCGwMGCwkI" + , "BwMCBBUCCAMEFgIDAQIeAQIXgAAKCRBG53tJR95LscKrAJ0ZtKqa2x6Kplwa2mzx" + , "ItImbIGMJACdETqofDYzUN91yLAFlOnxAyrE+UyIRgQQEQIABgUCSFd5GgAKCRAf" + , "u5W/LZrMjqr8AJ4xPVHpW8ZNlgMwDSVb075RnA2DiACgg2SR69jAHFQOWV6xfLRr" + , "vh0bLKGJAhwEEAEIAAYFAktEyIwACgkQ61zh116FEfm7Lg//Wiy3TjWAk8YHUddv" + , "zOioYzCxQ985GsVhJGAVPqSGOc9vfTWBJZ8J3l0NnYTRpEGucmbF9G+mAt9iGXu6" + , "7yZkxyFdvbo7EDsqMU1wLOM6PiU+Un63MKlbTNmFn7OKE8aXPRAFgcyUO/qjdqoD" + , "sa9FgU5Z0f60m9qah6BPXH6IzMLHYoiP7t8rCBIwLgyl3w2w+Fjt1DFpbW9Kb7jz" + , "i8jFvC8jPmxV8xh2OSgVZyNk4qg6hIV8GVQY7AJt8OurZSckgQd7ifHK9JTGohtF" + , "tXCiqeDEvnMF4A9HI/TcXJBzonZ8ds1JCq42nSSKmL+8TyjtUSD/xHygazuc0CK0" + , "hFnQWBub60IfyV6F0oTagJ8cmARv2sezHAeHDkzPHE8RdjgktazH1eJrA4LheEd6" + , "KeSnVtYWpw8dgMv5PleFyQiAj/t3C/N50fd15tUyfnH15G7nFjMQV2Yx35uwSxOj" + , "376OWnDN/YGTNk283XXULbyVJYR8Q2unso20XQ94yQ2A5EpHHPrHoLxrL/ydM08d" + , "nvKstLZIZtal1seiMkymtlSiGz25A5oqsclwS6VZCKdWA8HO/wlElOMcaHyl6Y1y" + , "gYP7y9O5yFYKFOrCH0nFjJbwmkRiBLsxuuWsYgJigVGq/atSrtawkHdshpCw0HCY" + , "N/RFcWkJ864BdsO0C0sDzueNkQO5Ag0ER/O5RBAIAJiwPH9tyJTgXcC2Y4XWboOq" + , "rx5CkOnr5b45oS9cK2eIJ8TKxE3XgKLxUr3mIH0QR2kZgDOwNl0WY+7/CXjn+Spn" + , "BokPg54rafEUePodGpGdUXdgrHhAMHYjh8fXFJ1SlQcg46/zc1wDI7jBCkGrK3V8" + , "5cXDqwTFTN5LcjoSRWeM4Voa6pEfDdL3rMlnOw9R9gDHRBBb6CDSjWXqM86pR889" + , "5QrR0SDwiJNrMoyxSjMXFKGBQAsYHJ82myZrlbuZbroZjVp5Uh7eB1ZiPljNVtcr" + , "sksACIWBCo1rvLzrPXsLYOeV3cDDtYAkSwGfuzC1Etbe+qgfIroFTOqdefMw4s8A" + , "AwUH/0KLXm4MS54QQspg3evu4Q4U/E8Hem5/FqB0GhBCitQ4rUsucKyY8/ItpUn5" + , "ismLE60bQqka+Mzd/Zw18TCTzImv0ozAaZ2sNtBado7f6jcC8EDfY5zzK1ukcsAr" + , "Qc5hdLHYuTQW5KpA6fKaW969OUzIwPbdVaCOLOBpxKC6N6iBspQYd6uiQtLw6EUO" + , "50oQqUiJABf0eOocvdw5e2KQQpuC3205+VMYtyl4w3pdJihK8NK0AikGXzDVsbQt" + , "l8kmB5ZrN4WIKhMke1FxbqQC5Q3XATvYRzpzzisZb/HYGNti8W6du5EUwJ0D2NRh" + , "cu+twocOzW0VKfmrDApfifJ9OsSISQQYEQIACQUCR/O5RAIbDAAKCRBG53tJR95L" + , "seQOAJ95KUyzjRjdYgZkDC69Mgu25L86UACdGduINUaRly43ag4kwUXxpqswBBM=" + , "=i2c3" + , "-----END PGP PUBLIC KEY BLOCK-----" + ] diff --git a/src/Propellor/Property/OpenId.hs b/src/Propellor/Property/OpenId.hs new file mode 100644 index 00000000..051d6425 --- /dev/null +++ b/src/Propellor/Property/OpenId.hs @@ -0,0 +1,29 @@ +module Propellor.Property.OpenId where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Service as Service + +import Data.List + +providerFor :: [UserName] -> String -> Property +providerFor users baseurl = propertyList desc $ + [ Apt.serviceInstalledRunning "apache2" + , Apt.installed ["simpleid"] + `onChange` Service.restarted "apache2" + , File.fileProperty (desc ++ " configured") + (map setbaseurl) "/etc/simpleid/config.inc" + ] ++ map identfile users + where + url = "http://"++baseurl++"/simpleid" + desc = "openid provider " ++ url + setbaseurl l + | "SIMPLEID_BASE_URL" `isInfixOf` l = + "define('SIMPLEID_BASE_URL', '"++url++"');" + | otherwise = l + + -- the identitites directory controls access, so open up + -- file mode + identfile u = File.hasPrivContentExposed $ + concat $ [ "/var/lib/simpleid/identities/", u, ".identity" ] diff --git a/src/Propellor/Property/Postfix.hs b/src/Propellor/Property/Postfix.hs new file mode 100644 index 00000000..9fa4a2c3 --- /dev/null +++ b/src/Propellor/Property/Postfix.hs @@ -0,0 +1,25 @@ +module Propellor.Property.Postfix where + +import Propellor +import qualified Propellor.Property.Apt as Apt + +installed :: Property +installed = Apt.serviceInstalledRunning "postfix" + +-- | Configures postfix as a satellite system, which +-- relats all mail through a relay host, which defaults to smtp.domain. +-- +-- The smarthost may refuse to relay mail on to other domains, without +-- futher coniguration/keys. But this should be enough to get cron job +-- mail flowing to a place where it will be seen. +satellite :: Property +satellite = setup `requires` installed + where + setup = trivial $ property "postfix satellite system" $ do + hn <- getHostName + ensureProperty $ Apt.reConfigure "postfix" + [ ("postfix/main_mailer_type", "select", "Satellite system") + , ("postfix/root_address", "string", "root") + , ("postfix/destinations", "string", " ") + , ("postfix/mailname", "string", hn) + ] diff --git a/src/Propellor/Property/Reboot.hs b/src/Propellor/Property/Reboot.hs new file mode 100644 index 00000000..25e53159 --- /dev/null +++ b/src/Propellor/Property/Reboot.hs @@ -0,0 +1,7 @@ +module Propellor.Property.Reboot where + +import Propellor + +now :: Property +now = cmdProperty "reboot" [] + `describe` "reboot now" diff --git a/src/Propellor/Property/Scheduled.hs b/src/Propellor/Property/Scheduled.hs new file mode 100644 index 00000000..f2911e50 --- /dev/null +++ b/src/Propellor/Property/Scheduled.hs @@ -0,0 +1,67 @@ +module Propellor.Property.Scheduled + ( period + , periodParse + , Recurrance(..) + , WeekDay + , MonthDay + , YearDay + ) where + +import Propellor +import Utility.Scheduled + +import Data.Time.Clock +import Data.Time.LocalTime +import qualified Data.Map as M + +-- | Makes a Property only be checked every so often. +-- +-- This uses the description of the Property to keep track of when it was +-- last run. +period :: Property -> Recurrance -> Property +period prop recurrance = flip describe desc $ adjustProperty prop $ \satisfy -> do + lasttime <- liftIO $ getLastChecked (propertyDesc prop) + nexttime <- liftIO $ fmap startTime <$> nextTime schedule lasttime + t <- liftIO localNow + if Just t >= nexttime + then do + r <- satisfy + liftIO $ setLastChecked t (propertyDesc prop) + return r + else noChange + where + schedule = Schedule recurrance AnyTime + desc = propertyDesc prop ++ " (period " ++ fromRecurrance recurrance ++ ")" + +-- | Like period, but parse a human-friendly string. +periodParse :: Property -> String -> Property +periodParse prop s = case toRecurrance s of + Just recurrance -> period prop recurrance + Nothing -> property "periodParse" $ do + liftIO $ warningMessage $ "failed periodParse: " ++ s + noChange + +lastCheckedFile :: FilePath +lastCheckedFile = localdir ".lastchecked" + +getLastChecked :: Desc -> IO (Maybe LocalTime) +getLastChecked desc = M.lookup desc <$> readLastChecked + +localNow :: IO LocalTime +localNow = do + now <- getCurrentTime + tz <- getTimeZone now + return $ utcToLocalTime tz now + +setLastChecked :: LocalTime -> Desc -> IO () +setLastChecked time desc = do + m <- readLastChecked + writeLastChecked (M.insert desc time m) + +readLastChecked :: IO (M.Map Desc LocalTime) +readLastChecked = fromMaybe M.empty <$> catchDefaultIO Nothing go + where + go = readish <$> readFileStrict lastCheckedFile + +writeLastChecked :: M.Map Desc LocalTime -> IO () +writeLastChecked = writeFile lastCheckedFile . show diff --git a/src/Propellor/Property/Service.hs b/src/Propellor/Property/Service.hs new file mode 100644 index 00000000..14e769d0 --- /dev/null +++ b/src/Propellor/Property/Service.hs @@ -0,0 +1,31 @@ +module Propellor.Property.Service where + +import Propellor +import Utility.SafeCommand + +type ServiceName = String + +-- | Ensures that a service is running. Does not ensure that +-- any package providing that service is installed. See +-- Apt.serviceInstalledRunning +-- +-- Note that due to the general poor state of init scripts, the best +-- we can do is try to start the service, and if it fails, assume +-- this means it's already running. +running :: ServiceName -> Property +running svc = property ("running " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " start >/dev/null 2>&1 || true"] + return NoChange + +restarted :: ServiceName -> Property +restarted svc = property ("restarted " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " restart >/dev/null 2>&1 || true"] + return NoChange + +reloaded :: ServiceName -> Property +reloaded svc = property ("reloaded " ++ svc) $ do + void $ ensureProperty $ + scriptProperty ["service " ++ shellEscape svc ++ " reload >/dev/null 2>&1 || true"] + return NoChange diff --git a/src/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs b/src/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs new file mode 100644 index 00000000..677aa760 --- /dev/null +++ b/src/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs @@ -0,0 +1,57 @@ +module Propellor.Property.SiteSpecific.GitAnnexBuilder where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.User as User +import qualified Propellor.Property.Cron as Cron +import Propellor.Property.Cron (CronTimes) + +builduser :: UserName +builduser = "builder" + +homedir :: FilePath +homedir = "/home/builder" + +gitbuilderdir :: FilePath +gitbuilderdir = homedir "gitbuilder" + +builddir :: FilePath +builddir = gitbuilderdir "build" + +builder :: Architecture -> CronTimes -> Bool -> Property +builder arch crontimes rsyncupload = combineProperties "gitannexbuilder" + [ Apt.stdSourcesList Unstable + , Apt.buildDep ["git-annex"] + , Apt.installed ["git", "rsync", "moreutils", "ca-certificates", + "liblockfile-simple-perl", "cabal-install", "vim", "less"] + , Apt.serviceInstalledRunning "cron" + , User.accountFor builduser + , check (not <$> doesDirectoryExist gitbuilderdir) $ userScriptProperty builduser + [ "git clone git://git.kitenet.net/gitannexbuilder " ++ gitbuilderdir + , "cd " ++ gitbuilderdir + , "git checkout " ++ arch + ] + `describe` "gitbuilder setup" + , check (not <$> doesDirectoryExist builddir) $ userScriptProperty builduser + [ "git clone git://git-annex.branchable.com/ " ++ builddir + ] + , "git-annex source build deps installed" ==> Apt.buildDepIn builddir + , Cron.niceJob "gitannexbuilder" crontimes builduser gitbuilderdir "git pull ; ./autobuild" + -- The builduser account does not have a password set, + -- instead use the password privdata to hold the rsync server + -- password used to upload the built image. + , property "rsync password" $ do + let f = homedir "rsyncpassword" + if rsyncupload + then withPrivData (Password builduser) $ \p -> do + oldp <- liftIO $ catchDefaultIO "" $ + readFileStrict f + if p /= oldp + then makeChange $ writeFile f p + else noChange + else do + ifM (liftIO $ doesFileExist f) + ( noChange + , makeChange $ writeFile f "no password configured" + ) + ] diff --git a/src/Propellor/Property/SiteSpecific/GitHome.hs b/src/Propellor/Property/SiteSpecific/GitHome.hs new file mode 100644 index 00000000..6ed02146 --- /dev/null +++ b/src/Propellor/Property/SiteSpecific/GitHome.hs @@ -0,0 +1,34 @@ +module Propellor.Property.SiteSpecific.GitHome where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import Propellor.Property.User +import Utility.SafeCommand + +-- | Clones Joey Hess's git home directory, and runs its fixups script. +installedFor :: UserName -> Property +installedFor user = check (not <$> hasGitDir user) $ + property ("githome " ++ user) (go =<< liftIO (homedir user)) + `requires` Apt.installed ["git"] + where + go home = do + let tmpdir = home "githome" + ensureProperty $ combineProperties "githome setup" + [ userScriptProperty user ["git clone " ++ url ++ " " ++ tmpdir] + , property "moveout" $ makeChange $ void $ + moveout tmpdir home + , property "rmdir" $ makeChange $ void $ + catchMaybeIO $ removeDirectory tmpdir + , userScriptProperty user ["rm -rf .aptitude/ .bashrc .profile; bin/mr checkout; bin/fixups"] + ] + moveout tmpdir home = do + fs <- dirContents tmpdir + forM fs $ \f -> boolSystem "mv" [File f, File home] + +url :: String +url = "git://git.kitenet.net/joey/home" + +hasGitDir :: UserName -> IO Bool +hasGitDir user = go =<< homedir user + where + go home = doesDirectoryExist (home ".git") diff --git a/src/Propellor/Property/SiteSpecific/JoeySites.hs b/src/Propellor/Property/SiteSpecific/JoeySites.hs new file mode 100644 index 00000000..28b3dffd --- /dev/null +++ b/src/Propellor/Property/SiteSpecific/JoeySites.hs @@ -0,0 +1,314 @@ +-- | Specific configuation for Joey Hess's sites. Probably not useful to +-- others except as an example. + +module Propellor.Property.SiteSpecific.JoeySites where + +import Propellor +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Gpg as Gpg +import qualified Propellor.Property.Ssh as Ssh +import qualified Propellor.Property.Git as Git +import qualified Propellor.Property.Cron as Cron +import qualified Propellor.Property.Service as Service +import qualified Propellor.Property.User as User +import qualified Propellor.Property.Obnam as Obnam +import qualified Propellor.Property.Apache as Apache +import Utility.SafeCommand +import Utility.FileMode + +import Data.List +import System.Posix.Files + +oldUseNetServer :: [Host] -> Property +oldUseNetServer hosts = propertyList ("olduse.net server") + [ oldUseNetInstalled "oldusenet-server" + , Obnam.latestVersion + , Obnam.backup datadir "33 4 * * *" + [ "--repository=sftp://2318@usw-s002.rsync.net/~/olduse.net" + , "--client-name=spool" + ] Obnam.OnlyClient + `requires` Ssh.keyImported SshRsa "root" + `requires` Ssh.knownHost hosts "usw-s002.rsync.net" "root" + , check (not . isSymbolicLink <$> getSymbolicLinkStatus newsspool) $ + property "olduse.net spool in place" $ makeChange $ do + removeDirectoryRecursive newsspool + createSymbolicLink (datadir "news") newsspool + , Apt.installed ["leafnode"] + , "/etc/news/leafnode/config" `File.hasContent` + [ "# olduse.net configuration (deployed by propellor)" + , "expire = 1000000" -- no expiry via texpire + , "server = " -- no upstream server + , "debugmode = 1" + , "allowSTRANGERS = 42" -- lets anyone connect + , "nopost = 1" -- no new posting (just gather them) + ] + , "/etc/hosts.deny" `File.lacksLine` "leafnode: ALL" + , Apt.serviceInstalledRunning "openbsd-inetd" + , File.notPresent "/etc/cron.daily/leafnode" + , File.notPresent "/etc/cron.d/leafnode" + , Cron.niceJob "oldusenet-expire" "11 1 * * *" "news" newsspool $ intercalate ";" + [ "find \\( -path ./out.going -or -path ./interesting.groups -or -path './*/.overview' \\) -prune -or -type f -ctime +60 -print | xargs --no-run-if-empty rm" + , "find -type d -empty | xargs --no-run-if-empty rmdir" + ] + , Cron.niceJob "oldusenet-uucp" "*/5 * * * *" "news" "/" $ + "/usr/bin/uucp " ++ datadir + , toProp $ Apache.siteEnabled "nntp.olduse.net" $ apachecfg "nntp.olduse.net" False + [ " DocumentRoot " ++ datadir ++ "/" + , " " + , " Options Indexes FollowSymlinks" + , " AllowOverride None" + -- I had this in the file before. + -- This may be needed by a newer version of apache? + --, " Require all granted" + , " " + ] + ] + where + newsspool = "/var/spool/news" + datadir = "/var/spool/oldusenet" + +oldUseNetShellBox :: Property +oldUseNetShellBox = oldUseNetInstalled "oldusenet" + +oldUseNetInstalled :: Apt.Package -> Property +oldUseNetInstalled pkg = check (not <$> Apt.isInstalled pkg) $ + propertyList ("olduse.net " ++ pkg) + [ Apt.installed (words "build-essential devscripts debhelper git libncursesw5-dev libpcre3-dev pkg-config bison libicu-dev libidn11-dev libcanlock2-dev libuu-dev ghc libghc-strptime-dev libghc-hamlet-dev libghc-ifelse-dev libghc-hxt-dev libghc-utf8-string-dev libghc-missingh-dev libghc-sha-dev") + `describe` "olduse.net build deps" + , scriptProperty + [ "rm -rf /root/tmp/oldusenet" -- idenpotency + , "git clone git://olduse.net/ /root/tmp/oldusenet/source" + , "cd /root/tmp/oldusenet/source/" + , "dpkg-buildpackage -us -uc" + , "dpkg -i ../" ++ pkg ++ "_*.deb || true" + , "apt-get -fy install" -- dependencies + , "rm -rf /root/tmp/oldusenet" + ] `describe` "olduse.net built" + ] + + +kgbServer :: Property +kgbServer = withOS desc $ \o -> case o of + (Just (System (Debian Unstable) _)) -> + ensureProperty $ propertyList desc + [ Apt.serviceInstalledRunning "kgb-bot" + , File.hasPrivContent "/etc/kgb-bot/kgb.conf" + `onChange` Service.restarted "kgb-bot" + , "/etc/default/kgb-bot" `File.containsLine` "BOT_ENABLED=1" + `describe` "kgb bot enabled" + `onChange` Service.running "kgb-bot" + ] + _ -> error "kgb server needs Debian unstable (for kgb-bot 1.31+)" + where + desc = "kgb.kitenet.net setup" + +mumbleServer :: [Host] -> Property +mumbleServer hosts = combineProperties "mumble.debian.net" + [ Apt.serviceInstalledRunning "mumble-server" + , Obnam.latestVersion + , Obnam.backup "/var/lib/mumble-server" "55 5 * * *" + [ "--repository=sftp://joey@turtle.kitenet.net/~/lib/backup/mumble.debian.net.obnam" + , "--client-name=mumble" + ] Obnam.OnlyClient + `requires` Ssh.keyImported SshRsa "root" + `requires` Ssh.knownHost hosts "turtle.kitenet.net" "root" + , trivial $ cmdProperty "chown" ["-R", "mumble-server:mumble-server", "/var/lib/mumble-server"] + ] + +obnamLowMem :: Property +obnamLowMem = combineProperties "obnam tuned for low memory use" + [ Obnam.latestVersion + , "/etc/obnam.conf" `File.containsLines` + [ "[config]" + , "# Suggested by liw to keep Obnam memory consumption down (at some speed cost)." + , "upload-queue-size = 128" + , "lru-size = 128" + ] + ] + +-- git.kitenet.net and git.joeyh.name +gitServer :: [Host] -> Property +gitServer hosts = propertyList "git.kitenet.net setup" + [ Obnam.latestVersion + , Obnam.backup "/srv/git" "33 3 * * *" + [ "--repository=sftp://2318@usw-s002.rsync.net/~/git.kitenet.net" + , "--encrypt-with=1B169BE1" + , "--client-name=wren" + ] Obnam.OnlyClient + `requires` Gpg.keyImported "1B169BE1" "root" + `requires` Ssh.keyImported SshRsa "root" + `requires` Ssh.knownHost hosts "usw-s002.rsync.net" "root" + `requires` Ssh.authorizedKeys "family" + `requires` User.accountFor "family" + , Apt.installed ["git", "rsync", "kgb-client-git", "gitweb"] + , Apt.installedBackport ["git-annex"] + , File.hasPrivContentExposed "/etc/kgb-bot/kgb-client.conf" + , toProp $ Git.daemonRunning "/srv/git" + , "/etc/gitweb.conf" `File.containsLines` + [ "$projectroot = '/srv/git';" + , "@git_base_url_list = ('git://git.kitenet.net', 'http://git.kitenet.net/git', 'https://git.kitenet.net/git', 'ssh://git.kitenet.net/srv/git');" + , "# disable snapshot download; overloads server" + , "$feature{'snapshot'}{'default'} = [];" + ] + `describe` "gitweb configured" + -- Repos push on to github. + , Ssh.knownHost hosts "github.com" "joey" + -- I keep the website used for gitweb checked into git.. + , Git.cloned "root" "/srv/git/joey/git.kitenet.net.git" "/srv/web/git.kitenet.net" Nothing + , website "git.kitenet.net" + , website "git.joeyh.name" + , toProp $ Apache.modEnabled "cgi" + ] + where + website hn = toProp $ Apache.siteEnabled hn $ apachecfg hn True + [ " DocumentRoot /srv/web/git.kitenet.net/" + , " " + , " Options Indexes ExecCGI FollowSymlinks" + , " AllowOverride None" + , " AddHandler cgi-script .cgi" + , " DirectoryIndex index.cgi" + , " " + , "" + , " ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/" + , " " + , " SetHandler cgi-script" + , " Options ExecCGI" + , " " + ] + +type AnnexUUID = String + +-- | A website, with files coming from a git-annex repository. +annexWebSite :: [Host] -> Git.RepoUrl -> HostName -> AnnexUUID -> [(String, Git.RepoUrl)] -> Property +annexWebSite hosts origin hn uuid remotes = propertyList (hn ++" website using git-annex") + [ Git.cloned "joey" origin dir Nothing + `onChange` setup + , postupdatehook `File.hasContent` + [ "#!/bin/sh" + , "exec git update-server-info" + ] `onChange` + (postupdatehook `File.mode` (combineModes (ownerWriteMode:readModes ++ executeModes))) + , setupapache + ] + where + dir = "/srv/web/" ++ hn + postupdatehook = dir ".git/hooks/post-update" + setup = userScriptProperty "joey" setupscript + `requires` Ssh.keyImported SshRsa "joey" + `requires` Ssh.knownHost hosts "turtle.kitenet.net" "joey" + setupscript = + [ "cd " ++ shellEscape dir + , "git config annex.uuid " ++ shellEscape uuid + ] ++ map addremote remotes ++ + [ "git annex get" + ] + addremote (name, url) = "git remote add " ++ shellEscape name ++ " " ++ shellEscape url + setupapache = toProp $ Apache.siteEnabled hn $ apachecfg hn True $ + [ " ServerAlias www."++hn + , "" + , " DocumentRoot /srv/web/"++hn + , " " + , " Options FollowSymLinks" + , " AllowOverride None" + , " " + , " " + , " Options Indexes FollowSymLinks ExecCGI" + , " AllowOverride None" + , " AddHandler cgi-script .cgi" + , " DirectoryIndex index.html index.cgi" + , " Order allow,deny" + , " allow from all" + , " " + ] + +apachecfg :: HostName -> Bool -> Apache.ConfigFile -> Apache.ConfigFile +apachecfg hn withssl middle + | withssl = vhost False ++ vhost True + | otherwise = vhost False + where + vhost ssl = + [ "" + , " ServerAdmin grue@joeyh.name" + , " ServerName "++hn++":"++show port + ] + ++ mainhttpscert ssl + ++ middle ++ + [ "" + , " ErrorLog /var/log/apache2/error.log" + , " LogLevel warn" + , " CustomLog /var/log/apache2/access.log combined" + , " ServerSignature On" + , " " + , " " + , " Options Indexes MultiViews" + , " AllowOverride None" + , " Order allow,deny" + , " Allow from all" + , " " + , "" + ] + where + port = if ssl then 443 else 80 :: Int + +mainhttpscert :: Bool -> Apache.ConfigFile +mainhttpscert False = [] +mainhttpscert True = + [ " SSLEngine on" + , " SSLCertificateFile /etc/ssl/certs/web.pem" + , " SSLCertificateKeyFile /etc/ssl/private/web.pem" + , " SSLCertificateChainFile /etc/ssl/certs/startssl.pem" + ] + +gitAnnexDistributor :: Property +gitAnnexDistributor = combineProperties "git-annex distributor, including rsync server and signer" + [ Apt.installed ["rsync"] + , File.hasPrivContent "/etc/rsyncd.conf" + , File.hasPrivContent "/etc/rsyncd.secrets" + , "/etc/default/rsync" `File.containsLine` "RSYNC_ENABLE=true" + `onChange` Service.running "rsync" + , endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild" + , endpoint "/srv/web/downloads.kitenet.net/git-annex/autobuild/x86_64-apple-mavericks" + -- git-annex distribution signing key + , Gpg.keyImported "89C809CB" "joey" + ] + where + endpoint d = combineProperties ("endpoint " ++ d) + [ File.dirExists d + , File.ownerGroup d "joey" "joey" + ] + +-- Twitter, you kill us. +twitRss :: Property +twitRss = combineProperties "twitter rss" + [ Git.cloned "joey" "git://git.kitenet.net/twitrss.git" dir Nothing + , check (not <$> doesFileExist (dir "twitRss")) $ + userScriptProperty "joey" + [ "cd " ++ dir + , "ghc --make twitRss" + ] + `requires` Apt.installed + [ "libghc-xml-dev" + , "libghc-feed-dev" + , "libghc-tagsoup-dev" + ] + , feed "http://twitter.com/search/realtime?q=git-annex" "git-annex-twitter" + , feed "http://twitter.com/search/realtime?q=olduse+OR+git-annex+OR+debhelper+OR+etckeeper+OR+ikiwiki+-ashley_ikiwiki" "twittergrep" + ] + where + dir = "/srv/web/tmp.kitenet.net/twitrss" + crontime = "15 * * * *" + feed url desc = Cron.job desc crontime "joey" dir $ + "./twitRss " ++ shellEscape url ++ " > " ++ shellEscape ("../" ++ desc ++ ".rss") + +ircBouncer :: Property +ircBouncer = propertyList "IRC bouncer" + [ Apt.installed ["znc"] + , User.accountFor "znc" + , File.hasPrivContent conf + , File.ownerGroup conf "znc" "znc" + , Cron.job "znconboot" "@reboot" "znc" "~" "znc" + , Cron.job "zncrunning" "@hourly" "znc" "~" "znc || true" + ] + where + conf = "/home/znc/.znc/configs/znc.conf" diff --git a/src/Propellor/Property/Ssh.hs b/src/Propellor/Property/Ssh.hs new file mode 100644 index 00000000..a4f87678 --- /dev/null +++ b/src/Propellor/Property/Ssh.hs @@ -0,0 +1,152 @@ +module Propellor.Property.Ssh ( + setSshdConfig, + permitRootLogin, + passwordAuthentication, + hasAuthorizedKeys, + restartSshd, + randomHostKeys, + hostKey, + keyImported, + knownHost, + authorizedKeys +) where + +import Propellor +import qualified Propellor.Property.File as File +import Propellor.Property.User +import Utility.SafeCommand +import Utility.FileMode + +import System.PosixCompat + +sshBool :: Bool -> String +sshBool True = "yes" +sshBool False = "no" + +sshdConfig :: FilePath +sshdConfig = "/etc/ssh/sshd_config" + +setSshdConfig :: String -> Bool -> Property +setSshdConfig setting allowed = combineProperties "sshd config" + [ sshdConfig `File.lacksLine` (sshline $ not allowed) + , sshdConfig `File.containsLine` (sshline allowed) + ] + `onChange` restartSshd + `describe` unwords [ "ssh config:", setting, sshBool allowed ] + where + sshline v = setting ++ " " ++ sshBool v + +permitRootLogin :: Bool -> Property +permitRootLogin = setSshdConfig "PermitRootLogin" + +passwordAuthentication :: Bool -> Property +passwordAuthentication = setSshdConfig "PasswordAuthentication" + +dotDir :: UserName -> IO FilePath +dotDir user = do + h <- homedir user + return $ h ".ssh" + +dotFile :: FilePath -> UserName -> IO FilePath +dotFile f user = do + d <- dotDir user + return $ d f + +hasAuthorizedKeys :: UserName -> IO Bool +hasAuthorizedKeys = go <=< dotFile "authorized_keys" + where + go f = not . null <$> catchDefaultIO "" (readFile f) + +restartSshd :: Property +restartSshd = cmdProperty "service" ["ssh", "restart"] + +-- | Blows away existing host keys and make new ones. +-- Useful for systems installed from an image that might reuse host keys. +-- A flag file is used to only ever do this once. +randomHostKeys :: Property +randomHostKeys = flagFile prop "/etc/ssh/.unique_host_keys" + `onChange` restartSshd + where + prop = property "ssh random host keys" $ do + void $ liftIO $ boolSystem "sh" + [ Param "-c" + , Param "rm -f /etc/ssh/ssh_host_*" + ] + ensureProperty $ + cmdProperty "/var/lib/dpkg/info/openssh-server.postinst" + ["configure"] + +-- | Sets ssh host keys from the site's PrivData. +-- +-- (Uses a null username for host keys.) +hostKey :: SshKeyType -> Property +hostKey keytype = combineProperties desc + [ property desc (install writeFile (SshPubKey keytype "") ".pub") + , property desc (install writeFileProtected (SshPrivKey keytype "") "") + ] + `onChange` restartSshd + where + desc = "known ssh host key (" ++ fromKeyType keytype ++ ")" + install writer p ext = withPrivData p $ \key -> do + let f = "/etc/ssh/ssh_host_" ++ fromKeyType keytype ++ "_key" ++ ext + s <- liftIO $ readFileStrict f + if s == key + then noChange + else makeChange $ writer f key + +-- | Sets up a user with a ssh private key and public key pair +-- from the site's PrivData. +keyImported :: SshKeyType -> UserName -> Property +keyImported keytype user = combineProperties desc + [ property desc (install writeFile (SshPubKey keytype user) ".pub") + , property desc (install writeFileProtected (SshPrivKey keytype user) "") + ] + where + desc = user ++ " has ssh key (" ++ fromKeyType keytype ++ ")" + install writer p ext = do + f <- liftIO $ keyfile ext + ifM (liftIO $ doesFileExist f) + ( noChange + , ensureProperty $ combineProperties desc + [ property desc $ + withPrivData p $ \key -> makeChange $ + writer f key + , File.ownerGroup f user user + ] + ) + keyfile ext = do + home <- homeDirectory <$> getUserEntryForName user + return $ home ".ssh" "id_" ++ fromKeyType keytype ++ ext + +fromKeyType :: SshKeyType -> String +fromKeyType SshRsa = "rsa" +fromKeyType SshDsa = "dsa" +fromKeyType SshEcdsa = "ecdsa" +fromKeyType SshEd25519 = "ed25519" + +-- | Puts some host's ssh public key into the known_hosts file for a user. +knownHost :: [Host] -> HostName -> UserName -> Property +knownHost hosts hn user = property desc $ + go =<< fromHost hosts hn getSshPubKey + where + desc = user ++ " knows ssh key for " ++ hn + go (Just (Just k)) = do + f <- liftIO $ dotFile "known_hosts" user + ensureProperty $ combineProperties desc + [ File.dirExists (takeDirectory f) + , f `File.containsLine` (hn ++ " " ++ k) + , File.ownerGroup f user user + ] + go _ = do + warningMessage $ "no configred sshPubKey for " ++ hn + return FailedChange + +-- | Makes a user have authorized_keys from the PrivData +authorizedKeys :: UserName -> Property +authorizedKeys user = property (user ++ " has authorized_keys") $ + withPrivData (SshAuthorizedKeys user) $ \v -> do + f <- liftIO $ dotFile "authorized_keys" user + liftIO $ do + createDirectoryIfMissing True (takeDirectory f) + writeFileProtected f v + ensureProperty $ File.ownerGroup f user user diff --git a/src/Propellor/Property/Sudo.hs b/src/Propellor/Property/Sudo.hs new file mode 100644 index 00000000..68b56608 --- /dev/null +++ b/src/Propellor/Property/Sudo.hs @@ -0,0 +1,32 @@ +module Propellor.Property.Sudo where + +import Data.List + +import Propellor +import Propellor.Property.File +import qualified Propellor.Property.Apt as Apt +import Propellor.Property.User + +-- | Allows a user to sudo. If the user has a password, sudo is configured +-- to require it. If not, NOPASSWORD is enabled for the user. +enabledFor :: UserName -> Property +enabledFor user = property desc go `requires` Apt.installed ["sudo"] + where + go = do + locked <- liftIO $ isLockedPassword user + ensureProperty $ + fileProperty desc + (modify locked . filter (wanted locked)) + "/etc/sudoers" + desc = user ++ " is sudoer" + sudobaseline = user ++ " ALL=(ALL:ALL)" + sudoline True = sudobaseline ++ " NOPASSWD:ALL" + sudoline False = sudobaseline ++ " ALL" + wanted locked l + -- TOOD: Full sudoers file format parse.. + | not (sudobaseline `isPrefixOf` l) = True + | "NOPASSWD" `isInfixOf` l = locked + | otherwise = True + modify locked ls + | sudoline locked `elem` ls = ls + | otherwise = ls ++ [sudoline locked] diff --git a/src/Propellor/Property/Tor.hs b/src/Propellor/Property/Tor.hs new file mode 100644 index 00000000..78e35c89 --- /dev/null +++ b/src/Propellor/Property/Tor.hs @@ -0,0 +1,19 @@ +module Propellor.Property.Tor where + +import Propellor +import qualified Propellor.Property.File as File +import qualified Propellor.Property.Apt as Apt + +isBridge :: Property +isBridge = setup `requires` Apt.installed ["tor"] + `describe` "tor bridge" + where + setup = "/etc/tor/torrc" `File.hasContent` + [ "SocksPort 0" + , "ORPort 443" + , "BridgeRelay 1" + , "Exitpolicy reject *:*" + ] `onChange` restartTor + +restartTor :: Property +restartTor = cmdProperty "service" ["tor", "restart"] diff --git a/src/Propellor/Property/User.hs b/src/Propellor/Property/User.hs new file mode 100644 index 00000000..eef2a57e --- /dev/null +++ b/src/Propellor/Property/User.hs @@ -0,0 +1,61 @@ +module Propellor.Property.User where + +import System.Posix + +import Propellor + +data Eep = YesReallyDeleteHome + +accountFor :: UserName -> Property +accountFor user = check (isNothing <$> catchMaybeIO (homedir user)) $ cmdProperty "adduser" + [ "--disabled-password" + , "--gecos", "" + , user + ] + `describe` ("account for " ++ user) + +-- | Removes user home directory!! Use with caution. +nuked :: UserName -> Eep -> Property +nuked user _ = check (isJust <$> catchMaybeIO (homedir user)) $ cmdProperty "userdel" + [ "-r" + , user + ] + `describe` ("nuked user " ++ user) + +-- | Only ensures that the user has some password set. It may or may +-- not be the password from the PrivData. +hasSomePassword :: UserName -> Property +hasSomePassword user = check ((/= HasPassword) <$> getPasswordStatus user) $ + hasPassword user + +hasPassword :: UserName -> Property +hasPassword user = property (user ++ " has password") $ + withPrivData (Password user) $ \password -> makeChange $ + withHandle StdinHandle createProcessSuccess + (proc "chpasswd" []) $ \h -> do + hPutStrLn h $ user ++ ":" ++ password + hClose h + +lockedPassword :: UserName -> Property +lockedPassword user = check (not <$> isLockedPassword user) $ cmdProperty "passwd" + [ "--lock" + , user + ] + `describe` ("locked " ++ user ++ " password") + +data PasswordStatus = NoPassword | LockedPassword | HasPassword + deriving (Eq) + +getPasswordStatus :: UserName -> IO PasswordStatus +getPasswordStatus user = parse . words <$> readProcess "passwd" ["-S", user] + where + parse (_:"L":_) = LockedPassword + parse (_:"NP":_) = NoPassword + parse (_:"P":_) = HasPassword + parse _ = NoPassword + +isLockedPassword :: UserName -> IO Bool +isLockedPassword user = (== LockedPassword) <$> getPasswordStatus user + +homedir :: UserName -> IO FilePath +homedir user = homeDirectory <$> getUserEntryForName user diff --git a/src/Propellor/SimpleSh.hs b/src/Propellor/SimpleSh.hs new file mode 100644 index 00000000..7ba30b0e --- /dev/null +++ b/src/Propellor/SimpleSh.hs @@ -0,0 +1,101 @@ +-- | Simple server, using a named pipe. Client connects, sends a command, +-- and gets back all the output from the command, in a stream. +-- +-- This is useful for eg, docker. + +module Propellor.SimpleSh where + +import Network.Socket +import Control.Concurrent +import Control.Concurrent.Async +import System.Process (std_in, std_out, std_err) + +import Propellor +import Utility.FileMode +import Utility.ThreadScheduler + +data Cmd = Cmd String [String] + deriving (Read, Show) + +data Resp = StdoutLine String | StderrLine String | Done + deriving (Read, Show) + +simpleSh :: FilePath -> IO () +simpleSh namedpipe = do + nukeFile namedpipe + let dir = takeDirectory namedpipe + createDirectoryIfMissing True dir + modifyFileMode dir (removeModes otherGroupModes) + s <- socket AF_UNIX Stream defaultProtocol + bindSocket s (SockAddrUnix namedpipe) + listen s 2 + forever $ do + (client, _addr) <- accept s + forkIO $ do + h <- socketToHandle client ReadWriteMode + maybe noop (run h) . readish =<< hGetLine h + where + run h (Cmd cmd params) = do + chan <- newChan + let runwriter = do + v <- readChan chan + hPutStrLn h (show v) + hFlush h + case v of + Done -> noop + _ -> runwriter + writer <- async runwriter + + flip catchIO (\_e -> writeChan chan Done) $ do + let p = (proc cmd params) + { std_in = Inherit + , std_out = CreatePipe + , std_err = CreatePipe + } + (Nothing, Just outh, Just errh, pid) <- createProcess p + + let mkreader t from = maybe noop (const $ mkreader t from) + =<< catchMaybeIO (writeChan chan . t =<< hGetLine from) + void $ concurrently + (mkreader StdoutLine outh) + (mkreader StderrLine errh) + + void $ tryIO $ waitForProcess pid + + writeChan chan Done + + hClose outh + hClose errh + + wait writer + hClose h + +simpleShClient :: FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a +simpleShClient namedpipe cmd params handler = do + s <- socket AF_UNIX Stream defaultProtocol + connect s (SockAddrUnix namedpipe) + h <- socketToHandle s ReadWriteMode + hPutStrLn h $ show $ Cmd cmd params + hFlush h + resps <- catMaybes . map readish . lines <$> hGetContents h + v <- hClose h `after` handler resps + return v + +simpleShClientRetry :: Int -> FilePath -> String -> [String] -> ([Resp] -> IO a) -> IO a +simpleShClientRetry retries namedpipe cmd params handler = go retries + where + run = simpleShClient namedpipe cmd params handler + go n + | n < 1 = run + | otherwise = do + v <- tryIO run + case v of + Right r -> return r + Left e -> do + debug ["simplesh connection retry", show e] + threadDelaySeconds (Seconds 1) + go (n - 1) + +getStdout :: Resp -> Maybe String +getStdout (StdoutLine s) = Just s +getStdout _ = Nothing diff --git a/src/Propellor/Types.hs b/src/Propellor/Types.hs new file mode 100644 index 00000000..22df9ddb --- /dev/null +++ b/src/Propellor/Types.hs @@ -0,0 +1,153 @@ +{-# LANGUAGE PackageImports #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE ExistentialQuantification #-} + +module Propellor.Types + ( Host(..) + , Attr + , SetAttr + , Propellor(..) + , Property(..) + , RevertableProperty(..) + , IsProp + , describe + , toProp + , setAttr + , requires + , Desc + , Result(..) + , ActionResult(..) + , CmdLine(..) + , PrivDataField(..) + , GpgKeyId + , SshKeyType(..) + , module Propellor.Types.OS + , module Propellor.Types.Dns + ) where + +import Data.Monoid +import Control.Applicative +import System.Console.ANSI +import "mtl" Control.Monad.Reader +import "MonadCatchIO-transformers" Control.Monad.CatchIO + +import Propellor.Types.Attr +import Propellor.Types.OS +import Propellor.Types.Dns + +data Host = Host [Property] SetAttr + +-- | Propellor's monad provides read-only access to attributes of the +-- system. +newtype Propellor p = Propellor { runWithAttr :: ReaderT Attr IO p } + deriving + ( Monad + , Functor + , Applicative + , MonadReader Attr + , MonadIO + , MonadCatchIO + ) + +-- | The core data type of Propellor, this represents a property +-- that the system should have, and an action to ensure it has the +-- property. +data Property = Property + { propertyDesc :: Desc + , propertySatisfy :: Propellor Result + -- ^ must be idempotent; may run repeatedly + , propertyAttr :: SetAttr + -- ^ a property can set an Attr on the host that has the property. + } + +-- | A property that can be reverted. +data RevertableProperty = RevertableProperty Property Property + +class IsProp p where + -- | Sets description. + describe :: p -> Desc -> p + toProp :: p -> Property + -- | Indicates that the first property can only be satisfied + -- once the second one is. + requires :: p -> Property -> p + setAttr :: p -> SetAttr + +instance IsProp Property where + describe p d = p { propertyDesc = d } + toProp p = p + setAttr = propertyAttr + x `requires` y = Property (propertyDesc x) satisfy attr + where + attr = propertyAttr x . propertyAttr y + satisfy = do + r <- propertySatisfy y + case r of + FailedChange -> return FailedChange + _ -> propertySatisfy x + + +instance IsProp RevertableProperty where + -- | Sets the description of both sides. + describe (RevertableProperty p1 p2) d = + RevertableProperty (describe p1 d) (describe p2 ("not " ++ d)) + toProp (RevertableProperty p1 _) = p1 + (RevertableProperty p1 p2) `requires` y = + RevertableProperty (p1 `requires` y) p2 + -- | Return the SetAttr of the currently active side. + setAttr (RevertableProperty p1 _p2) = setAttr p1 + +type Desc = String + +data Result = NoChange | MadeChange | FailedChange + deriving (Read, Show, Eq) + +instance Monoid Result where + mempty = NoChange + + mappend FailedChange _ = FailedChange + mappend _ FailedChange = FailedChange + mappend MadeChange _ = MadeChange + mappend _ MadeChange = MadeChange + mappend NoChange NoChange = NoChange + +-- | Results of actions, with color. +class ActionResult a where + getActionResult :: a -> (String, ColorIntensity, Color) + +instance ActionResult Bool where + getActionResult False = ("failed", Vivid, Red) + getActionResult True = ("done", Dull, Green) + +instance ActionResult Result where + getActionResult NoChange = ("ok", Dull, Green) + getActionResult MadeChange = ("done", Vivid, Green) + getActionResult FailedChange = ("failed", Vivid, Red) + +data CmdLine + = Run HostName + | Spin HostName + | Boot HostName + | Set HostName PrivDataField + | AddKey String + | Continue CmdLine + | Chain HostName + | Docker HostName + deriving (Read, Show, Eq) + +-- | Note that removing or changing field names will break the +-- serialized privdata files, so don't do that! +-- It's fine to add new fields. +data PrivDataField + = DockerAuthentication + | SshPubKey SshKeyType UserName + | SshPrivKey SshKeyType UserName + | SshAuthorizedKeys UserName + | Password UserName + | PrivFile FilePath + | GpgKey GpgKeyId + deriving (Read, Show, Ord, Eq) + +type GpgKeyId = String + +data SshKeyType = SshRsa | SshDsa | SshEcdsa | SshEd25519 + deriving (Read, Show, Ord, Eq) diff --git a/src/Propellor/Types/Attr.hs b/src/Propellor/Types/Attr.hs new file mode 100644 index 00000000..8b7d3b09 --- /dev/null +++ b/src/Propellor/Types/Attr.hs @@ -0,0 +1,48 @@ +module Propellor.Types.Attr where + +import Propellor.Types.OS +import qualified Propellor.Types.Dns as Dns + +import qualified Data.Set as S +import qualified Data.Map as M + +-- | The attributes of a host. For example, its hostname. +data Attr = Attr + { _hostname :: HostName + , _os :: Maybe System + , _sshPubKey :: Maybe String + , _dns :: S.Set Dns.Record + , _namedconf :: M.Map Dns.Domain Dns.NamedConf + + , _dockerImage :: Maybe String + , _dockerRunParams :: [HostName -> String] + } + +instance Eq Attr where + x == y = and + [ _hostname x == _hostname y + , _os x == _os y + , _dns x == _dns y + , _namedconf x == _namedconf y + , _sshPubKey x == _sshPubKey y + + , _dockerImage x == _dockerImage y + , let simpl v = map (\a -> a "") (_dockerRunParams v) + in simpl x == simpl y + ] + +instance Show Attr where + show a = unlines + [ "hostname " ++ _hostname a + , "OS " ++ show (_os a) + , "sshPubKey " ++ show (_sshPubKey a) + , "dns " ++ show (_dns a) + , "namedconf " ++ show (_namedconf a) + , "docker image " ++ show (_dockerImage a) + , "docker run params " ++ show (map (\mk -> mk "") (_dockerRunParams a)) + ] + +newAttr :: HostName -> Attr +newAttr hn = Attr hn Nothing Nothing S.empty M.empty Nothing [] + +type SetAttr = Attr -> Attr diff --git a/src/Propellor/Types/Dns.hs b/src/Propellor/Types/Dns.hs new file mode 100644 index 00000000..ba6a92dd --- /dev/null +++ b/src/Propellor/Types/Dns.hs @@ -0,0 +1,92 @@ +module Propellor.Types.Dns where + +import Propellor.Types.OS (HostName) + +import Data.Word + +type Domain = String + +data IPAddr = IPv4 String | IPv6 String + deriving (Read, Show, Eq, Ord) + +fromIPAddr :: IPAddr -> String +fromIPAddr (IPv4 addr) = addr +fromIPAddr (IPv6 addr) = addr + +-- | Represents a bind 9 named.conf file. +data NamedConf = NamedConf + { confDomain :: Domain + , confDnsServerType :: DnsServerType + , confFile :: FilePath + , confMasters :: [IPAddr] + , confAllowTransfer :: [IPAddr] + , confLines :: [String] + } + deriving (Show, Eq, Ord) + +data DnsServerType = Master | Secondary + deriving (Show, Eq, Ord) + +-- | Represents a bind 9 zone file. +data Zone = Zone + { zDomain :: Domain + , zSOA :: SOA + , zHosts :: [(BindDomain, Record)] + } + deriving (Read, Show, Eq) + +-- | Every domain has a SOA record, which is big and complicated. +data SOA = SOA + { sDomain :: BindDomain + -- ^ Typically ns1.your.domain + , sSerial :: SerialNumber + -- ^ The most important parameter is the serial number, + -- which must increase after each change. + , sRefresh :: Integer + , sRetry :: Integer + , sExpire :: Integer + , sNegativeCacheTTL :: Integer + } + deriving (Read, Show, Eq) + +-- | Types of DNS records. +-- +-- This is not a complete list, more can be added. +data Record + = Address IPAddr + | CNAME BindDomain + | MX Int BindDomain + | NS BindDomain + | TXT String + | SRV Word16 Word16 Word16 BindDomain + deriving (Read, Show, Eq, Ord) + +getIPAddr :: Record -> Maybe IPAddr +getIPAddr (Address addr) = Just addr +getIPAddr _ = Nothing + +getCNAME :: Record -> Maybe BindDomain +getCNAME (CNAME d) = Just d +getCNAME _ = Nothing + +getNS :: Record -> Maybe BindDomain +getNS (NS d) = Just d +getNS _ = Nothing + +-- | Bind serial numbers are unsigned, 32 bit integers. +type SerialNumber = Word32 + +-- | Domains in the zone file must end with a period if they are absolute. +-- +-- Let's use a type to keep absolute domains straight from relative +-- domains. +-- +-- The RootDomain refers to the top level of the domain, so can be used +-- to add nameservers, MX's, etc to a domain. +data BindDomain = RelDomain Domain | AbsDomain Domain | RootDomain + deriving (Read, Show, Eq, Ord) + +domainHostName :: BindDomain -> Maybe HostName +domainHostName (RelDomain d) = Just d +domainHostName (AbsDomain d) = Just d +domainHostName RootDomain = Nothing diff --git a/src/Propellor/Types/OS.hs b/src/Propellor/Types/OS.hs new file mode 100644 index 00000000..23cc8a29 --- /dev/null +++ b/src/Propellor/Types/OS.hs @@ -0,0 +1,27 @@ +module Propellor.Types.OS where + +type HostName = String +type UserName = String +type GroupName = String + +-- | High level descritption of a operating system. +data System = System Distribution Architecture + deriving (Show, Eq) + +data Distribution + = Debian DebianSuite + | Ubuntu Release + deriving (Show, Eq) + +data DebianSuite = Experimental | Unstable | Testing | Stable | DebianRelease Release + deriving (Show, Eq) + +-- | The release that currently corresponds to stable. +stableRelease :: DebianSuite +stableRelease = DebianRelease "wheezy" + +isStable :: DebianSuite -> Bool +isStable s = s == Stable || s == stableRelease + +type Release = String +type Architecture = String diff --git a/src/Utility/Applicative.hs b/src/Utility/Applicative.hs new file mode 100644 index 00000000..fd8944b2 --- /dev/null +++ b/src/Utility/Applicative.hs @@ -0,0 +1,16 @@ +{- applicative stuff + - + - Copyright 2012 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.Applicative where + +{- Like <$> , but supports one level of currying. + - + - foo v = bar <$> action v == foo = bar <$$> action + -} +(<$$>) :: Functor f => (a -> b) -> (c -> f a) -> c -> f b +f <$$> v = fmap f . v +infixr 4 <$$> diff --git a/src/Utility/Data.hs b/src/Utility/Data.hs new file mode 100644 index 00000000..2df12b36 --- /dev/null +++ b/src/Utility/Data.hs @@ -0,0 +1,17 @@ +{- utilities for simple data types + - + - Copyright 2013 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.Data where + +{- First item in the list that is not Nothing. -} +firstJust :: Eq a => [Maybe a] -> Maybe a +firstJust ms = case dropWhile (== Nothing) ms of + [] -> Nothing + (md:_) -> md + +eitherToMaybe :: Either a b -> Maybe b +eitherToMaybe = either (const Nothing) Just diff --git a/src/Utility/Directory.hs b/src/Utility/Directory.hs new file mode 100644 index 00000000..d92327c0 --- /dev/null +++ b/src/Utility/Directory.hs @@ -0,0 +1,135 @@ +{- directory manipulation + - + - Copyright 2011-2014 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.Directory where + +import System.IO.Error +import System.Directory +import Control.Exception (throw) +import Control.Monad +import Control.Monad.IfElse +import System.FilePath +import Control.Applicative +import System.IO.Unsafe (unsafeInterleaveIO) + +import Utility.PosixFiles +import Utility.SafeCommand +import Utility.Tmp +import Utility.Exception +import Utility.Monad +import Utility.Applicative + +dirCruft :: FilePath -> Bool +dirCruft "." = True +dirCruft ".." = True +dirCruft _ = False + +{- Lists the contents of a directory. + - Unlike getDirectoryContents, paths are not relative to the directory. -} +dirContents :: FilePath -> IO [FilePath] +dirContents d = map (d ) . filter (not . dirCruft) <$> getDirectoryContents d + +{- Gets files in a directory, and then its subdirectories, recursively, + - and lazily. + - + - Does not follow symlinks to other subdirectories. + - + - When the directory does not exist, no exception is thrown, + - instead, [] is returned. -} +dirContentsRecursive :: FilePath -> IO [FilePath] +dirContentsRecursive = dirContentsRecursiveSkipping (const False) True + +{- Skips directories whose basenames match the skipdir. -} +dirContentsRecursiveSkipping :: (FilePath -> Bool) -> Bool -> FilePath -> IO [FilePath] +dirContentsRecursiveSkipping skipdir followsubdirsymlinks topdir = go [topdir] + where + go [] = return [] + go (dir:dirs) + | skipdir (takeFileName dir) = go dirs + | otherwise = unsafeInterleaveIO $ do + (files, dirs') <- collect [] [] + =<< catchDefaultIO [] (dirContents dir) + files' <- go (dirs' ++ dirs) + return (files ++ files') + collect files dirs' [] = return (reverse files, reverse dirs') + collect files dirs' (entry:entries) + | dirCruft entry = collect files dirs' entries + | otherwise = do + let skip = collect (entry:files) dirs' entries + let recurse = collect files (entry:dirs') entries + ms <- catchMaybeIO $ getSymbolicLinkStatus entry + case ms of + (Just s) + | isDirectory s -> recurse + | isSymbolicLink s && followsubdirsymlinks -> + ifM (doesDirectoryExist entry) + ( recurse + , skip + ) + _ -> skip + +{- Gets the directory tree from a point, recursively and lazily, + - with leaf directories **first**, skipping any whose basenames + - match the skipdir. Does not follow symlinks. -} +dirTreeRecursiveSkipping :: (FilePath -> Bool) -> FilePath -> IO [FilePath] +dirTreeRecursiveSkipping skipdir topdir = go [] [topdir] + where + go c [] = return c + go c (dir:dirs) + | skipdir (takeFileName dir) = go c dirs + | otherwise = unsafeInterleaveIO $ do + subdirs <- go c + =<< filterM (isDirectory <$$> getSymbolicLinkStatus) + =<< catchDefaultIO [] (dirContents dir) + go (subdirs++[dir]) dirs + +{- Moves one filename to another. + - First tries a rename, but falls back to moving across devices if needed. -} +moveFile :: FilePath -> FilePath -> IO () +moveFile src dest = tryIO (rename src dest) >>= onrename + where + onrename (Right _) = noop + onrename (Left e) + | isPermissionError e = rethrow + | isDoesNotExistError e = rethrow + | otherwise = do + -- copyFile is likely not as optimised as + -- the mv command, so we'll use the latter. + -- But, mv will move into a directory if + -- dest is one, which is not desired. + whenM (isdir dest) rethrow + viaTmp mv dest undefined + where + rethrow = throw e + mv tmp _ = do + ok <- boolSystem "mv" [Param "-f", Param src, Param tmp] + unless ok $ do + -- delete any partial + _ <- tryIO $ removeFile tmp + rethrow + + isdir f = do + r <- tryIO $ getFileStatus f + case r of + (Left _) -> return False + (Right s) -> return $ isDirectory s + +{- Removes a file, which may or may not exist, and does not have to + - be a regular file. + - + - Note that an exception is thrown if the file exists but + - cannot be removed. -} +nukeFile :: FilePath -> IO () +nukeFile file = void $ tryWhenExists go + where +#ifndef mingw32_HOST_OS + go = removeLink file +#else + go = removeFile file +#endif diff --git a/src/Utility/Env.hs b/src/Utility/Env.hs new file mode 100644 index 00000000..6763c24e --- /dev/null +++ b/src/Utility/Env.hs @@ -0,0 +1,81 @@ +{- portable environment variables + - + - Copyright 2013 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.Env where + +#ifdef mingw32_HOST_OS +import Utility.Exception +import Control.Applicative +import Data.Maybe +import qualified System.Environment as E +#else +import qualified System.Posix.Env as PE +#endif + +getEnv :: String -> IO (Maybe String) +#ifndef mingw32_HOST_OS +getEnv = PE.getEnv +#else +getEnv = catchMaybeIO . E.getEnv +#endif + +getEnvDefault :: String -> String -> IO String +#ifndef mingw32_HOST_OS +getEnvDefault = PE.getEnvDefault +#else +getEnvDefault var fallback = fromMaybe fallback <$> getEnv var +#endif + +getEnvironment :: IO [(String, String)] +#ifndef mingw32_HOST_OS +getEnvironment = PE.getEnvironment +#else +getEnvironment = E.getEnvironment +#endif + +{- Returns True if it could successfully set the environment variable. + - + - There is, apparently, no way to do this in Windows. Instead, + - environment varuables must be provided when running a new process. -} +setEnv :: String -> String -> Bool -> IO Bool +#ifndef mingw32_HOST_OS +setEnv var val overwrite = do + PE.setEnv var val overwrite + return True +#else +setEnv _ _ _ = return False +#endif + +{- Returns True if it could successfully unset the environment variable. -} +unsetEnv :: String -> IO Bool +#ifndef mingw32_HOST_OS +unsetEnv var = do + PE.unsetEnv var + return True +#else +unsetEnv _ = return False +#endif + +{- Adds the environment variable to the input environment. If already + - present in the list, removes the old value. + - + - This does not really belong here, but Data.AssocList is for some reason + - buried inside hxt. + -} +addEntry :: Eq k => k -> v -> [(k, v)] -> [(k, v)] +addEntry k v l = ( (k,v) : ) $! delEntry k l + +addEntries :: Eq k => [(k, v)] -> [(k, v)] -> [(k, v)] +addEntries = foldr (.) id . map (uncurry addEntry) . reverse + +delEntry :: Eq k => k -> [(k, v)] -> [(k, v)] +delEntry _ [] = [] +delEntry k (x@(k1,_) : rest) + | k == k1 = rest + | otherwise = ( x : ) $! delEntry k rest diff --git a/src/Utility/Exception.hs b/src/Utility/Exception.hs new file mode 100644 index 00000000..1fecf65d --- /dev/null +++ b/src/Utility/Exception.hs @@ -0,0 +1,59 @@ +{- Simple IO exception handling (and some more) + - + - Copyright 2011-2012 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE ScopedTypeVariables #-} + +module Utility.Exception where + +import Control.Exception +import qualified Control.Exception as E +import Control.Applicative +import Control.Monad +import System.IO.Error (isDoesNotExistError) +import Utility.Data + +{- Catches IO errors and returns a Bool -} +catchBoolIO :: IO Bool -> IO Bool +catchBoolIO = catchDefaultIO False + +{- Catches IO errors and returns a Maybe -} +catchMaybeIO :: IO a -> IO (Maybe a) +catchMaybeIO a = catchDefaultIO Nothing $ Just <$> a + +{- Catches IO errors and returns a default value. -} +catchDefaultIO :: a -> IO a -> IO a +catchDefaultIO def a = catchIO a (const $ return def) + +{- Catches IO errors and returns the error message. -} +catchMsgIO :: IO a -> IO (Either String a) +catchMsgIO a = either (Left . show) Right <$> tryIO a + +{- catch specialized for IO errors only -} +catchIO :: IO a -> (IOException -> IO a) -> IO a +catchIO = E.catch + +{- try specialized for IO errors only -} +tryIO :: IO a -> IO (Either IOException a) +tryIO = try + +{- Catches all exceptions except for async exceptions. + - This is often better to use than catching them all, so that + - ThreadKilled and UserInterrupt get through. + -} +catchNonAsync :: IO a -> (SomeException -> IO a) -> IO a +catchNonAsync a onerr = a `catches` + [ Handler (\ (e :: AsyncException) -> throw e) + , Handler (\ (e :: SomeException) -> onerr e) + ] + +tryNonAsync :: IO a -> IO (Either SomeException a) +tryNonAsync a = (Right <$> a) `catchNonAsync` (return . Left) + +{- Catches only DoesNotExist exceptions, and lets all others through. -} +tryWhenExists :: IO a -> IO (Maybe a) +tryWhenExists a = eitherToMaybe <$> + tryJust (guard . isDoesNotExistError) a diff --git a/src/Utility/FileMode.hs b/src/Utility/FileMode.hs new file mode 100644 index 00000000..c2ef683a --- /dev/null +++ b/src/Utility/FileMode.hs @@ -0,0 +1,158 @@ +{- File mode utilities. + - + - Copyright 2010-2012 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.FileMode where + +import System.IO +import Control.Monad +import Control.Exception (bracket) +import System.PosixCompat.Types +import Utility.PosixFiles +#ifndef mingw32_HOST_OS +import System.Posix.Files +#endif +import Foreign (complement) + +import Utility.Exception + +{- Applies a conversion function to a file's mode. -} +modifyFileMode :: FilePath -> (FileMode -> FileMode) -> IO () +modifyFileMode f convert = void $ modifyFileMode' f convert +modifyFileMode' :: FilePath -> (FileMode -> FileMode) -> IO FileMode +modifyFileMode' f convert = do + s <- getFileStatus f + let old = fileMode s + let new = convert old + when (new /= old) $ + setFileMode f new + return old + +{- Adds the specified FileModes to the input mode, leaving the rest + - unchanged. -} +addModes :: [FileMode] -> FileMode -> FileMode +addModes ms m = combineModes (m:ms) + +{- Removes the specified FileModes from the input mode. -} +removeModes :: [FileMode] -> FileMode -> FileMode +removeModes ms m = m `intersectFileModes` complement (combineModes ms) + +{- Runs an action after changing a file's mode, then restores the old mode. -} +withModifiedFileMode :: FilePath -> (FileMode -> FileMode) -> IO a -> IO a +withModifiedFileMode file convert a = bracket setup cleanup go + where + setup = modifyFileMode' file convert + cleanup oldmode = modifyFileMode file (const oldmode) + go _ = a + +writeModes :: [FileMode] +writeModes = [ownerWriteMode, groupWriteMode, otherWriteMode] + +readModes :: [FileMode] +readModes = [ownerReadMode, groupReadMode, otherReadMode] + +executeModes :: [FileMode] +executeModes = [ownerExecuteMode, groupExecuteMode, otherExecuteMode] + +otherGroupModes :: [FileMode] +otherGroupModes = + [ groupReadMode, otherReadMode + , groupWriteMode, otherWriteMode + ] + +{- Removes the write bits from a file. -} +preventWrite :: FilePath -> IO () +preventWrite f = modifyFileMode f $ removeModes writeModes + +{- Turns a file's owner write bit back on. -} +allowWrite :: FilePath -> IO () +allowWrite f = modifyFileMode f $ addModes [ownerWriteMode] + +{- Turns a file's owner read bit back on. -} +allowRead :: FilePath -> IO () +allowRead f = modifyFileMode f $ addModes [ownerReadMode] + +{- Allows owner and group to read and write to a file. -} +groupSharedModes :: [FileMode] +groupSharedModes = + [ ownerWriteMode, groupWriteMode + , ownerReadMode, groupReadMode + ] + +groupWriteRead :: FilePath -> IO () +groupWriteRead f = modifyFileMode f $ addModes groupSharedModes + +checkMode :: FileMode -> FileMode -> Bool +checkMode checkfor mode = checkfor `intersectFileModes` mode == checkfor + +{- Checks if a file mode indicates it's a symlink. -} +isSymLink :: FileMode -> Bool +#ifdef mingw32_HOST_OS +isSymLink _ = False +#else +isSymLink = checkMode symbolicLinkMode +#endif + +{- Checks if a file has any executable bits set. -} +isExecutable :: FileMode -> Bool +isExecutable mode = combineModes executeModes `intersectFileModes` mode /= 0 + +{- Runs an action without that pesky umask influencing it, unless the + - passed FileMode is the standard one. -} +noUmask :: FileMode -> IO a -> IO a +#ifndef mingw32_HOST_OS +noUmask mode a + | mode == stdFileMode = a + | otherwise = withUmask nullFileMode a +#else +noUmask _ a = a +#endif + +withUmask :: FileMode -> IO a -> IO a +#ifndef mingw32_HOST_OS +withUmask umask a = bracket setup cleanup go + where + setup = setFileCreationMask umask + cleanup = setFileCreationMask + go _ = a +#else +withUmask _ a = a +#endif + +combineModes :: [FileMode] -> FileMode +combineModes [] = undefined +combineModes [m] = m +combineModes (m:ms) = foldl unionFileModes m ms + +isSticky :: FileMode -> Bool +#ifdef mingw32_HOST_OS +isSticky _ = False +#else +isSticky = checkMode stickyMode + +stickyMode :: FileMode +stickyMode = 512 + +setSticky :: FilePath -> IO () +setSticky f = modifyFileMode f $ addModes [stickyMode] +#endif + +{- Writes a file, ensuring that its modes do not allow it to be read + - or written by anyone other than the current user, + - before any content is written. + - + - When possible, this is done using the umask. + - + - On a filesystem that does not support file permissions, this is the same + - as writeFile. + -} +writeFileProtected :: FilePath -> String -> IO () +writeFileProtected file content = withUmask 0o0077 $ + withFile file WriteMode $ \h -> do + void $ tryIO $ modifyFileMode file $ removeModes otherGroupModes + hPutStr h content diff --git a/src/Utility/FileSystemEncoding.hs b/src/Utility/FileSystemEncoding.hs new file mode 100644 index 00000000..b81fdc53 --- /dev/null +++ b/src/Utility/FileSystemEncoding.hs @@ -0,0 +1,132 @@ +{- GHC File system encoding handling. + - + - Copyright 2012-2014 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.FileSystemEncoding ( + fileEncoding, + withFilePath, + md5FilePath, + decodeBS, + decodeW8, + encodeW8, + truncateFilePath, +) where + +import qualified GHC.Foreign as GHC +import qualified GHC.IO.Encoding as Encoding +import Foreign.C +import System.IO +import System.IO.Unsafe +import qualified Data.Hash.MD5 as MD5 +import Data.Word +import Data.Bits.Utils +import qualified Data.ByteString.Lazy as L +#ifdef mingw32_HOST_OS +import qualified Data.ByteString.Lazy.UTF8 as L8 +#endif + +{- Sets a Handle to use the filesystem encoding. This causes data + - written or read from it to be encoded/decoded the same + - as ghc 7.4 does to filenames etc. This special encoding + - allows "arbitrary undecodable bytes to be round-tripped through it". + -} +fileEncoding :: Handle -> IO () +#ifndef mingw32_HOST_OS +fileEncoding h = hSetEncoding h =<< Encoding.getFileSystemEncoding +#else +{- The file system encoding does not work well on Windows, + - and Windows only has utf FilePaths anyway. -} +fileEncoding h = hSetEncoding h Encoding.utf8 +#endif + +{- Marshal a Haskell FilePath into a NUL terminated C string using temporary + - storage. The FilePath is encoded using the filesystem encoding, + - reversing the decoding that should have been done when the FilePath + - was obtained. -} +withFilePath :: FilePath -> (CString -> IO a) -> IO a +withFilePath fp f = Encoding.getFileSystemEncoding + >>= \enc -> GHC.withCString enc fp f + +{- Encodes a FilePath into a String, applying the filesystem encoding. + - + - There are very few things it makes sense to do with such an encoded + - string. It's not a legal filename; it should not be displayed. + - So this function is not exported, but instead used by the few functions + - that can usefully consume it. + - + - This use of unsafePerformIO is belived to be safe; GHC's interface + - only allows doing this conversion with CStrings, and the CString buffer + - is allocated, used, and deallocated within the call, with no side + - effects. + -} +{-# NOINLINE _encodeFilePath #-} +_encodeFilePath :: FilePath -> String +_encodeFilePath fp = unsafePerformIO $ do + enc <- Encoding.getFileSystemEncoding + GHC.withCString enc fp $ GHC.peekCString Encoding.char8 + +{- Encodes a FilePath into a Md5.Str, applying the filesystem encoding. -} +md5FilePath :: FilePath -> MD5.Str +md5FilePath = MD5.Str . _encodeFilePath + +{- Decodes a ByteString into a FilePath, applying the filesystem encoding. -} +decodeBS :: L.ByteString -> FilePath +#ifndef mingw32_HOST_OS +decodeBS = encodeW8 . L.unpack +#else +{- On Windows, we assume that the ByteString is utf-8, since Windows + - only uses unicode for filenames. -} +decodeBS = L8.toString +#endif + +{- Converts a [Word8] to a FilePath, encoding using the filesystem encoding. + - + - w82c produces a String, which may contain Chars that are invalid + - unicode. From there, this is really a simple matter of applying the + - file system encoding, only complicated by GHC's interface to doing so. + -} +{-# NOINLINE encodeW8 #-} +encodeW8 :: [Word8] -> FilePath +encodeW8 w8 = unsafePerformIO $ do + enc <- Encoding.getFileSystemEncoding + GHC.withCString Encoding.char8 (w82s w8) $ GHC.peekCString enc + +{- Useful when you want the actual number of bytes that will be used to + - represent the FilePath on disk. -} +decodeW8 :: FilePath -> [Word8] +decodeW8 = s2w8 . _encodeFilePath + +{- Truncates a FilePath to the given number of bytes (or less), + - as represented on disk. + - + - Avoids returning an invalid part of a unicode byte sequence, at the + - cost of efficiency when running on a large FilePath. + -} +truncateFilePath :: Int -> FilePath -> FilePath +#ifndef mingw32_HOST_OS +truncateFilePath n = go . reverse + where + go f = + let bytes = decodeW8 f + in if length bytes <= n + then reverse f + else go (drop 1 f) +#else +{- On Windows, count the number of bytes used by each utf8 character. -} +truncateFilePath n = reverse . go [] n . L8.fromString + where + go coll cnt bs + | cnt <= 0 = coll + | otherwise = case L8.decode bs of + Just (c, x) | c /= L8.replacement_char -> + let x' = fromIntegral x + in if cnt - x' < 0 + then coll + else go (c:coll) (cnt - x') (L8.drop 1 bs) + _ -> coll +#endif diff --git a/src/Utility/LinuxMkLibs.hs b/src/Utility/LinuxMkLibs.hs new file mode 100644 index 00000000..1dc4e1ea --- /dev/null +++ b/src/Utility/LinuxMkLibs.hs @@ -0,0 +1,61 @@ +{- Linux library copier and binary shimmer + - + - Copyright 2013 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.LinuxMkLibs where + +import Control.Applicative +import Data.Maybe +import System.Directory +import Data.List.Utils +import System.Posix.Files +import Data.Char +import Control.Monad.IfElse + +import Utility.PartialPrelude +import Utility.Directory +import Utility.Process +import Utility.Monad +import Utility.Path + +{- Installs a library. If the library is a symlink to another file, + - install the file it links to, and update the symlink to be relative. -} +installLib :: (FilePath -> FilePath -> IO ()) -> FilePath -> FilePath -> IO (Maybe FilePath) +installLib installfile top lib = ifM (doesFileExist lib) + ( do + installfile top lib + checksymlink lib + return $ Just $ parentDir lib + , return Nothing + ) + where + checksymlink f = whenM (isSymbolicLink <$> getSymbolicLinkStatus (inTop top f)) $ do + l <- readSymbolicLink (inTop top f) + let absl = absPathFrom (parentDir f) l + let target = relPathDirToFile (parentDir f) absl + installfile top absl + nukeFile (top ++ f) + createSymbolicLink target (inTop top f) + checksymlink absl + +-- Note that f is not relative, so cannot use +inTop :: FilePath -> FilePath -> FilePath +inTop top f = top ++ f + +{- Parse ldd output, getting all the libraries that the input files + - link to. Note that some of the libraries may not exist + - (eg, linux-vdso.so) -} +parseLdd :: String -> [FilePath] +parseLdd = mapMaybe (getlib . dropWhile isSpace) . lines + where + getlib l = headMaybe . words =<< lastMaybe (split " => " l) + +{- Get all glibc libs and other support files, including gconv files + - + - XXX Debian specific. -} +glibcLibs :: IO [FilePath] +glibcLibs = lines <$> readProcess "sh" + ["-c", "dpkg -L libc6:$(dpkg --print-architecture) libgcc1:$(dpkg --print-architecture) | egrep '\\.so|gconv'"] diff --git a/src/Utility/Misc.hs b/src/Utility/Misc.hs new file mode 100644 index 00000000..949f41e7 --- /dev/null +++ b/src/Utility/Misc.hs @@ -0,0 +1,148 @@ +{- misc utility functions + - + - Copyright 2010-2011 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.Misc where + +import System.IO +import Control.Monad +import Foreign +import Data.Char +import Data.List +import Control.Applicative +import System.Exit +#ifndef mingw32_HOST_OS +import System.Posix.Process (getAnyProcessStatus) +import Utility.Exception +#endif + +import Utility.FileSystemEncoding +import Utility.Monad + +{- A version of hgetContents that is not lazy. Ensures file is + - all read before it gets closed. -} +hGetContentsStrict :: Handle -> IO String +hGetContentsStrict = hGetContents >=> \s -> length s `seq` return s + +{- A version of readFile that is not lazy. -} +readFileStrict :: FilePath -> IO String +readFileStrict = readFile >=> \s -> length s `seq` return s + +{- Reads a file strictly, and using the FileSystemEncoding, so it will + - never crash on a badly encoded file. -} +readFileStrictAnyEncoding :: FilePath -> IO String +readFileStrictAnyEncoding f = withFile f ReadMode $ \h -> do + fileEncoding h + hClose h `after` hGetContentsStrict h + +{- Writes a file, using the FileSystemEncoding so it will never crash + - on a badly encoded content string. -} +writeFileAnyEncoding :: FilePath -> String -> IO () +writeFileAnyEncoding f content = withFile f WriteMode $ \h -> do + fileEncoding h + hPutStr h content + +{- Like break, but the item matching the condition is not included + - in the second result list. + - + - separate (== ':') "foo:bar" = ("foo", "bar") + - separate (== ':') "foobar" = ("foobar", "") + -} +separate :: (a -> Bool) -> [a] -> ([a], [a]) +separate c l = unbreak $ break c l + where + unbreak r@(a, b) + | null b = r + | otherwise = (a, tail b) + +{- Breaks out the first line. -} +firstLine :: String -> String +firstLine = takeWhile (/= '\n') + +{- Splits a list into segments that are delimited by items matching + - a predicate. (The delimiters are not included in the segments.) + - Segments may be empty. -} +segment :: (a -> Bool) -> [a] -> [[a]] +segment p l = map reverse $ go [] [] l + where + go c r [] = reverse $ c:r + go c r (i:is) + | p i = go [] (c:r) is + | otherwise = go (i:c) r is + +prop_segment_regressionTest :: Bool +prop_segment_regressionTest = all id + -- Even an empty list is a segment. + [ segment (== "--") [] == [[]] + -- There are two segements in this list, even though the first is empty. + , segment (== "--") ["--", "foo", "bar"] == [[],["foo","bar"]] + ] + +{- Includes the delimiters as segments of their own. -} +segmentDelim :: (a -> Bool) -> [a] -> [[a]] +segmentDelim p l = map reverse $ go [] [] l + where + go c r [] = reverse $ c:r + go c r (i:is) + | p i = go [] ([i]:c:r) is + | otherwise = go (i:c) r is + +{- Replaces multiple values in a string. + - + - Takes care to skip over just-replaced values, so that they are not + - mangled. For example, massReplace [("foo", "new foo")] does not + - replace the "new foo" with "new new foo". + -} +massReplace :: [(String, String)] -> String -> String +massReplace vs = go [] vs + where + + go acc _ [] = concat $ reverse acc + go acc [] (c:cs) = go ([c]:acc) vs cs + go acc ((val, replacement):rest) s + | val `isPrefixOf` s = + go (replacement:acc) vs (drop (length val) s) + | otherwise = go acc rest s + +{- Wrapper around hGetBufSome that returns a String. + - + - The null string is returned on eof, otherwise returns whatever + - data is currently available to read from the handle, or waits for + - data to be written to it if none is currently available. + - + - Note on encodings: The normal encoding of the Handle is ignored; + - each byte is converted to a Char. Not unicode clean! + -} +hGetSomeString :: Handle -> Int -> IO String +hGetSomeString h sz = do + fp <- mallocForeignPtrBytes sz + len <- withForeignPtr fp $ \buf -> hGetBufSome h buf sz + map (chr . fromIntegral) <$> withForeignPtr fp (peekbytes len) + where + peekbytes :: Int -> Ptr Word8 -> IO [Word8] + peekbytes len buf = mapM (peekElemOff buf) [0..pred len] + +{- Reaps any zombie git processes. + - + - Warning: Not thread safe. Anything that was expecting to wait + - on a process and get back an exit status is going to be confused + - if this reap gets there first. -} +reapZombies :: IO () +#ifndef mingw32_HOST_OS +reapZombies = do + -- throws an exception when there are no child processes + catchDefaultIO Nothing (getAnyProcessStatus False True) + >>= maybe (return ()) (const reapZombies) + +#else +reapZombies = return () +#endif + +exitBool :: Bool -> IO a +exitBool False = exitFailure +exitBool True = exitSuccess diff --git a/src/Utility/Monad.hs b/src/Utility/Monad.hs new file mode 100644 index 00000000..eba3c428 --- /dev/null +++ b/src/Utility/Monad.hs @@ -0,0 +1,69 @@ +{- monadic stuff + - + - Copyright 2010-2012 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.Monad where + +import Data.Maybe +import Control.Monad + +{- Return the first value from a list, if any, satisfying the given + - predicate -} +firstM :: Monad m => (a -> m Bool) -> [a] -> m (Maybe a) +firstM _ [] = return Nothing +firstM p (x:xs) = ifM (p x) (return $ Just x , firstM p xs) + +{- Runs the action on values from the list until it succeeds, returning + - its result. -} +getM :: Monad m => (a -> m (Maybe b)) -> [a] -> m (Maybe b) +getM _ [] = return Nothing +getM p (x:xs) = maybe (getM p xs) (return . Just) =<< p x + +{- Returns true if any value in the list satisfies the predicate, + - stopping once one is found. -} +anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool +anyM p = liftM isJust . firstM p + +allM :: Monad m => (a -> m Bool) -> [a] -> m Bool +allM _ [] = return True +allM p (x:xs) = p x <&&> allM p xs + +{- Runs an action on values from a list until it succeeds. -} +untilTrue :: Monad m => [a] -> (a -> m Bool) -> m Bool +untilTrue = flip anyM + +{- if with a monadic conditional. -} +ifM :: Monad m => m Bool -> (m a, m a) -> m a +ifM cond (thenclause, elseclause) = do + c <- cond + if c then thenclause else elseclause + +{- short-circuiting monadic || -} +(<||>) :: Monad m => m Bool -> m Bool -> m Bool +ma <||> mb = ifM ma ( return True , mb ) + +{- short-circuiting monadic && -} +(<&&>) :: Monad m => m Bool -> m Bool -> m Bool +ma <&&> mb = ifM ma ( mb , return False ) + +{- Same fixity as && and || -} +infixr 3 <&&> +infixr 2 <||> + +{- Runs an action, passing its value to an observer before returning it. -} +observe :: Monad m => (a -> m b) -> m a -> m a +observe observer a = do + r <- a + _ <- observer r + return r + +{- b `after` a runs first a, then b, and returns the value of a -} +after :: Monad m => m b -> m a -> m a +after = observe . const + +{- do nothing -} +noop :: Monad m => m () +noop = return () diff --git a/src/Utility/PartialPrelude.hs b/src/Utility/PartialPrelude.hs new file mode 100644 index 00000000..6efa093f --- /dev/null +++ b/src/Utility/PartialPrelude.hs @@ -0,0 +1,68 @@ +{- Parts of the Prelude are partial functions, which are a common source of + - bugs. + - + - This exports functions that conflict with the prelude, which avoids + - them being accidentially used. + -} + +module Utility.PartialPrelude where + +import qualified Data.Maybe + +{- read should be avoided, as it throws an error + - Instead, use: readish -} +read :: Read a => String -> a +read = Prelude.read + +{- head is a partial function; head [] is an error + - Instead, use: take 1 or headMaybe -} +head :: [a] -> a +head = Prelude.head + +{- tail is also partial + - Instead, use: drop 1 -} +tail :: [a] -> [a] +tail = Prelude.tail + +{- init too + - Instead, use: beginning -} +init :: [a] -> [a] +init = Prelude.init + +{- last too + - Instead, use: end or lastMaybe -} +last :: [a] -> a +last = Prelude.last + +{- Attempts to read a value from a String. + - + - Ignores leading/trailing whitespace, and throws away any trailing + - text after the part that can be read. + - + - readMaybe is available in Text.Read in new versions of GHC, + - but that one requires the entire string to be consumed. + -} +readish :: Read a => String -> Maybe a +readish s = case reads s of + ((x,_):_) -> Just x + _ -> Nothing + +{- Like head but Nothing on empty list. -} +headMaybe :: [a] -> Maybe a +headMaybe = Data.Maybe.listToMaybe + +{- Like last but Nothing on empty list. -} +lastMaybe :: [a] -> Maybe a +lastMaybe [] = Nothing +lastMaybe v = Just $ Prelude.last v + +{- All but the last element of a list. + - (Like init, but no error on an empty list.) -} +beginning :: [a] -> [a] +beginning [] = [] +beginning l = Prelude.init l + +{- Like last, but no error on an empty list. -} +end :: [a] -> [a] +end [] = [] +end l = [Prelude.last l] diff --git a/src/Utility/Path.hs b/src/Utility/Path.hs new file mode 100644 index 00000000..99c9438b --- /dev/null +++ b/src/Utility/Path.hs @@ -0,0 +1,293 @@ +{- path manipulation + - + - Copyright 2010-2014 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE PackageImports, CPP #-} + +module Utility.Path where + +import Data.String.Utils +import System.FilePath +import System.Directory +import Data.List +import Data.Maybe +import Data.Char +import Control.Applicative + +#ifdef mingw32_HOST_OS +import qualified System.FilePath.Posix as Posix +#else +import System.Posix.Files +#endif + +import qualified "MissingH" System.Path as MissingH +import Utility.Monad +import Utility.UserInfo + +{- Simplifies a path, removing any ".." or ".", and removing the trailing + - path separator. + - + - On Windows, preserves whichever style of path separator might be used in + - the input FilePaths. This is done because some programs in Windows + - demand a particular path separator -- and which one actually varies! + - + - This does not guarantee that two paths that refer to the same location, + - and are both relative to the same location (or both absolute) will + - yeild the same result. Run both through normalise from System.FilePath + - to ensure that. + -} +simplifyPath :: FilePath -> FilePath +simplifyPath path = dropTrailingPathSeparator $ + joinDrive drive $ joinPath $ norm [] $ splitPath path' + where + (drive, path') = splitDrive path + + norm c [] = reverse c + norm c (p:ps) + | p' == ".." = norm (drop 1 c) ps + | p' == "." = norm c ps + | otherwise = norm (p:c) ps + where + p' = dropTrailingPathSeparator p + +{- Makes a path absolute. + - + - The first parameter is a base directory (ie, the cwd) to use if the path + - is not already absolute. + - + - Does not attempt to deal with edge cases or ensure security with + - untrusted inputs. + -} +absPathFrom :: FilePath -> FilePath -> FilePath +absPathFrom dir path = simplifyPath (combine dir path) + +{- On Windows, this converts the paths to unix-style, in order to run + - MissingH's absNormPath on them. Resulting path will use / separators. -} +absNormPathUnix :: FilePath -> FilePath -> Maybe FilePath +#ifndef mingw32_HOST_OS +absNormPathUnix dir path = MissingH.absNormPath dir path +#else +absNormPathUnix dir path = todos <$> MissingH.absNormPath (fromdos dir) (fromdos path) + where + fromdos = replace "\\" "/" + todos = replace "/" "\\" +#endif + +{- Returns the parent directory of a path. + - + - To allow this to be easily used in loops, which terminate upon reaching the + - top, the parent of / is "" -} +parentDir :: FilePath -> FilePath +parentDir dir + | null dirs = "" + | otherwise = joinDrive drive (join s $ init dirs) + where + -- on Unix, the drive will be "/" when the dir is absolute, otherwise "" + (drive, path) = splitDrive dir + dirs = filter (not . null) $ split s path + s = [pathSeparator] + +prop_parentDir_basics :: FilePath -> Bool +prop_parentDir_basics dir + | null dir = True + | dir == "/" = parentDir dir == "" + | otherwise = p /= dir + where + p = parentDir dir + +{- Checks if the first FilePath is, or could be said to contain the second. + - For example, "foo/" contains "foo/bar". Also, "foo", "./foo", "foo/" etc + - are all equivilant. + -} +dirContains :: FilePath -> FilePath -> Bool +dirContains a b = a == b || a' == b' || (addTrailingPathSeparator a') `isPrefixOf` b' + where + a' = norm a + b' = norm b + norm = normalise . simplifyPath + +{- Converts a filename into an absolute path. + - + - Unlike Directory.canonicalizePath, this does not require the path + - already exists. -} +absPath :: FilePath -> IO FilePath +absPath file = do + cwd <- getCurrentDirectory + return $ absPathFrom cwd file + +{- Constructs a relative path from the CWD to a file. + - + - For example, assuming CWD is /tmp/foo/bar: + - relPathCwdToFile "/tmp/foo" == ".." + - relPathCwdToFile "/tmp/foo/bar" == "" + -} +relPathCwdToFile :: FilePath -> IO FilePath +relPathCwdToFile f = relPathDirToFile <$> getCurrentDirectory <*> absPath f + +{- Constructs a relative path from a directory to a file. + - + - Both must be absolute, and cannot contain .. etc. (eg use absPath first). + -} +relPathDirToFile :: FilePath -> FilePath -> FilePath +relPathDirToFile from to = join s $ dotdots ++ uncommon + where + s = [pathSeparator] + pfrom = split s from + pto = split s to + common = map fst $ takeWhile same $ zip pfrom pto + same (c,d) = c == d + uncommon = drop numcommon pto + dotdots = replicate (length pfrom - numcommon) ".." + numcommon = length common + +prop_relPathDirToFile_basics :: FilePath -> FilePath -> Bool +prop_relPathDirToFile_basics from to + | from == to = null r + | otherwise = not (null r) + where + r = relPathDirToFile from to + +prop_relPathDirToFile_regressionTest :: Bool +prop_relPathDirToFile_regressionTest = same_dir_shortcurcuits_at_difference + where + {- Two paths have the same directory component at the same + - location, but it's not really the same directory. + - Code used to get this wrong. -} + same_dir_shortcurcuits_at_difference = + relPathDirToFile (joinPath [pathSeparator : "tmp", "r", "lll", "xxx", "yyy", "18"]) + (joinPath [pathSeparator : "tmp", "r", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"]) + == joinPath ["..", "..", "..", "..", ".git", "annex", "objects", "18", "gk", "SHA256-foo", "SHA256-foo"] + +{- Given an original list of paths, and an expanded list derived from it, + - generates a list of lists, where each sublist corresponds to one of the + - original paths. When the original path is a directory, any items + - in the expanded list that are contained in that directory will appear in + - its segment. + -} +segmentPaths :: [FilePath] -> [FilePath] -> [[FilePath]] +segmentPaths [] new = [new] +segmentPaths [_] new = [new] -- optimisation +segmentPaths (l:ls) new = [found] ++ segmentPaths ls rest + where + (found, rest)=partition (l `dirContains`) new + +{- This assumes that it's cheaper to call segmentPaths on the result, + - than it would be to run the action separately with each path. In + - the case of git file list commands, that assumption tends to hold. + -} +runSegmentPaths :: ([FilePath] -> IO [FilePath]) -> [FilePath] -> IO [[FilePath]] +runSegmentPaths a paths = segmentPaths paths <$> a paths + +{- Converts paths in the home directory to use ~/ -} +relHome :: FilePath -> IO String +relHome path = do + home <- myHomeDir + return $ if dirContains home path + then "~/" ++ relPathDirToFile home path + else path + +{- Checks if a command is available in PATH. + - + - The command may be fully-qualified, in which case, this succeeds as + - long as it exists. -} +inPath :: String -> IO Bool +inPath command = isJust <$> searchPath command + +{- Finds a command in PATH and returns the full path to it. + - + - The command may be fully qualified already, in which case it will + - be returned if it exists. + -} +searchPath :: String -> IO (Maybe FilePath) +searchPath command + | isAbsolute command = check command + | otherwise = getSearchPath >>= getM indir + where + indir d = check $ d command + check f = firstM doesFileExist +#ifdef mingw32_HOST_OS + [f, f ++ ".exe"] +#else + [f] +#endif + +{- Checks if a filename is a unix dotfile. All files inside dotdirs + - count as dotfiles. -} +dotfile :: FilePath -> Bool +dotfile file + | f == "." = False + | f == ".." = False + | f == "" = False + | otherwise = "." `isPrefixOf` f || dotfile (takeDirectory file) + where + f = takeFileName file + +{- Converts a DOS style path to a Cygwin style path. Only on Windows. + - Any trailing '\' is preserved as a trailing '/' -} +toCygPath :: FilePath -> FilePath +#ifndef mingw32_HOST_OS +toCygPath = id +#else +toCygPath p + | null drive = recombine parts + | otherwise = recombine $ "/cygdrive" : driveletter drive : parts + where + (drive, p') = splitDrive p + parts = splitDirectories p' + driveletter = map toLower . takeWhile (/= ':') + recombine = fixtrailing . Posix.joinPath + fixtrailing s + | hasTrailingPathSeparator p = Posix.addTrailingPathSeparator s + | otherwise = s +#endif + +{- Maximum size to use for a file in a specified directory. + - + - Many systems have a 255 byte limit to the name of a file, + - so that's taken as the max if the system has a larger limit, or has no + - limit. + -} +fileNameLengthLimit :: FilePath -> IO Int +#ifdef mingw32_HOST_OS +fileNameLengthLimit _ = return 255 +#else +fileNameLengthLimit dir = do + l <- fromIntegral <$> getPathVar dir FileNameLimit + if l <= 0 + then return 255 + else return $ minimum [l, 255] + where +#endif + +{- Given a string that we'd like to use as the basis for FilePath, but that + - was provided by a third party and is not to be trusted, returns the closest + - sane FilePath. + - + - All spaces and punctuation and other wacky stuff are replaced + - with '_', except for '.' "../" will thus turn into ".._", which is safe. + -} +sanitizeFilePath :: String -> FilePath +sanitizeFilePath = map sanitize + where + sanitize c + | c == '.' = c + | isSpace c || isPunctuation c || isSymbol c || isControl c || c == '/' = '_' + | otherwise = c + +{- Similar to splitExtensions, but knows that some things in FilePaths + - after a dot are too long to be extensions. -} +splitShortExtensions :: FilePath -> (FilePath, [String]) +splitShortExtensions = splitShortExtensions' 5 -- enough for ".jpeg" +splitShortExtensions' :: Int -> FilePath -> (FilePath, [String]) +splitShortExtensions' maxextension = go [] + where + go c f + | len > 0 && len <= maxextension && not (null base) = + go (ext:c) base + | otherwise = (f, c) + where + (base, ext) = splitExtension f + len = length ext diff --git a/src/Utility/PosixFiles.hs b/src/Utility/PosixFiles.hs new file mode 100644 index 00000000..5abbb578 --- /dev/null +++ b/src/Utility/PosixFiles.hs @@ -0,0 +1,33 @@ +{- POSIX files (and compatablity wrappers). + - + - This is like System.PosixCompat.Files, except with a fixed rename. + - + - Copyright 2014 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.PosixFiles ( + module X, + rename +) where + +import System.PosixCompat.Files as X hiding (rename) + +#ifndef mingw32_HOST_OS +import System.Posix.Files (rename) +#else +import qualified System.Win32.File as Win32 +#endif + +{- System.PosixCompat.Files.rename on Windows calls renameFile, + - so cannot rename directories. + - + - Instead, use Win32 moveFile, which can. It needs to be told to overwrite + - any existing file. -} +#ifdef mingw32_HOST_OS +rename :: FilePath -> FilePath -> IO () +rename src dest = Win32.moveFileEx src dest Win32.mOVEFILE_REPLACE_EXISTING +#endif diff --git a/src/Utility/Process.hs b/src/Utility/Process.hs new file mode 100644 index 00000000..549ae570 --- /dev/null +++ b/src/Utility/Process.hs @@ -0,0 +1,364 @@ +{- System.Process enhancements, including additional ways of running + - processes, and logging. + - + - Copyright 2012 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP, Rank2Types #-} + +module Utility.Process ( + module X, + CreateProcess, + StdHandle(..), + readProcess, + readProcessEnv, + writeReadProcessEnv, + forceSuccessProcess, + checkSuccessProcess, + ignoreFailureProcess, + createProcessSuccess, + createProcessChecked, + createBackgroundProcess, + processTranscript, + processTranscript', + withHandle, + withBothHandles, + withQuietOutput, + createProcess, + startInteractiveProcess, + stdinHandle, + stdoutHandle, + stderrHandle, + processHandle, + devNull, +) where + +import qualified System.Process +import System.Process as X hiding (CreateProcess(..), createProcess, runInteractiveProcess, readProcess, readProcessWithExitCode, system, rawSystem, runInteractiveCommand, runProcess) +import System.Process hiding (createProcess, readProcess) +import System.Exit +import System.IO +import System.Log.Logger +import Control.Concurrent +import qualified Control.Exception as E +import Control.Monad +#ifndef mingw32_HOST_OS +import System.Posix.IO +#else +import Control.Applicative +#endif +import Data.Maybe + +import Utility.Misc +import Utility.Exception + +type CreateProcessRunner = forall a. CreateProcess -> ((Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO a) -> IO a + +data StdHandle = StdinHandle | StdoutHandle | StderrHandle + deriving (Eq) + +{- Normally, when reading from a process, it does not need to be fed any + - standard input. -} +readProcess :: FilePath -> [String] -> IO String +readProcess cmd args = readProcessEnv cmd args Nothing + +readProcessEnv :: FilePath -> [String] -> Maybe [(String, String)] -> IO String +readProcessEnv cmd args environ = + withHandle StdoutHandle createProcessSuccess p $ \h -> do + output <- hGetContentsStrict h + hClose h + return output + where + p = (proc cmd args) + { std_out = CreatePipe + , env = environ + } + +{- Runs an action to write to a process on its stdin, + - returns its output, and also allows specifying the environment. + -} +writeReadProcessEnv + :: FilePath + -> [String] + -> Maybe [(String, String)] + -> (Maybe (Handle -> IO ())) + -> (Maybe (Handle -> IO ())) + -> IO String +writeReadProcessEnv cmd args environ writestdin adjusthandle = do + (Just inh, Just outh, _, pid) <- createProcess p + + maybe (return ()) (\a -> a inh) adjusthandle + maybe (return ()) (\a -> a outh) adjusthandle + + -- fork off a thread to start consuming the output + output <- hGetContents outh + outMVar <- newEmptyMVar + _ <- forkIO $ E.evaluate (length output) >> putMVar outMVar () + + -- now write and flush any input + maybe (return ()) (\a -> a inh >> hFlush inh) writestdin + hClose inh -- done with stdin + + -- wait on the output + takeMVar outMVar + hClose outh + + -- wait on the process + forceSuccessProcess p pid + + return output + + where + p = (proc cmd args) + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + , env = environ + } + +{- Waits for a ProcessHandle, and throws an IOError if the process + - did not exit successfully. -} +forceSuccessProcess :: CreateProcess -> ProcessHandle -> IO () +forceSuccessProcess p pid = do + code <- waitForProcess pid + case code of + ExitSuccess -> return () + ExitFailure n -> fail $ showCmd p ++ " exited " ++ show n + +{- Waits for a ProcessHandle and returns True if it exited successfully. + - Note that using this with createProcessChecked will throw away + - the Bool, and is only useful to ignore the exit code of a process, + - while still waiting for it. -} +checkSuccessProcess :: ProcessHandle -> IO Bool +checkSuccessProcess pid = do + code <- waitForProcess pid + return $ code == ExitSuccess + +ignoreFailureProcess :: ProcessHandle -> IO Bool +ignoreFailureProcess pid = do + void $ waitForProcess pid + return True + +{- Runs createProcess, then an action on its handles, and then + - forceSuccessProcess. -} +createProcessSuccess :: CreateProcessRunner +createProcessSuccess p a = createProcessChecked (forceSuccessProcess p) p a + +{- Runs createProcess, then an action on its handles, and then + - a checker action on its exit code, which must wait for the process. -} +createProcessChecked :: (ProcessHandle -> IO b) -> CreateProcessRunner +createProcessChecked checker p a = do + t@(_, _, _, pid) <- createProcess p + r <- tryNonAsync $ a t + _ <- checker pid + either E.throw return r + +{- Leaves the process running, suitable for lazy streaming. + - Note: Zombies will result, and must be waited on. -} +createBackgroundProcess :: CreateProcessRunner +createBackgroundProcess p a = a =<< createProcess p + +{- Runs a process, optionally feeding it some input, and + - returns a transcript combining its stdout and stderr, and + - whether it succeeded or failed. -} +processTranscript :: String -> [String] -> (Maybe String) -> IO (String, Bool) +processTranscript cmd opts input = processTranscript' cmd opts Nothing input + +processTranscript' :: String -> [String] -> Maybe [(String, String)] -> (Maybe String) -> IO (String, Bool) +#ifndef mingw32_HOST_OS +{- This implementation interleves stdout and stderr in exactly the order + - the process writes them. -} +processTranscript' cmd opts environ input = do + (readf, writef) <- createPipe + readh <- fdToHandle readf + writeh <- fdToHandle writef + p@(_, _, _, pid) <- createProcess $ + (proc cmd opts) + { std_in = if isJust input then CreatePipe else Inherit + , std_out = UseHandle writeh + , std_err = UseHandle writeh + , env = environ + } + hClose writeh + + get <- mkreader readh + + -- now write and flush any input + case input of + Just s -> do + let inh = stdinHandle p + unless (null s) $ do + hPutStr inh s + hFlush inh + hClose inh + Nothing -> return () + + transcript <- get + + ok <- checkSuccessProcess pid + return (transcript, ok) +#else +{- This implementation for Windows puts stderr after stdout. -} +processTranscript' cmd opts environ input = do + p@(_, _, _, pid) <- createProcess $ + (proc cmd opts) + { std_in = if isJust input then CreatePipe else Inherit + , std_out = CreatePipe + , std_err = CreatePipe + , env = environ + } + + getout <- mkreader (stdoutHandle p) + geterr <- mkreader (stderrHandle p) + + case input of + Just s -> do + let inh = stdinHandle p + unless (null s) $ do + hPutStr inh s + hFlush inh + hClose inh + Nothing -> return () + + transcript <- (++) <$> getout <*> geterr + ok <- checkSuccessProcess pid + return (transcript, ok) +#endif + where + mkreader h = do + s <- hGetContents h + v <- newEmptyMVar + void $ forkIO $ do + void $ E.evaluate (length s) + putMVar v () + return $ do + takeMVar v + return s + +{- Runs a CreateProcessRunner, on a CreateProcess structure, that + - is adjusted to pipe only from/to a single StdHandle, and passes + - the resulting Handle to an action. -} +withHandle + :: StdHandle + -> CreateProcessRunner + -> CreateProcess + -> (Handle -> IO a) + -> IO a +withHandle h creator p a = creator p' $ a . select + where + base = p + { std_in = Inherit + , std_out = Inherit + , std_err = Inherit + } + (select, p') + | h == StdinHandle = + (stdinHandle, base { std_in = CreatePipe }) + | h == StdoutHandle = + (stdoutHandle, base { std_out = CreatePipe }) + | h == StderrHandle = + (stderrHandle, base { std_err = CreatePipe }) + +{- Like withHandle, but passes (stdin, stdout) handles to the action. -} +withBothHandles + :: CreateProcessRunner + -> CreateProcess + -> ((Handle, Handle) -> IO a) + -> IO a +withBothHandles creator p a = creator p' $ a . bothHandles + where + p' = p + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + } + +{- Forces the CreateProcessRunner to run quietly; + - both stdout and stderr are discarded. -} +withQuietOutput + :: CreateProcessRunner + -> CreateProcess + -> IO () +withQuietOutput creator p = withFile devNull WriteMode $ \nullh -> do + let p' = p + { std_out = UseHandle nullh + , std_err = UseHandle nullh + } + creator p' $ const $ return () + +devNull :: FilePath +#ifndef mingw32_HOST_OS +devNull = "/dev/null" +#else +devNull = "NUL" +#endif + +{- Extract a desired handle from createProcess's tuple. + - These partial functions are safe as long as createProcess is run + - with appropriate parameters to set up the desired handle. + - Get it wrong and the runtime crash will always happen, so should be + - easily noticed. -} +type HandleExtractor = (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> Handle +stdinHandle :: HandleExtractor +stdinHandle (Just h, _, _, _) = h +stdinHandle _ = error "expected stdinHandle" +stdoutHandle :: HandleExtractor +stdoutHandle (_, Just h, _, _) = h +stdoutHandle _ = error "expected stdoutHandle" +stderrHandle :: HandleExtractor +stderrHandle (_, _, Just h, _) = h +stderrHandle _ = error "expected stderrHandle" +bothHandles :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> (Handle, Handle) +bothHandles (Just hin, Just hout, _, _) = (hin, hout) +bothHandles _ = error "expected bothHandles" + +processHandle :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> ProcessHandle +processHandle (_, _, _, pid) = pid + +{- Debugging trace for a CreateProcess. -} +debugProcess :: CreateProcess -> IO () +debugProcess p = do + debugM "Utility.Process" $ unwords + [ action ++ ":" + , showCmd p + ] + where + action + | piped (std_in p) && piped (std_out p) = "chat" + | piped (std_in p) = "feed" + | piped (std_out p) = "read" + | otherwise = "call" + piped Inherit = False + piped _ = True + +{- Shows the command that a CreateProcess will run. -} +showCmd :: CreateProcess -> String +showCmd = go . cmdspec + where + go (ShellCommand s) = s + go (RawCommand c ps) = c ++ " " ++ show ps + +{- Starts an interactive process. Unlike runInteractiveProcess in + - System.Process, stderr is inherited. -} +startInteractiveProcess + :: FilePath + -> [String] + -> Maybe [(String, String)] + -> IO (ProcessHandle, Handle, Handle) +startInteractiveProcess cmd args environ = do + let p = (proc cmd args) + { std_in = CreatePipe + , std_out = CreatePipe + , std_err = Inherit + , env = environ + } + (Just from, Just to, _, pid) <- createProcess p + return (pid, to, from) + +{- Wrapper around System.Process function that does debug logging. -} +createProcess :: CreateProcess -> IO (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) +createProcess p = do + debugProcess p + System.Process.createProcess p diff --git a/src/Utility/QuickCheck.hs b/src/Utility/QuickCheck.hs new file mode 100644 index 00000000..a498ee61 --- /dev/null +++ b/src/Utility/QuickCheck.hs @@ -0,0 +1,52 @@ +{- QuickCheck with additional instances + - + - Copyright 2012-2014 Joey Hess + - + - License: BSD-2-clause + -} + +{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE TypeSynonymInstances #-} + +module Utility.QuickCheck + ( module X + , module Utility.QuickCheck + ) where + +import Test.QuickCheck as X +import Data.Time.Clock.POSIX +import System.Posix.Types +import qualified Data.Map as M +import qualified Data.Set as S +import Control.Applicative + +instance (Arbitrary k, Arbitrary v, Eq k, Ord k) => Arbitrary (M.Map k v) where + arbitrary = M.fromList <$> arbitrary + +instance (Arbitrary v, Eq v, Ord v) => Arbitrary (S.Set v) where + arbitrary = S.fromList <$> arbitrary + +{- Times before the epoch are excluded. -} +instance Arbitrary POSIXTime where + arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral + +instance Arbitrary EpochTime where + arbitrary = fromInteger <$> nonNegative arbitrarySizedIntegral + +{- Pids are never negative, or 0. -} +instance Arbitrary ProcessID where + arbitrary = arbitrarySizedBoundedIntegral `suchThat` (> 0) + +{- Inodes are never negative. -} +instance Arbitrary FileID where + arbitrary = nonNegative arbitrarySizedIntegral + +{- File sizes are never negative. -} +instance Arbitrary FileOffset where + arbitrary = nonNegative arbitrarySizedIntegral + +nonNegative :: (Num a, Ord a) => Gen a -> Gen a +nonNegative g = g `suchThat` (>= 0) + +positive :: (Num a, Ord a) => Gen a -> Gen a +positive g = g `suchThat` (> 0) diff --git a/src/Utility/SafeCommand.hs b/src/Utility/SafeCommand.hs new file mode 100644 index 00000000..04fcf390 --- /dev/null +++ b/src/Utility/SafeCommand.hs @@ -0,0 +1,120 @@ +{- safely running shell commands + - + - Copyright 2010-2013 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.SafeCommand where + +import System.Exit +import Utility.Process +import System.Process (env) +import Data.String.Utils +import Control.Applicative +import System.FilePath +import Data.Char + +{- A type for parameters passed to a shell command. A command can + - be passed either some Params (multiple parameters can be included, + - whitespace-separated, or a single Param (for when parameters contain + - whitespace), or a File. + -} +data CommandParam = Params String | Param String | File FilePath + deriving (Eq, Show, Ord) + +{- Used to pass a list of CommandParams to a function that runs + - a command and expects Strings. -} +toCommand :: [CommandParam] -> [String] +toCommand = concatMap unwrap + where + unwrap (Param s) = [s] + unwrap (Params s) = filter (not . null) (split " " s) + -- Files that start with a non-alphanumeric that is not a path + -- separator are modified to avoid the command interpreting them as + -- options or other special constructs. + unwrap (File s@(h:_)) + | isAlphaNum h || h `elem` pathseps = [s] + | otherwise = ["./" ++ s] + unwrap (File s) = [s] + -- '/' is explicitly included because it's an alternative + -- path separator on Windows. + pathseps = pathSeparator:"./" + +{- Run a system command, and returns True or False + - if it succeeded or failed. + -} +boolSystem :: FilePath -> [CommandParam] -> IO Bool +boolSystem command params = boolSystemEnv command params Nothing + +boolSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO Bool +boolSystemEnv command params environ = dispatch <$> safeSystemEnv command params environ + where + dispatch ExitSuccess = True + dispatch _ = False + +{- Runs a system command, returning the exit status. -} +safeSystem :: FilePath -> [CommandParam] -> IO ExitCode +safeSystem command params = safeSystemEnv command params Nothing + +safeSystemEnv :: FilePath -> [CommandParam] -> Maybe [(String, String)] -> IO ExitCode +safeSystemEnv command params environ = do + (_, _, _, pid) <- createProcess (proc command $ toCommand params) + { env = environ } + waitForProcess pid + +{- Wraps a shell command line inside sh -c, allowing it to be run in a + - login shell that may not support POSIX shell, eg csh. -} +shellWrap :: String -> String +shellWrap cmdline = "sh -c " ++ shellEscape cmdline + +{- Escapes a filename or other parameter to be safely able to be exposed to + - the shell. + - + - This method works for POSIX shells, as well as other shells like csh. + -} +shellEscape :: String -> String +shellEscape f = "'" ++ escaped ++ "'" + where + -- replace ' with '"'"' + escaped = join "'\"'\"'" $ split "'" f + +{- Unescapes a set of shellEscaped words or filenames. -} +shellUnEscape :: String -> [String] +shellUnEscape [] = [] +shellUnEscape s = word : shellUnEscape rest + where + (word, rest) = findword "" s + findword w [] = (w, "") + findword w (c:cs) + | c == ' ' = (w, cs) + | c == '\'' = inquote c w cs + | c == '"' = inquote c w cs + | otherwise = findword (w++[c]) cs + inquote _ w [] = (w, "") + inquote q w (c:cs) + | c == q = findword w cs + | otherwise = inquote q (w++[c]) cs + +{- For quickcheck. -} +prop_idempotent_shellEscape :: String -> Bool +prop_idempotent_shellEscape s = [s] == (shellUnEscape . shellEscape) s +prop_idempotent_shellEscape_multiword :: [String] -> Bool +prop_idempotent_shellEscape_multiword s = s == (shellUnEscape . unwords . map shellEscape) s + +{- Segements a list of filenames into groups that are all below the manximum + - command-line length limit. Does not preserve order. -} +segmentXargs :: [FilePath] -> [[FilePath]] +segmentXargs l = go l [] 0 [] + where + go [] c _ r = c:r + go (f:fs) c accumlen r + | len < maxlen && newlen > maxlen = go (f:fs) [] 0 (c:r) + | otherwise = go fs (f:c) newlen r + where + len = length f + newlen = accumlen + len + + {- 10k of filenames per command, well under Linux's 20k limit; + - allows room for other parameters etc. -} + maxlen = 10240 diff --git a/src/Utility/Scheduled.hs b/src/Utility/Scheduled.hs new file mode 100644 index 00000000..305410c5 --- /dev/null +++ b/src/Utility/Scheduled.hs @@ -0,0 +1,396 @@ +{- scheduled activities + - + - Copyright 2013-2014 Joey Hess + - + - License: BSD-2-clause + -} + +module Utility.Scheduled ( + Schedule(..), + Recurrance(..), + ScheduledTime(..), + NextTime(..), + WeekDay, + MonthDay, + YearDay, + nextTime, + calcNextTime, + startTime, + fromSchedule, + fromScheduledTime, + toScheduledTime, + fromRecurrance, + toRecurrance, + toSchedule, + parseSchedule, + prop_schedule_roundtrips, + prop_past_sane, +) where + +import Utility.Data +import Utility.QuickCheck +import Utility.PartialPrelude +import Utility.Misc + +import Control.Applicative +import Data.List +import Data.Time.Clock +import Data.Time.LocalTime +import Data.Time.Calendar +import Data.Time.Calendar.WeekDate +import Data.Time.Calendar.OrdinalDate +import Data.Tuple.Utils +import Data.Char + +{- Some sort of scheduled event. -} +data Schedule = Schedule Recurrance ScheduledTime + deriving (Eq, Read, Show, Ord) + +data Recurrance + = Daily + | Weekly (Maybe WeekDay) + | Monthly (Maybe MonthDay) + | Yearly (Maybe YearDay) + | Divisible Int Recurrance + -- ^ Days, Weeks, or Months of the year evenly divisible by a number. + -- (Divisible Year is years evenly divisible by a number.) + deriving (Eq, Read, Show, Ord) + +type WeekDay = Int +type MonthDay = Int +type YearDay = Int + +data ScheduledTime + = AnyTime + | SpecificTime Hour Minute + deriving (Eq, Read, Show, Ord) + +type Hour = Int +type Minute = Int + +-- | Next time a Schedule should take effect. The NextTimeWindow is used +-- when a Schedule is allowed to start at some point within the window. +data NextTime + = NextTimeExactly LocalTime + | NextTimeWindow LocalTime LocalTime + deriving (Eq, Read, Show) + +startTime :: NextTime -> LocalTime +startTime (NextTimeExactly t) = t +startTime (NextTimeWindow t _) = t + +nextTime :: Schedule -> Maybe LocalTime -> IO (Maybe NextTime) +nextTime schedule lasttime = do + now <- getCurrentTime + tz <- getTimeZone now + return $ calcNextTime schedule lasttime $ utcToLocalTime tz now + +-- | Calculate the next time that fits a Schedule, based on the +-- last time it occurred, and the current time. +calcNextTime :: Schedule -> Maybe LocalTime -> LocalTime -> Maybe NextTime +calcNextTime schedule@(Schedule recurrance scheduledtime) lasttime currenttime + | scheduledtime == AnyTime = do + next <- findfromtoday True + return $ case next of + NextTimeWindow _ _ -> next + NextTimeExactly t -> window (localDay t) (localDay t) + | otherwise = NextTimeExactly . startTime <$> findfromtoday False + where + findfromtoday anytime = findfrom recurrance afterday today + where + today = localDay currenttime + afterday = sameaslastrun || toolatetoday + toolatetoday = not anytime && localTimeOfDay currenttime >= nexttime + sameaslastrun = lastrun == Just today + lastrun = localDay <$> lasttime + nexttime = case scheduledtime of + AnyTime -> TimeOfDay 0 0 0 + SpecificTime h m -> TimeOfDay h m 0 + exactly d = NextTimeExactly $ LocalTime d nexttime + window startd endd = NextTimeWindow + (LocalTime startd nexttime) + (LocalTime endd (TimeOfDay 23 59 0)) + findfrom r afterday candidate + | ynum candidate > (ynum (localDay currenttime)) + 100 = + -- avoid possible infinite recusion + error $ "bug: calcNextTime did not find a time within 100 years to run " ++ + show (schedule, lasttime, currenttime) + | otherwise = findfromChecked r afterday candidate + findfromChecked r afterday candidate = case r of + Daily + | afterday -> Just $ exactly $ addDays 1 candidate + | otherwise -> Just $ exactly candidate + Weekly Nothing + | afterday -> skip 1 + | otherwise -> case (wday <$> lastrun, wday candidate) of + (Nothing, _) -> Just $ window candidate (addDays 6 candidate) + (Just old, curr) + | old == curr -> Just $ window candidate (addDays 6 candidate) + | otherwise -> skip 1 + Monthly Nothing + | afterday -> skip 1 + | maybe True (candidate `oneMonthPast`) lastrun -> + Just $ window candidate (endOfMonth candidate) + | otherwise -> skip 1 + Yearly Nothing + | afterday -> skip 1 + | maybe True (candidate `oneYearPast`) lastrun -> + Just $ window candidate (endOfYear candidate) + | otherwise -> skip 1 + Weekly (Just w) + | w < 0 || w > maxwday -> Nothing + | w == wday candidate -> if afterday + then Just $ exactly $ addDays 7 candidate + else Just $ exactly candidate + | otherwise -> Just $ exactly $ + addDays (fromIntegral $ (w - wday candidate) `mod` 7) candidate + Monthly (Just m) + | m < 0 || m > maxmday -> Nothing + -- TODO can be done more efficiently than recursing + | m == mday candidate -> if afterday + then skip 1 + else Just $ exactly candidate + | otherwise -> skip 1 + Yearly (Just y) + | y < 0 || y > maxyday -> Nothing + | y == yday candidate -> if afterday + then skip 365 + else Just $ exactly candidate + | otherwise -> skip 1 + Divisible n r'@Daily -> handlediv n r' yday (Just maxyday) + Divisible n r'@(Weekly _) -> handlediv n r' wnum (Just maxwnum) + Divisible n r'@(Monthly _) -> handlediv n r' mnum (Just maxmnum) + Divisible n r'@(Yearly _) -> handlediv n r' ynum Nothing + Divisible _ r'@(Divisible _ _) -> findfrom r' afterday candidate + where + skip n = findfrom r False (addDays n candidate) + handlediv n r' getval mmax + | n > 0 && maybe True (n <=) mmax = + findfromwhere r' (divisible n . getval) afterday candidate + | otherwise = Nothing + findfromwhere r p afterday candidate + | maybe True (p . getday) next = next + | otherwise = maybe Nothing (findfromwhere r p True . getday) next + where + next = findfrom r afterday candidate + getday = localDay . startTime + divisible n v = v `rem` n == 0 + +-- Check if the new Day occurs one month or more past the old Day. +oneMonthPast :: Day -> Day -> Bool +new `oneMonthPast` old = fromGregorian y (m+1) d <= new + where + (y,m,d) = toGregorian old + +-- Check if the new Day occurs one year or more past the old Day. +oneYearPast :: Day -> Day -> Bool +new `oneYearPast` old = fromGregorian (y+1) m d <= new + where + (y,m,d) = toGregorian old + +endOfMonth :: Day -> Day +endOfMonth day = + let (y,m,_d) = toGregorian day + in fromGregorian y m (gregorianMonthLength y m) + +endOfYear :: Day -> Day +endOfYear day = + let (y,_m,_d) = toGregorian day + in endOfMonth (fromGregorian y maxmnum 1) + +-- extracting various quantities from a Day +wday :: Day -> Int +wday = thd3 . toWeekDate +wnum :: Day -> Int +wnum = snd3 . toWeekDate +mday :: Day -> Int +mday = thd3 . toGregorian +mnum :: Day -> Int +mnum = snd3 . toGregorian +yday :: Day -> Int +yday = snd . toOrdinalDate +ynum :: Day -> Int +ynum = fromIntegral . fst . toOrdinalDate + +-- Calendar max values. +maxyday :: Int +maxyday = 366 -- with leap days +maxwnum :: Int +maxwnum = 53 -- some years have more than 52 +maxmday :: Int +maxmday = 31 +maxmnum :: Int +maxmnum = 12 +maxwday :: Int +maxwday = 7 + +fromRecurrance :: Recurrance -> String +fromRecurrance (Divisible n r) = + fromRecurrance' (++ "s divisible by " ++ show n) r +fromRecurrance r = fromRecurrance' ("every " ++) r + +fromRecurrance' :: (String -> String) -> Recurrance -> String +fromRecurrance' a Daily = a "day" +fromRecurrance' a (Weekly n) = onday n (a "week") +fromRecurrance' a (Monthly n) = onday n (a "month") +fromRecurrance' a (Yearly n) = onday n (a "year") +fromRecurrance' a (Divisible _n r) = fromRecurrance' a r -- not used + +onday :: Maybe Int -> String -> String +onday (Just n) s = "on day " ++ show n ++ " of " ++ s +onday Nothing s = s + +toRecurrance :: String -> Maybe Recurrance +toRecurrance s = case words s of + ("every":"day":[]) -> Just Daily + ("on":"day":sd:"of":"every":something:[]) -> withday sd something + ("every":something:[]) -> noday something + ("days":"divisible":"by":sn:[]) -> + Divisible <$> getdivisor sn <*> pure Daily + ("on":"day":sd:"of":something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> withday sd something + ("every":something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> noday something + (something:"divisible":"by":sn:[]) -> + Divisible + <$> getdivisor sn + <*> noday something + _ -> Nothing + where + constructor "week" = Just Weekly + constructor "month" = Just Monthly + constructor "year" = Just Yearly + constructor u + | "s" `isSuffixOf` u = constructor $ reverse $ drop 1 $ reverse u + | otherwise = Nothing + withday sd u = do + c <- constructor u + d <- readish sd + Just $ c (Just d) + noday u = do + c <- constructor u + Just $ c Nothing + getdivisor sn = do + n <- readish sn + if n > 0 + then Just n + else Nothing + +fromScheduledTime :: ScheduledTime -> String +fromScheduledTime AnyTime = "any time" +fromScheduledTime (SpecificTime h m) = + show h' ++ (if m > 0 then ":" ++ pad 2 (show m) else "") ++ " " ++ ampm + where + pad n s = take (n - length s) (repeat '0') ++ s + (h', ampm) + | h == 0 = (12, "AM") + | h < 12 = (h, "AM") + | h == 12 = (h, "PM") + | otherwise = (h - 12, "PM") + +toScheduledTime :: String -> Maybe ScheduledTime +toScheduledTime "any time" = Just AnyTime +toScheduledTime v = case words v of + (s:ampm:[]) + | map toUpper ampm == "AM" -> + go s h0 + | map toUpper ampm == "PM" -> + go s (\h -> (h0 h) + 12) + | otherwise -> Nothing + (s:[]) -> go s id + _ -> Nothing + where + h0 h + | h == 12 = 0 + | otherwise = h + go :: String -> (Int -> Int) -> Maybe ScheduledTime + go s adjust = + let (h, m) = separate (== ':') s + in SpecificTime + <$> (adjust <$> readish h) + <*> if null m then Just 0 else readish m + +fromSchedule :: Schedule -> String +fromSchedule (Schedule recurrance scheduledtime) = unwords + [ fromRecurrance recurrance + , "at" + , fromScheduledTime scheduledtime + ] + +toSchedule :: String -> Maybe Schedule +toSchedule = eitherToMaybe . parseSchedule + +parseSchedule :: String -> Either String Schedule +parseSchedule s = do + r <- maybe (Left $ "bad recurrance: " ++ recurrance) Right + (toRecurrance recurrance) + t <- maybe (Left $ "bad time of day: " ++ scheduledtime) Right + (toScheduledTime scheduledtime) + Right $ Schedule r t + where + (rws, tws) = separate (== "at") (words s) + recurrance = unwords rws + scheduledtime = unwords tws + +instance Arbitrary Schedule where + arbitrary = Schedule <$> arbitrary <*> arbitrary + +instance Arbitrary ScheduledTime where + arbitrary = oneof + [ pure AnyTime + , SpecificTime + <$> choose (0, 23) + <*> choose (1, 59) + ] + +instance Arbitrary Recurrance where + arbitrary = oneof + [ pure Daily + , Weekly <$> arbday + , Monthly <$> arbday + , Yearly <$> arbday + , Divisible + <$> positive arbitrary + <*> oneof -- no nested Divisibles + [ pure Daily + , Weekly <$> arbday + , Monthly <$> arbday + , Yearly <$> arbday + ] + ] + where + arbday = oneof + [ Just <$> nonNegative arbitrary + , pure Nothing + ] + +prop_schedule_roundtrips :: Schedule -> Bool +prop_schedule_roundtrips s = toSchedule (fromSchedule s) == Just s + +prop_past_sane :: Bool +prop_past_sane = and + [ all (checksout oneMonthPast) (mplus1 ++ yplus1) + , all (not . (checksout oneMonthPast)) (map swap (mplus1 ++ yplus1)) + , all (checksout oneYearPast) yplus1 + , all (not . (checksout oneYearPast)) (map swap yplus1) + ] + where + mplus1 = -- new date old date, 1+ months before it + [ (fromGregorian 2014 01 15, fromGregorian 2013 12 15) + , (fromGregorian 2014 01 15, fromGregorian 2013 02 15) + , (fromGregorian 2014 02 15, fromGregorian 2013 01 15) + , (fromGregorian 2014 03 01, fromGregorian 2013 01 15) + , (fromGregorian 2014 03 01, fromGregorian 2013 12 15) + , (fromGregorian 2015 01 01, fromGregorian 2010 01 01) + ] + yplus1 = -- new date old date, 1+ years before it + [ (fromGregorian 2014 01 15, fromGregorian 2012 01 16) + , (fromGregorian 2014 01 15, fromGregorian 2013 01 14) + , (fromGregorian 2022 12 31, fromGregorian 2000 01 01) + ] + checksout cmp (new, old) = new `cmp` old + swap (a,b) = (b,a) diff --git a/src/Utility/ThreadScheduler.hs b/src/Utility/ThreadScheduler.hs new file mode 100644 index 00000000..fc026d7e --- /dev/null +++ b/src/Utility/ThreadScheduler.hs @@ -0,0 +1,75 @@ +{- thread scheduling + - + - Copyright 2012, 2013 Joey Hess + - Copyright 2011 Bas van Dijk & Roel van Dijk + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.ThreadScheduler where + +import Control.Monad +import Control.Concurrent +#ifndef mingw32_HOST_OS +import Control.Monad.IfElse +import System.Posix.IO +#endif +#ifndef mingw32_HOST_OS +import System.Posix.Signals +#ifndef __ANDROID__ +import System.Posix.Terminal +#endif +#endif + +newtype Seconds = Seconds { fromSeconds :: Int } + deriving (Eq, Ord, Show) + +type Microseconds = Integer + +{- Runs an action repeatedly forever, sleeping at least the specified number + - of seconds in between. -} +runEvery :: Seconds -> IO a -> IO a +runEvery n a = forever $ do + threadDelaySeconds n + a + +threadDelaySeconds :: Seconds -> IO () +threadDelaySeconds (Seconds n) = unboundDelay (fromIntegral n * oneSecond) + +{- Like threadDelay, but not bounded by an Int. + - + - There is no guarantee that the thread will be rescheduled promptly when the + - delay has expired, but the thread will never continue to run earlier than + - specified. + - + - Taken from the unbounded-delay package to avoid a dependency for 4 lines + - of code. + -} +unboundDelay :: Microseconds -> IO () +unboundDelay time = do + let maxWait = min time $ toInteger (maxBound :: Int) + threadDelay $ fromInteger maxWait + when (maxWait /= time) $ unboundDelay (time - maxWait) + +{- Pauses the main thread, letting children run until program termination. -} +waitForTermination :: IO () +waitForTermination = do +#ifdef mingw32_HOST_OS + runEvery (Seconds 600) $ + void getLine +#else + lock <- newEmptyMVar + let check sig = void $ + installHandler sig (CatchOnce $ putMVar lock ()) Nothing + check softwareTermination +#ifndef __ANDROID__ + whenM (queryTerminal stdInput) $ + check keyboardSignal +#endif + takeMVar lock +#endif + +oneSecond :: Microseconds +oneSecond = 1000000 diff --git a/src/Utility/Tmp.hs b/src/Utility/Tmp.hs new file mode 100644 index 00000000..0dc9f2c0 --- /dev/null +++ b/src/Utility/Tmp.hs @@ -0,0 +1,100 @@ +{- Temporary files and directories. + - + - Copyright 2010-2013 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.Tmp where + +import Control.Exception (bracket) +import System.IO +import System.Directory +import Control.Monad.IfElse +import System.FilePath + +import Utility.Exception +import Utility.FileSystemEncoding +import Utility.PosixFiles + +type Template = String + +{- Runs an action like writeFile, writing to a temp file first and + - then moving it into place. The temp file is stored in the same + - directory as the final file to avoid cross-device renames. -} +viaTmp :: (FilePath -> String -> IO ()) -> FilePath -> String -> IO () +viaTmp a file content = do + let (dir, base) = splitFileName file + createDirectoryIfMissing True dir + (tmpfile, handle) <- openTempFile dir (base ++ ".tmp") + hClose handle + a tmpfile content + rename tmpfile file + +{- Runs an action with a tmp file located in the system's tmp directory + - (or in "." if there is none) then removes the file. -} +withTmpFile :: Template -> (FilePath -> Handle -> IO a) -> IO a +withTmpFile template a = do + tmpdir <- catchDefaultIO "." getTemporaryDirectory + withTmpFileIn tmpdir template a + +{- Runs an action with a tmp file located in the specified directory, + - then removes the file. -} +withTmpFileIn :: FilePath -> Template -> (FilePath -> Handle -> IO a) -> IO a +withTmpFileIn tmpdir template a = bracket create remove use + where + create = openTempFile tmpdir template + remove (name, handle) = do + hClose handle + catchBoolIO (removeFile name >> return True) + use (name, handle) = a name handle + +{- Runs an action with a tmp directory located within the system's tmp + - directory (or within "." if there is none), then removes the tmp + - directory and all its contents. -} +withTmpDir :: Template -> (FilePath -> IO a) -> IO a +withTmpDir template a = do + tmpdir <- catchDefaultIO "." getTemporaryDirectory + withTmpDirIn tmpdir template a + +{- Runs an action with a tmp directory located within a specified directory, + - then removes the tmp directory and all its contents. -} +withTmpDirIn :: FilePath -> Template -> (FilePath -> IO a) -> IO a +withTmpDirIn tmpdir template = bracket create remove + where + remove d = whenM (doesDirectoryExist d) $ do +#if mingw32_HOST_OS + -- Windows will often refuse to delete a file + -- after a process has just written to it and exited. + -- Because it's crap, presumably. So, ignore failure + -- to delete the temp directory. + _ <- tryIO $ removeDirectoryRecursive d + return () +#else + removeDirectoryRecursive d +#endif + create = do + createDirectoryIfMissing True tmpdir + makenewdir (tmpdir template) (0 :: Int) + makenewdir t n = do + let dir = t ++ "." ++ show n + either (const $ makenewdir t $ n + 1) (const $ return dir) + =<< tryIO (createDirectory dir) + +{- It's not safe to use a FilePath of an existing file as the template + - for openTempFile, because if the FilePath is really long, the tmpfile + - will be longer, and may exceed the maximum filename length. + - + - This generates a template that is never too long. + - (Well, it allocates 20 characters for use in making a unique temp file, + - anyway, which is enough for the current implementation and any + - likely implementation.) + -} +relatedTemplate :: FilePath -> FilePath +relatedTemplate f + | len > 20 = truncateFilePath (len - 20) f + | otherwise = f + where + len = length f diff --git a/src/Utility/UserInfo.hs b/src/Utility/UserInfo.hs new file mode 100644 index 00000000..617c3e94 --- /dev/null +++ b/src/Utility/UserInfo.hs @@ -0,0 +1,55 @@ +{- user info + - + - Copyright 2012 Joey Hess + - + - License: BSD-2-clause + -} + +{-# LANGUAGE CPP #-} + +module Utility.UserInfo ( + myHomeDir, + myUserName, + myUserGecos, +) where + +import Control.Applicative +import System.PosixCompat + +import Utility.Env + +{- Current user's home directory. + - + - getpwent will fail on LDAP or NIS, so use HOME if set. -} +myHomeDir :: IO FilePath +myHomeDir = myVal env homeDirectory + where +#ifndef mingw32_HOST_OS + env = ["HOME"] +#else + env = ["USERPROFILE", "HOME"] -- HOME is used in Cygwin +#endif + +{- Current user's user name. -} +myUserName :: IO String +myUserName = myVal env userName + where +#ifndef mingw32_HOST_OS + env = ["USER", "LOGNAME"] +#else + env = ["USERNAME", "USER", "LOGNAME"] +#endif + +myUserGecos :: IO String +#ifdef __ANDROID__ +myUserGecos = return "" -- userGecos crashes on Android +#else +myUserGecos = myVal [] userGecos +#endif + +myVal :: [String] -> (UserEntry -> String) -> IO String +myVal envvars extract = maybe (extract <$> getpwent) return =<< check envvars + where + check [] = return Nothing + check (v:vs) = maybe (check vs) (return . Just) =<< getEnv v + getpwent = getUserEntryForID =<< getEffectiveUserID diff --git a/src/config.hs b/src/config.hs new file mode 120000 index 00000000..e3af968e --- /dev/null +++ b/src/config.hs @@ -0,0 +1 @@ +../config.hs \ No newline at end of file diff --git a/src/wrapper.hs b/src/wrapper.hs new file mode 100644 index 00000000..f180e855 --- /dev/null +++ b/src/wrapper.hs @@ -0,0 +1,93 @@ +-- | Wrapper program for propellor distribution. +-- +-- Distributions should install this program into PATH. +-- (Cabal builds it as dist/build/propellor. +-- +-- This is not the propellor main program (that's config.hs) +-- +-- This installs propellor's source into ~/.propellor, +-- uses it to build the real propellor program (if not already built), +-- and runs it. +-- +-- The source is either copied from /usr/src/propellor, or is cloned from +-- git over the network. + +module Main where + +import Utility.UserInfo +import Utility.Monad +import Utility.Process +import Utility.SafeCommand +import Utility.Directory + +import Control.Monad +import Control.Monad.IfElse +import System.Directory +import System.FilePath +import System.Environment (getArgs) +import System.Exit +import System.Posix.Directory + +srcdir :: FilePath +srcdir = "/usr/src/propellor" + +-- Using the github mirror of the main propellor repo because +-- it is accessible over https for better security. +srcrepo :: String +srcrepo = "https://github.com/joeyh/propellor.git" + +main :: IO () +main = do + args <- getArgs + home <- myHomeDir + let propellordir = home ".propellor" + let propellorbin = propellordir "propellor" + wrapper args propellordir propellorbin + +wrapper :: [String] -> FilePath -> FilePath -> IO () +wrapper args propellordir propellorbin = do + unlessM (doesDirectoryExist propellordir) $ + makeRepo + buildruncfg + where + chain = do + (_, _, _, pid) <- createProcess (proc propellorbin args) + exitWith =<< waitForProcess pid + makeRepo = do + putStrLn $ "Setting up your propellor repo in " ++ propellordir + putStrLn "" + ifM (doesDirectoryExist srcdir) + ( do + void $ boolSystem "cp" [Param "-a", File srcdir, File propellordir] + changeWorkingDirectory propellordir + void $ boolSystem "git" [Param "init"] + void $ boolSystem "git" [Param "add", Param "."] + setuprepo True + , do + void $ boolSystem "git" [Param "clone", Param srcrepo, File propellordir] + void $ boolSystem "git" [Param "remote", Param "rm", Param "origin"] + setuprepo False + ) + setuprepo fromsrcdir = do + changeWorkingDirectory propellordir + whenM (doesDirectoryExist "privdata") $ + mapM_ nukeFile =<< dirContents "privdata" + void $ boolSystem "git" [Param "commit", Param "--allow-empty", Param "--quiet", Param "-m", Param "setting up propellor git repository"] + void $ boolSystem "git" [Param "remote", Param "add", Param "upstream", Param srcrepo] + -- Connect synthetic git repo with upstream history so + -- merging with upstream will work going forward. + -- Note -s ours is used to avoid getting any divergent + -- changes from upstream. + when fromsrcdir $ do + void $ boolSystem "git" [Param "fetch", Param "upstream"] + version <- readProcess "dpkg-query" ["--showformat", "${Version}", "--show", "propellor"] + void $ boolSystem "git" [Param "merge", Param "-s", Param "ours", Param version] + buildruncfg = do + changeWorkingDirectory propellordir + ifM (boolSystem "make" [Param "build"]) + ( do + putStrLn "" + putStrLn "" + chain + , error "Propellor build failed." + ) diff --git a/wrapper.hs b/wrapper.hs deleted file mode 100644 index f180e855..00000000 --- a/wrapper.hs +++ /dev/null @@ -1,93 +0,0 @@ --- | Wrapper program for propellor distribution. --- --- Distributions should install this program into PATH. --- (Cabal builds it as dist/build/propellor. --- --- This is not the propellor main program (that's config.hs) --- --- This installs propellor's source into ~/.propellor, --- uses it to build the real propellor program (if not already built), --- and runs it. --- --- The source is either copied from /usr/src/propellor, or is cloned from --- git over the network. - -module Main where - -import Utility.UserInfo -import Utility.Monad -import Utility.Process -import Utility.SafeCommand -import Utility.Directory - -import Control.Monad -import Control.Monad.IfElse -import System.Directory -import System.FilePath -import System.Environment (getArgs) -import System.Exit -import System.Posix.Directory - -srcdir :: FilePath -srcdir = "/usr/src/propellor" - --- Using the github mirror of the main propellor repo because --- it is accessible over https for better security. -srcrepo :: String -srcrepo = "https://github.com/joeyh/propellor.git" - -main :: IO () -main = do - args <- getArgs - home <- myHomeDir - let propellordir = home ".propellor" - let propellorbin = propellordir "propellor" - wrapper args propellordir propellorbin - -wrapper :: [String] -> FilePath -> FilePath -> IO () -wrapper args propellordir propellorbin = do - unlessM (doesDirectoryExist propellordir) $ - makeRepo - buildruncfg - where - chain = do - (_, _, _, pid) <- createProcess (proc propellorbin args) - exitWith =<< waitForProcess pid - makeRepo = do - putStrLn $ "Setting up your propellor repo in " ++ propellordir - putStrLn "" - ifM (doesDirectoryExist srcdir) - ( do - void $ boolSystem "cp" [Param "-a", File srcdir, File propellordir] - changeWorkingDirectory propellordir - void $ boolSystem "git" [Param "init"] - void $ boolSystem "git" [Param "add", Param "."] - setuprepo True - , do - void $ boolSystem "git" [Param "clone", Param srcrepo, File propellordir] - void $ boolSystem "git" [Param "remote", Param "rm", Param "origin"] - setuprepo False - ) - setuprepo fromsrcdir = do - changeWorkingDirectory propellordir - whenM (doesDirectoryExist "privdata") $ - mapM_ nukeFile =<< dirContents "privdata" - void $ boolSystem "git" [Param "commit", Param "--allow-empty", Param "--quiet", Param "-m", Param "setting up propellor git repository"] - void $ boolSystem "git" [Param "remote", Param "add", Param "upstream", Param srcrepo] - -- Connect synthetic git repo with upstream history so - -- merging with upstream will work going forward. - -- Note -s ours is used to avoid getting any divergent - -- changes from upstream. - when fromsrcdir $ do - void $ boolSystem "git" [Param "fetch", Param "upstream"] - version <- readProcess "dpkg-query" ["--showformat", "${Version}", "--show", "propellor"] - void $ boolSystem "git" [Param "merge", Param "-s", Param "ours", Param version] - buildruncfg = do - changeWorkingDirectory propellordir - ifM (boolSystem "make" [Param "build"]) - ( do - putStrLn "" - putStrLn "" - chain - , error "Propellor build failed." - ) -- cgit v1.2.3