packages feed

salmon-ops-0.1.0.0: src/Salmon/Builtin/Nodes/User.hs

module Salmon.Builtin.Nodes.User where

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Builtin.Nodes.Filesystem
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter

import Control.Monad (void)
import Data.Text (Text)
import qualified Data.Text as Text

import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.Process.ByteString (readCreateProcessWithExitCode)
import System.Process.ListLike (CreateProcess (..), proc)

-------------------------------------------------------------------------------
data Report
    = RunGroupAdd !GroupAddCommand !Binary.Report
    | RunUserAdd !UserAddCommand !Binary.Report
    | RunUserMod !UserModCommand !Binary.Report
    | RunChown !ChownCommand !Binary.Report
    deriving (Show)

-------------------------------------------------------------------------------

newtype Group = Group {groupName :: Text}

group :: Reporter Report -> Track' (Binary "groupadd") -> Group -> Op
group r groupadd grp =
    withBinary groupadd runGroupAdd cmd $ \add ->
        op "group" nodeps $ \actions ->
            actions
                { help = "creates a system group"
                , ref = mkRef "group" (groupName grp)
                , check = skipIfGroupExists grp
                , up = add r'
                }
  where
    cmd = AddGroup grp.groupName
    r' = contramap (RunGroupAdd cmd) r

data GroupAddCommand
    = AddGroup Text
    deriving (Show)

runGroupAdd :: Command "groupadd" GroupAddCommand
runGroupAdd = Command go
  where
    go (AddGroup name) =
        proc
            "groupadd"
            [ Text.unpack name
            ]

-- | @groupadd@ has no idempotent form (no @-f@-equivalent that's safe across
-- all cases), so skip it via 'check' if @getent group@ already knows about it.
skipIfGroupExists :: Group -> IO CheckResult
skipIfGroupExists grp = do
    (code, _out, _err) <-
        readCreateProcessWithExitCode
            (proc "getent" ["group", Text.unpack grp.groupName])
            ""
    pure $ case code of
        ExitSuccess -> Success
        _ -> Failure ("no such group: " <> grp.groupName)

-------------------------------------------------------------------------------

newtype User = User {userName :: Text}

data NewUser = NewUser {newUser :: User, groups :: [Group]}

user :: Reporter Report -> Track' (Binary "useradd") -> Track' Group -> NewUser -> Op
user r useradd grp nu =
    withBinary useradd runUserAdd cmd $ \add ->
        op "user" (deps userGroups) $ \actions ->
            actions
                { help = "creates a system user"
                , ref = mkRef "user" nu.newUser.userName
                , check = skipIfUserExists nu.newUser
                , up = add r'
                }
  where
    cmd = AddUser nu.newUser.userName (fmap groupName nu.groups)
    r' = contramap (RunUserAdd cmd) r

    userGroups :: [Op]
    userGroups = groupForUser : fmap (run grp) (groups nu)

    groupForUser :: Op
    groupForUser = run grp (Group nu.newUser.userName)

data UserAddCommand
    = AddUser Text [Text]
    deriving (Show)

runUserAdd :: Command "useradd" UserAddCommand
runUserAdd = Command go
  where
    go (AddUser name []) =
        proc
            "useradd"
            [ "-M"
            , "-c"
            , "salmon-created user"
            , "-g"
            , Text.unpack name
            , Text.unpack name
            ]
    go (AddUser name grps) =
        proc
            "useradd"
            [ "-M"
            , "-c"
            , "salmon-created user"
            , "-G"
            , Text.unpack $ Text.intercalate "," grps
            , "-g"
            , Text.unpack name
            , Text.unpack name
            ]

-- | @useradd@ has no idempotent form either, so skip it via 'check' if
-- @getent passwd@ already knows about it.
skipIfUserExists :: User -> IO CheckResult
skipIfUserExists u = do
    (code, _out, _err) <-
        readCreateProcessWithExitCode
            (proc "getent" ["passwd", Text.unpack u.userName])
            ""
    pure $ case code of
        ExitSuccess -> Success
        _ -> Failure ("no such user: " <> u.userName)

-------------------------------------------------------------------------------

-- | Clears a user's password (@usermod -p '*'@), locking out password-based
-- login while leaving the account otherwise usable (e.g. for key-based ssh).
-- @usermod -p@ is a set rather than an add, so it's already idempotent and
-- needs no 'check' guard.
passwordless :: Reporter Report -> Track' (Binary "usermod") -> Track' User -> User -> Op
passwordless r usermod trackUser u =
    withBinary usermod runUserMod cmd $ \remove ->
        op "passwordless" (deps [run trackUser u]) $ \actions ->
            actions
                { help = "removes a system user's password"
                , ref = mkRef "passwordless" u.userName
                , up = remove r'
                }
  where
    cmd = RemovePassword u.userName
    r' = contramap (RunUserMod cmd) r

data UserModCommand
    = RemovePassword Text
    deriving (Show)

runUserMod :: Command "usermod" UserModCommand
runUserMod = Command go
  where
    go (RemovePassword name) =
        proc
            "usermod"
            [ "-p"
            , "*"
            , Text.unpack name
            ]

-------------------------------------------------------------------------------

-- | A user/group pair to hand to 'chown'.
data Owner = Owner {ownerUser :: User, ownerGroup :: Group}

{- | Sets a path's ownership (@chown user:group path@, optionally @-R@).
@chown@ is a set rather than an add, so it's already idempotent and needs no
'check' guard. Unlike 'dir'/'filecontents', this does not itself create the
path — callers are expected to wire the path's own creation as a dependency
(e.g. via 'Salmon.Op.OpGraph.inject').
-}
chown :: Reporter Report -> Track' (Binary "chown") -> Bool -> Owner -> FilePath -> Op
chown r chownBin recursive owner path =
    withBinary chownBin runChown cmd $ \apply ->
        op "chown" nodeps $ \actions ->
            actions
                { help = Text.pack $ "sets ownership of " <> path <> " to " <> Text.unpack ownerText
                , ref = mkRef "chown" (path, owner.ownerUser.userName, owner.ownerGroup.groupName)
                , up = apply r'
                }
  where
    ownerText = owner.ownerUser.userName <> ":" <> owner.ownerGroup.groupName
    cmd = Chown recursive owner.ownerUser.userName owner.ownerGroup.groupName path
    r' = contramap (RunChown cmd) r

data ChownCommand
    = Chown Bool Text Text FilePath
    deriving (Show)

runChown :: Command "chown" ChownCommand
runChown = Command go
  where
    go (Chown recursive u g path) =
        proc
            "chown"
            ( ["-R" | recursive]
                <> [ Text.unpack u <> ":" <> Text.unpack g
                   , path
                   ]
            )