summaryrefslogtreecommitdiff
path: root/Propellor/Property
diff options
context:
space:
mode:
authorJoey Hess2014-04-19 02:10:56 -0400
committerJoey Hess2014-04-19 02:10:56 -0400
commit5dd316a0ad4abce5e81ea19e52caf7b57081cda3 (patch)
tree92070fc17e1a57245e1d0f89d5d3bf8599406d85 /Propellor/Property
parent5b4f3d109ee7393b1e44cac60b43def2ce4c8b24 (diff)
parent6aeeaaab9073675e8c043d009c97ff62d809975b (diff)
Merge branch 'joeyconfig'
Diffstat (limited to 'Propellor/Property')
-rw-r--r--Propellor/Property/Apt.hs8
-rw-r--r--Propellor/Property/Cmd.hs3
-rw-r--r--Propellor/Property/Cron.hs2
-rw-r--r--Propellor/Property/Dns.hs390
-rw-r--r--Propellor/Property/Docker.hs53
-rw-r--r--Propellor/Property/File.hs12
-rw-r--r--Propellor/Property/Git.hs2
-rw-r--r--Propellor/Property/Gpg.hs2
-rw-r--r--Propellor/Property/Hostname.hs2
-rw-r--r--Propellor/Property/Obnam.hs17
-rw-r--r--Propellor/Property/Postfix.hs2
-rw-r--r--Propellor/Property/Scheduled.hs6
-rw-r--r--Propellor/Property/Service.hs6
-rw-r--r--Propellor/Property/SiteSpecific/GitAnnexBuilder.hs2
-rw-r--r--Propellor/Property/SiteSpecific/GitHome.hs6
-rw-r--r--Propellor/Property/SiteSpecific/JoeySites.hs61
-rw-r--r--Propellor/Property/Ssh.hs16
-rw-r--r--Propellor/Property/Sudo.hs2
-rw-r--r--Propellor/Property/User.hs2
19 files changed, 479 insertions, 115 deletions
diff --git a/Propellor/Property/Apt.hs b/Propellor/Property/Apt.hs
index d31e8b46..9234cbbf 100644
--- a/Propellor/Property/Apt.hs
+++ b/Propellor/Property/Apt.hs
@@ -157,8 +157,8 @@ buildDepIn dir = go `requires` installedMin ["devscripts", "equivs"]
-- | Package installation may fail becuse the archive has changed.
-- Run an update in that case and retry.
robustly :: Property -> Property
-robustly p = Property (propertyDesc p) $ do
- r <- ensureProperty p
+robustly p = adjustProperty p $ \satisfy -> do
+ r <- satisfy
if r == FailedChange
then ensureProperty $ p `requires` update
else return r
@@ -210,7 +210,7 @@ reConfigure :: Package -> [(String, String, String)] -> Property
reConfigure package vals = reconfigure `requires` setselections
`describe` ("reconfigure " ++ package)
where
- setselections = Property "preseed" $ makeChange $
+ setselections = property "preseed" $ makeChange $
withHandle StdinHandle createProcessSuccess
(proc "debconf-set-selections" []) $ \h -> do
forM_ vals $ \(tmpl, tmpltype, value) ->
@@ -236,7 +236,7 @@ trustsKey k = RevertableProperty trust untrust
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
+ 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)
diff --git a/Propellor/Property/Cmd.hs b/Propellor/Property/Cmd.hs
index 875c1f9a..bcd08246 100644
--- a/Propellor/Property/Cmd.hs
+++ b/Propellor/Property/Cmd.hs
@@ -12,6 +12,7 @@ import Data.List
import "mtl" Control.Monad.Reader
import Propellor.Types
+import Propellor.Property
import Utility.Monad
import Utility.SafeCommand
import Utility.Env
@@ -25,7 +26,7 @@ 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
+cmdProperty' cmd params env = property desc $ liftIO $ do
env' <- addEntries env <$> getEnvironment
ifM (boolSystemEnv cmd (map Param params) (Just env'))
( return MadeChange
diff --git a/Propellor/Property/Cron.hs b/Propellor/Property/Cron.hs
index 0649ee9f..5b070eff 100644
--- a/Propellor/Property/Cron.hs
+++ b/Propellor/Property/Cron.hs
@@ -33,7 +33,7 @@ job desc times user cddir command = cronjobfile `File.hasContent`
`requires` Apt.installed ["util-linux", "moreutils"]
`describe` ("cronned " ++ desc)
where
- cmdline = "cd " ++ cddir ++ " && " ++ command
+ cmdline = "cd " ++ cddir ++ " && ( " ++ command ++ " )"
cronjobfile = "/etc/cron.d/" ++ map sanitize desc
sanitize c
| isAlphaNum c = c
diff --git a/Propellor/Property/Dns.hs b/Propellor/Property/Dns.hs
index 34e790d9..0708417d 100644
--- a/Propellor/Property/Dns.hs
+++ b/Propellor/Property/Dns.hs
@@ -1,49 +1,128 @@
-module Propellor.Property.Dns where
+module Propellor.Property.Dns (
+ module Propellor.Types.Dns,
+ primary,
+ secondary,
+ secondaryFor,
+ mkSOA,
+ rootAddressesFrom,
+ writeZoneFile,
+ nextSerialNumber,
+ adjustSerialNumber,
+ serialNumberOffset,
+ 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
-namedconf :: FilePath
-namedconf = "/etc/bind/named.conf.local"
+import qualified Data.Map as M
+import qualified Data.Set as S
+import Data.List
-data Zone = Zone
- { zdomain :: Domain
- , ztype :: Type
- , zfile :: FilePath
- , zmasters :: [IPAddr]
- , zconfiglines :: [String]
- }
-
-zoneDesc :: Zone -> String
-zoneDesc z = zdomain z ++ " (" ++ show (ztype z) ++ ")"
-
-type IPAddr = String
+-- | 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 [(Domain, Record)] list can be used for additional records
+-- that cannot be configured elsewhere. For example, it might contain
+-- CNAMEs pointing at hosts that propellor does not control.
+primary :: [Host] -> Domain -> SOA -> [(BindDomain, Record)] -> Property
+primary hosts domain soa rs = withwarnings (check needupdate baseprop)
+ `requires` servingZones
+ `onChange` Service.reloaded "bind9"
+ where
+ (partialzone, warnings) = 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 warnings
+ satisfy
+ conf = NamedConf
+ { confDomain = domain
+ , confType = Master
+ , confFile = zonefile
+ , confMasters = []
+ , confLines = []
+ }
+ 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)
-type Domain = String
+-- | 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 -> Property
+secondary hosts domain = secondaryFor masters hosts domain
+ where
+ masters = M.keys $ M.filter ismaster $ hostAttrMap hosts
+ ismaster attr = case M.lookup domain (_namedconf attr) of
+ Nothing -> False
+ Just conf -> confType conf == Master && confDomain conf == domain
-data Type = Master | Secondary
- deriving (Show, Eq)
+-- | This variant is useful if the primary server does not have its DNS
+-- configured via propellor.
+secondaryFor :: [HostName] -> [Host] -> Domain -> Property
+secondaryFor masters hosts domain = pureAttrProperty desc (addNamedConf conf)
+ `requires` servingZones
+ where
+ desc = "dns secondary for " ++ domain
+ conf = NamedConf
+ { confDomain = domain
+ , confType = Secondary
+ , confFile = "db." ++ domain
+ , confMasters = concatMap (\m -> hostAddresses m hosts) masters
+ , confLines = ["allow-transfer { }"]
+ }
-secondary :: Domain -> [IPAddr] -> Zone
-secondary domain masters = Zone
- { zdomain = domain
- , ztype = Secondary
- , zfile = "db." ++ domain
- , zmasters = masters
- , zconfiglines = ["allow-transfer { }"]
- }
+-- | 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 = property "serving configured dns zones" go
+ `requires` Apt.serviceInstalledRunning "bind9"
+ `onChange` Service.reloaded "bind9"
+ where
+ go = do
+ zs <- getNamedConf
+ ensureProperty $
+ hasContent namedConfFile $
+ concatMap confStanza $ M.elems zs
-zoneStanza :: Zone -> [Line]
-zoneStanza z =
+confStanza :: NamedConf -> [Line]
+confStanza c =
[ "// automatically generated by propellor"
- , "zone \"" ++ zdomain z ++ "\" {"
- , cfgline "type" (if ztype z == Master then "master" else "slave")
- , cfgline "file" ("\"" ++ zfile z ++ "\"")
+ , "zone \"" ++ confDomain c ++ "\" {"
+ , cfgline "type" (if confType c == Master then "master" else "slave")
+ , cfgline "file" ("\"" ++ confFile c ++ "\"")
] ++
- (if null (zmasters z) then [] else mastersblock) ++
- (map (\l -> "\t" ++ l ++ ";") (zconfiglines z)) ++
+ (if null (confMasters c) then [] else mastersblock) ++
+ (map (\l -> "\t" ++ l ++ ";") (confLines c)) ++
[ "};"
, ""
]
@@ -51,13 +130,242 @@ zoneStanza z =
cfgline f v = "\t" ++ f ++ " " ++ v ++ ";"
mastersblock =
[ "\tmasters {" ] ++
- (map (\ip -> "\t\t" ++ ip ++ ";") (zmasters z)) ++
+ (map (\ip -> "\t\t" ++ fromIPAddr ip ++ ";") (confMasters c)) ++
[ "\t};" ]
--- | Rewrites the whole named.conf.local file to serve the specificed
--- zones.
-zones :: [Zone] -> Property
-zones zs = hasContent namedconf (concatMap zoneStanza zs)
- `describe` ("dns server for zones: " ++ unwords (map zoneDesc zs))
- `requires` Apt.serviceInstalledRunning "bind9"
- `onChange` Service.reloaded "bind9"
+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.
+--
+-- Handy trick: You don't need to list IPAddrs in the [Record],
+-- just make some Host sets its `alias` to the root of domain.
+mkSOA :: Domain -> SerialNumber -> [Record] -> SOA
+mkSOA d sn rs = SOA
+ { sDomain = AbsDomain d
+ , sSerial = sn
+ , sRefresh = hours 4
+ , sRetry = hours 1
+ , sExpire = 2419200 -- 4 weeks
+ , sNegativeCacheTTL = hours 8
+ , sRecord = rs
+ }
+ where
+ hours n = n * 60 * 60
+
+rootAddressesFrom :: [Host] -> HostName -> [Record]
+rootAddressesFrom hosts hn = map Address (hostAddresses hn hosts)
+
+dValue :: BindDomain -> String
+dValue (RelDomain d) = d
+dValue (AbsDomain d) = d ++ "."
+dValue (SOADomain) = "@"
+
+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.
+-- * 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'
+
+-- | 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 zdomain soa ++ map genr rs
+ where
+ header = com $ "BIND zone file for " ++ zdomain ++ ". Generated by propellor, do not edit."
+
+ genr (d, r) = genRecord zdomain (Just d, r)
+
+genRecord :: Domain -> (Maybe BindDomain, Record) -> String
+genRecord zdomain (mdomain, record) = intercalate "\t"
+ [ hn
+ , "IN"
+ , rField record
+ , rValue record
+ ]
+ where
+ hn = maybe "" (domainHost zdomain) mdomain
+
+genSOA :: Domain -> SOA -> [String]
+genSOA zdomain soa =
+ header ++ map (genRecord zdomain) (zip (repeat Nothing) (sRecord soa))
+ where
+ header =
+ -- "@ IN SOA ns1.example.com. root ("
+ [ intercalate "\t"
+ [ dValue SOADomain
+ , "IN"
+ , "SOA"
+ , dValue (sDomain soa)
+ , "root"
+ , "("
+ ]
+ , headerline sSerial "Serial"
+ , headerline sRefresh "Refresh"
+ , headerline sRetry "Retry"
+ , headerline sExpire "Expire"
+ , headerline sNegativeCacheTTL "Negative Cache TTL"
+ , inheader ")"
+ ]
+ 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 _ SOADomain = "@"
+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
index d2555ea5..e5b8d64a 100644
--- a/Propellor/Property/Docker.hs
+++ b/Propellor/Property/Docker.hs
@@ -25,7 +25,7 @@ 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
+configured = property "docker configured" go `requires` installed
where
go = withPrivData DockerAuthentication $ \cfg -> ensureProperty $
"/root/.dockercfg" `File.hasContent` (lines cfg)
@@ -64,7 +64,7 @@ docked
-> RevertableProperty
docked hosts cn = RevertableProperty (go "docked" setup) (go "undocked" teardown)
where
- go desc a = Property (desc ++ " " ++ cn) $ do
+ go desc a = property (desc ++ " " ++ cn) $ do
hn <- getHostName
let cid = ContainerId hn cn
ensureProperties [findContainer hosts cid cn $ a cid]
@@ -79,7 +79,7 @@ docked hosts cn = RevertableProperty (go "docked" setup) (go "undocked" teardown
teardown cid (Container image _runparams) =
combineProperties ("undocked " ++ fromContainerId cid)
[ stoppedContainer cid
- , Property ("cleaned up " ++ fromContainerId cid) $
+ , property ("cleaned up " ++ fromContainerId cid) $
liftIO $ report <$> mapM id
[ removeContainer cid
, removeImage image
@@ -96,7 +96,7 @@ 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
+ cantfind = containerDesc cid $ property "" $ do
liftIO $ warningMessage $
"missing definition for docker container \"" ++ cn2hn cn
return FailedChange
@@ -126,9 +126,9 @@ garbageCollected = propertyList "docker garbage collected"
, gcimages
]
where
- gccontainers = Property "docker containers garbage collected" $
+ gccontainers = property "docker containers garbage collected" $
liftIO $ report <$> (mapM removeContainer =<< listContainers AllContainers)
- gcimages = Property "docker images garbage collected" $ do
+ gcimages = property "docker images garbage collected" $ do
liftIO $ report <$> (mapM removeImage =<< listImages)
data Container = Container Image [RunParam]
@@ -140,51 +140,51 @@ type RunParam = String
type Image = String
-- | Set custom dns server for container.
-dns :: String -> AttrProperty
+dns :: String -> Property
dns = runProp "dns"
-- | Set container host name.
-hostname :: String -> AttrProperty
+hostname :: String -> Property
hostname = runProp "hostname"
-- | Set name for container. (Normally done automatically.)
-name :: String -> AttrProperty
+name :: String -> Property
name = runProp "name"
-- | Publish a container's port to the host
-- (format: ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort)
-publish :: String -> AttrProperty
+publish :: String -> Property
publish = runProp "publish"
-- | Username or UID for container.
-user :: String -> AttrProperty
+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 -> AttrProperty
+volume :: String -> Property
volume = runProp "volume"
-- | Mount a volume from the specified container into the current
-- container.
-volumes_from :: ContainerName -> AttrProperty
+volumes_from :: ContainerName -> Property
volumes_from cn = genProp "volumes-from" $ \hn ->
fromContainerId (ContainerId hn cn)
-- | Work dir inside the container.
-workdir :: String -> AttrProperty
+workdir :: String -> Property
workdir = runProp "workdir"
-- | Memory limit for container.
--Format: <number><optional unit>, where unit = b, k, m or g
-memory :: String -> AttrProperty
+memory :: String -> Property
memory = runProp "memory"
-- | Link with another container on the same host.
-link :: ContainerName -> ContainerAlias -> AttrProperty
-link linkwith alias = genProp "link" $ \hn ->
- fromContainerId (ContainerId hn linkwith) ++ ":" ++ alias
+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.
@@ -230,7 +230,7 @@ containerDesc cid p = p `describe` desc
desc = "[" ++ fromContainerId cid ++ "] " ++ propertyDesc p
runningContainer :: ContainerId -> Image -> [RunParam] -> Property
-runningContainer cid@(ContainerId hn cn) image runps = containerDesc cid $ Property "running" $ do
+runningContainer cid@(ContainerId hn cn) image runps = containerDesc cid $ property "running" $ do
l <- liftIO $ listContainers RunningContainers
if cid `elem` l
then do
@@ -324,7 +324,7 @@ chain s = case toContainerId s of
-- being run. So, retry connections to the client for up to
-- 1 minute.
provisionContainer :: ContainerId -> Property
-provisionContainer cid = containerDesc cid $ Property "provision" $ liftIO $ do
+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) $
@@ -356,7 +356,7 @@ stopContainer :: ContainerId -> IO Bool
stopContainer cid = boolSystem dockercmd [Param "stop", Param $ fromContainerId cid ]
stoppedContainer :: ContainerId -> Property
-stoppedContainer cid = containerDesc cid $ Property desc $
+stoppedContainer cid = containerDesc cid $ property desc $
ifM (liftIO $ elem cid <$> listContainers RunningContainers)
( liftIO cleanup `after` ensureProperty
(boolProperty desc $ stopContainer cid)
@@ -405,18 +405,15 @@ listContainers status =
listImages :: IO [Image]
listImages = lines <$> readProcess dockercmd ["images", "--all", "--quiet"]
-runProp :: String -> RunParam -> AttrProperty
-runProp field val = AttrProperty prop $ \attr ->
+runProp :: String -> RunParam -> Property
+runProp field val = pureAttrProperty (param) $ \attr ->
attr { _dockerRunParams = _dockerRunParams attr ++ [\_ -> "--"++param] }
where
param = field++"="++val
- prop = Property (param) (return NoChange)
-genProp :: String -> (HostName -> RunParam) -> AttrProperty
-genProp field mkval = AttrProperty prop $ \attr ->
+genProp :: String -> (HostName -> RunParam) -> Property
+genProp field mkval = pureAttrProperty field $ \attr ->
attr { _dockerRunParams = _dockerRunParams attr ++ [\hn -> "--"++field++"=" ++ mkval hn] }
- where
- prop = Property field (return NoChange)
-- | The ContainerIdent of a container is written to
-- /.propellor-ident inside it. This can be checked to see if
diff --git a/Propellor/Property/File.hs b/Propellor/Property/File.hs
index 8f23dab7..0b060177 100644
--- a/Propellor/Property/File.hs
+++ b/Propellor/Property/File.hs
@@ -18,7 +18,7 @@ f `hasContent` newcontent = fileProperty ("replace " ++ f)
-- 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 ->
+hasPrivContent f = property desc $ withPrivData (PrivFile f) $ \privcontent ->
ensureProperty $ fileProperty' writeFileProtected desc
(\_oldcontent -> lines privcontent) f
where
@@ -48,13 +48,13 @@ 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") $
+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)
+fileProperty' writer desc a f = property desc $ go =<< liftIO (doesFileExist f)
where
go True = do
ls <- liftIO $ lines <$> readFile f
@@ -74,12 +74,12 @@ fileProperty' writer desc a f = Property desc $ go =<< liftIO (doesFileExist f)
-- | Ensures a directory exists.
dirExists :: FilePath -> Property
-dirExists d = check (not <$> doesDirectoryExist d) $ Property (d ++ " exists") $
+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
+ownerGroup f owner group = property (f ++ " owner " ++ og) $ do
r <- ensureProperty $ cmdProperty "chown" [og, f]
if r == FailedChange
then return r
@@ -89,6 +89,6 @@ ownerGroup f owner group = Property (f ++ " owner " ++ og) $ do
-- | Ensures that a file/dir has the specfied mode.
mode :: FilePath -> FileMode -> Property
-mode f v = Property (f ++ " mode " ++ show v) $ do
+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
index 1dae94bf..ba370e51 100644
--- a/Propellor/Property/Git.hs
+++ b/Propellor/Property/Git.hs
@@ -62,7 +62,7 @@ type Branch = String
--
-- 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)
+cloned owner url dir mbranch = check originurl (property desc checkout)
`requires` installed
where
desc = "git cloned " ++ url ++ " to " ++ dir
diff --git a/Propellor/Property/Gpg.hs b/Propellor/Property/Gpg.hs
index e23111bb..64ea9fea 100644
--- a/Propellor/Property/Gpg.hs
+++ b/Propellor/Property/Gpg.hs
@@ -21,7 +21,7 @@ installed = Apt.installed ["gnupg"]
-- 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
+keyImported keyid user = flagFile' (property desc go) genflag
`requires` installed
where
desc = user ++ " has gpg key " ++ show keyid
diff --git a/Propellor/Property/Hostname.hs b/Propellor/Property/Hostname.hs
index 30e0992d..031abb9d 100644
--- a/Propellor/Property/Hostname.hs
+++ b/Propellor/Property/Hostname.hs
@@ -9,7 +9,7 @@ import qualified Propellor.Property.File as File
-- 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)
+sane = property ("sane hostname") (ensureProperty . setTo =<< getHostName)
setTo :: HostName -> Property
setTo hn = combineProperties desc go
diff --git a/Propellor/Property/Obnam.hs b/Propellor/Property/Obnam.hs
index 4d0584bb..32374b57 100644
--- a/Propellor/Property/Obnam.hs
+++ b/Propellor/Property/Obnam.hs
@@ -65,7 +65,7 @@ backup dir crontimes params numclients = cronjob `describe` desc
-- 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
+restored dir params = property (dir ++ " restored by obnam") go
`requires` installed
where
go = ifM (liftIO needsRestore)
@@ -97,14 +97,17 @@ installed = Apt.installed ["obnam"]
-- | Ensures that a recent version of obnam gets installed.
--
--- Only useful on Stable.
+-- Only does anything for Debian Stable.
latestVersion :: Property
-latestVersion = propertyList "obnam latest version"
- [ toProp $ Apt.trustsKey key
- , Apt.setSourcesListD sources "obnam"
- ]
+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 = ["deb http://code.liw.fi/debian wheezy main"]
+ 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-----"
diff --git a/Propellor/Property/Postfix.hs b/Propellor/Property/Postfix.hs
index f4be27cf..9fa4a2c3 100644
--- a/Propellor/Property/Postfix.hs
+++ b/Propellor/Property/Postfix.hs
@@ -15,7 +15,7 @@ installed = Apt.serviceInstalledRunning "postfix"
satellite :: Property
satellite = setup `requires` installed
where
- setup = trivial $ Property "postfix satellite system" $ do
+ setup = trivial $ property "postfix satellite system" $ do
hn <- getHostName
ensureProperty $ Apt.reConfigure "postfix"
[ ("postfix/main_mailer_type", "select", "Satellite system")
diff --git a/Propellor/Property/Scheduled.hs b/Propellor/Property/Scheduled.hs
index 769a3931..f2911e50 100644
--- a/Propellor/Property/Scheduled.hs
+++ b/Propellor/Property/Scheduled.hs
@@ -19,13 +19,13 @@ import qualified Data.Map as M
-- This uses the description of the Property to keep track of when it was
-- last run.
period :: Property -> Recurrance -> Property
-period prop recurrance = Property desc $ do
+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 <- ensureProperty prop
+ r <- satisfy
liftIO $ setLastChecked t (propertyDesc prop)
return r
else noChange
@@ -37,7 +37,7 @@ period prop recurrance = Property desc $ do
periodParse :: Property -> String -> Property
periodParse prop s = case toRecurrance s of
Just recurrance -> period prop recurrance
- Nothing -> Property "periodParse" $ do
+ Nothing -> property "periodParse" $ do
liftIO $ warningMessage $ "failed periodParse: " ++ s
noChange
diff --git a/Propellor/Property/Service.hs b/Propellor/Property/Service.hs
index c6498e57..14e769d0 100644
--- a/Propellor/Property/Service.hs
+++ b/Propellor/Property/Service.hs
@@ -13,19 +13,19 @@ type ServiceName = String
-- 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
+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
+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
+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
index 204a9ca7..677aa760 100644
--- a/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs
+++ b/Propellor/Property/SiteSpecific/GitAnnexBuilder.hs
@@ -40,7 +40,7 @@ builder arch crontimes rsyncupload = combineProperties "gitannexbuilder"
-- 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
+ , property "rsync password" $ do
let f = homedir </> "rsyncpassword"
if rsyncupload
then withPrivData (Password builduser) $ \p -> do
diff --git a/Propellor/Property/SiteSpecific/GitHome.hs b/Propellor/Property/SiteSpecific/GitHome.hs
index ee46a9e4..6ed02146 100644
--- a/Propellor/Property/SiteSpecific/GitHome.hs
+++ b/Propellor/Property/SiteSpecific/GitHome.hs
@@ -8,16 +8,16 @@ 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))
+ 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 $
+ , property "moveout" $ makeChange $ void $
moveout tmpdir home
- , Property "rmdir" $ makeChange $ void $
+ , property "rmdir" $ makeChange $ void $
catchMaybeIO $ removeDirectory tmpdir
, userScriptProperty user ["rm -rf .aptitude/ .bashrc .profile; bin/mr checkout; bin/fixups"]
]
diff --git a/Propellor/Property/SiteSpecific/JoeySites.hs b/Propellor/Property/SiteSpecific/JoeySites.hs
index 3d0ff246..b43d83f8 100644
--- a/Propellor/Property/SiteSpecific/JoeySites.hs
+++ b/Propellor/Property/SiteSpecific/JoeySites.hs
@@ -16,9 +16,63 @@ import qualified Propellor.Property.Obnam as Obnam
import qualified Propellor.Property.Apache as Apache
import Utility.SafeCommand
+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 ++ "/"
+ , " <Directory " ++ 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"
+ , " </Directory>"
+ ]
+ ]
+ where
+ newsspool = "/var/spool/news"
+ datadir = "/var/spool/oldusenet"
+
oldUseNetShellBox :: Property
-oldUseNetShellBox = check (not <$> Apt.isInstalled "oldusenet") $
- propertyList ("olduse.net shellbox")
+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
@@ -26,12 +80,13 @@ oldUseNetShellBox = check (not <$> Apt.isInstalled "oldusenet") $
, "git clone git://olduse.net/ /root/tmp/oldusenet/source"
, "cd /root/tmp/oldusenet/source/"
, "dpkg-buildpackage -us -uc"
- , "dpkg -i ../oldusenet*.deb || true"
+ , "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) _)) ->
diff --git a/Propellor/Property/Ssh.hs b/Propellor/Property/Ssh.hs
index a39792cf..a4f87678 100644
--- a/Propellor/Property/Ssh.hs
+++ b/Propellor/Property/Ssh.hs
@@ -67,7 +67,7 @@ randomHostKeys :: Property
randomHostKeys = flagFile prop "/etc/ssh/.unique_host_keys"
`onChange` restartSshd
where
- prop = Property "ssh random host keys" $ do
+ prop = property "ssh random host keys" $ do
void $ liftIO $ boolSystem "sh"
[ Param "-c"
, Param "rm -f /etc/ssh/ssh_host_*"
@@ -81,8 +81,8 @@ randomHostKeys = flagFile prop "/etc/ssh/.unique_host_keys"
-- (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 "") "")
+ [ property desc (install writeFile (SshPubKey keytype "") ".pub")
+ , property desc (install writeFileProtected (SshPrivKey keytype "") "")
]
`onChange` restartSshd
where
@@ -98,8 +98,8 @@ hostKey keytype = combineProperties desc
-- 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) "")
+ [ property desc (install writeFile (SshPubKey keytype user) ".pub")
+ , property desc (install writeFileProtected (SshPrivKey keytype user) "")
]
where
desc = user ++ " has ssh key (" ++ fromKeyType keytype ++ ")"
@@ -108,7 +108,7 @@ keyImported keytype user = combineProperties desc
ifM (liftIO $ doesFileExist f)
( noChange
, ensureProperty $ combineProperties desc
- [ Property desc $
+ [ property desc $
withPrivData p $ \key -> makeChange $
writer f key
, File.ownerGroup f user user
@@ -126,7 +126,7 @@ 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 $
+knownHost hosts hn user = property desc $
go =<< fromHost hosts hn getSshPubKey
where
desc = user ++ " knows ssh key for " ++ hn
@@ -143,7 +143,7 @@ knownHost hosts hn user = Property desc $
-- | Makes a user have authorized_keys from the PrivData
authorizedKeys :: UserName -> Property
-authorizedKeys user = Property (user ++ " has authorized_keys") $
+authorizedKeys user = property (user ++ " has authorized_keys") $
withPrivData (SshAuthorizedKeys user) $ \v -> do
f <- liftIO $ dotFile "authorized_keys" user
liftIO $ do
diff --git a/Propellor/Property/Sudo.hs b/Propellor/Property/Sudo.hs
index 66ceb580..68b56608 100644
--- a/Propellor/Property/Sudo.hs
+++ b/Propellor/Property/Sudo.hs
@@ -10,7 +10,7 @@ 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"]
+enabledFor user = property desc go `requires` Apt.installed ["sudo"]
where
go = do
locked <- liftIO $ isLockedPassword user
diff --git a/Propellor/Property/User.hs b/Propellor/Property/User.hs
index 8e7afd81..eef2a57e 100644
--- a/Propellor/Property/User.hs
+++ b/Propellor/Property/User.hs
@@ -29,7 +29,7 @@ hasSomePassword user = check ((/= HasPassword) <$> getPasswordStatus user) $
hasPassword user
hasPassword :: UserName -> Property
-hasPassword user = Property (user ++ " has password") $
+hasPassword user = property (user ++ " has password") $
withPrivData (Password user) $ \password -> makeChange $
withHandle StdinHandle createProcessSuccess
(proc "chpasswd" []) $ \h -> do