packages feed

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

{- | pgbouncer, configured to sit in front of one or more upstream Postgres
databases and pool connections for a set of client-facing users.

Deliberately decoupled from "Salmon.Builtin.Nodes.Postgres": callers pass
plain host\/port\/dbname\/user\/password values, so this module doesn't need
to know anything about how the upstream cluster/roles were provisioned (they
may not even be managed by salmon on this machine, e.g. a remote replica).
-}
module Salmon.Builtin.Nodes.PgBouncer where

import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Binary (Binary, justInstall)
import qualified Salmon.Builtin.Nodes.Filesystem as FS
import qualified Salmon.Builtin.Nodes.Systemd as Systemd
import Salmon.Op.Track
import Salmon.Reporter

import Data.Text (Text)
import qualified Data.Text as Text

import System.FilePath ((</>))
import System.Process (readProcess)

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

type DbAlias = Text

-- | The upstream Postgres database a client-facing alias is pooled against.
data UpstreamDb
    = UpstreamDb
    { upstream_host :: Text
    , upstream_port :: Int
    , upstream_dbname :: Text
    }
    deriving (Show)

data BouncerDatabase
    = BouncerDatabase
    { bouncer_db_alias :: DbAlias
    -- ^ the name clients connect to through pgbouncer
    , bouncer_db_upstream :: UpstreamDb
    }
    deriving (Show)

-- | A client-facing user allowed to authenticate against pgbouncer.
data AuthUser
    = AuthUser
    { auth_user :: Text
    , auth_password :: Text
    -- ^ cleartext; only ever touches disk already-hashed, in the auth_file
    }

data PoolMode
    = SessionPooling
    | TransactionPooling
    | StatementPooling
    deriving (Show)

poolModeTxt :: PoolMode -> Text
poolModeTxt SessionPooling = "session"
poolModeTxt TransactionPooling = "transaction"
poolModeTxt StatementPooling = "statement"

data BouncerConfig
    = BouncerConfig
    { bouncer_config_dir :: FilePath
    -- ^ e.g. \/etc\/pgbouncer
    , bouncer_listen_addr :: Text
    , bouncer_listen_port :: Int
    , bouncer_databases :: [BouncerDatabase]
    , bouncer_users :: [AuthUser]
    , bouncer_pool_mode :: PoolMode
    , bouncer_max_client_conn :: Int
    , bouncer_default_pool_size :: Int
    , bouncer_admin_users :: [Text]
    -- ^ users allowed on the admin console (database @pgbouncer@), which is
    -- how anything moves traffic without restarting: @PAUSE@, @RELOAD@,
    -- @RESUME@, and the @SHOW@s that say where clients are being sent. They
    -- authenticate like any other user, so each one also belongs in
    -- 'bouncer_users'.
    , bouncer_routing_file :: Maybe FilePath
    -- ^ a second config file, pulled in with @%include@, for databases whose
    -- upstream is somebody else's to decide -- a pair's routing, say
    -- (@SreBox.PostgresPair@).
    --
    -- It exists so that ownership is divisible. This node owns the service
    -- and the static configuration, and watches those files, so that a
    -- change to them is noticed and applied -- by a restart. Traffic must
    -- not move that way: a restart drops every client this process exists to
    -- hold. So the routing file is deliberately /not/ watched, and whoever
    -- owns it applies a change the gentle way, through the admin console.
    -- It must exist before the service starts, since pgbouncer refuses a
    -- missing include.
    }

configPath :: BouncerConfig -> FilePath
configPath cfg = cfg.bouncer_config_dir </> "pgbouncer.ini"

userlistPath :: BouncerConfig -> FilePath
userlistPath cfg = cfg.bouncer_config_dir </> "userlist.txt"

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

-- | Renders @pgbouncer.ini@ and the @auth_file@ (@userlist.txt@, md5-hashed passwords).
configFiles :: BouncerConfig -> Op
configFiles cfg =
    op "pgbouncer-config" (deps [iniFile, userlistFile]) id
  where
    iniFile = FS.filecontents $ FS.FileContents (configPath cfg) (renderIni cfg)
    userlistFile = FS.filecontents $ FS.FileContents (userlistPath cfg) (renderUserlist cfg.bouncer_users)

renderIni :: BouncerConfig -> Text
renderIni cfg =
    Text.unlines $
        mconcat
            [
                [ "[databases]"
                ]
            , fmap renderDb cfg.bouncer_databases
            ,
                [ ""
                , "[pgbouncer]"
                , "listen_addr = " <> cfg.bouncer_listen_addr
                , "listen_port = " <> Text.pack (show cfg.bouncer_listen_port)
                , "auth_type = md5"
                , "auth_file = " <> Text.pack (userlistPath cfg)
                , "pool_mode = " <> poolModeTxt cfg.bouncer_pool_mode
                , "max_client_conn = " <> Text.pack (show cfg.bouncer_max_client_conn)
                , "default_pool_size = " <> Text.pack (show cfg.bouncer_default_pool_size)
                ]
            , ["admin_users = " <> Text.intercalate "," cfg.bouncer_admin_users | not (null cfg.bouncer_admin_users)]
            , maybe [] (\path -> ["", "%include " <> Text.pack path]) cfg.bouncer_routing_file
            ]
  where
    renderDb :: BouncerDatabase -> Text
    renderDb db =
        mconcat
            [ db.bouncer_db_alias
            , " = host="
            , db.bouncer_db_upstream.upstream_host
            , " port="
            , Text.pack (show db.bouncer_db_upstream.upstream_port)
            , " dbname="
            , db.bouncer_db_upstream.upstream_dbname
            ]

-- | @"user" "md5<hex(md5(password<>username))>"@ per entry, one per line —
-- pgbouncer's own md5 @auth_file@ format (matching Postgres's md5 auth scheme).
renderUserlist :: [AuthUser] -> IO Text
renderUserlist users = Text.unlines <$> mapM renderUser users
  where
    renderUser :: AuthUser -> IO Text
    renderUser u = do
        h <- md5AuthHash u.auth_password u.auth_user
        pure $ mconcat ["\"", u.auth_user, "\" \"", h, "\""]

md5AuthHash :: Text -> Text -> IO Text
md5AuthHash password username = do
    out <- readProcess "openssl" ["dgst", "-md5", "-r"] (Text.unpack (password <> username))
    pure $ "md5" <> Text.takeWhile (/= ' ') (Text.pack out)

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

{- | Installs pgbouncer, renders its config, and runs it as a systemd service.

The ini and the userlist are 'Systemd.systemdServiceWatching'\'s watched
files, without which a changed upstream or a rotated password is written to
disk and never reaches the running process: the unit is untouched, so the
service node is skipped. Note that acting on such a change is a restart,
which drops the clients this process exists to hold on to -- a node that
means to move traffic should @PAUSE@ the bouncers, change the config, and
@RESUME@ them, rather than let this node notice on its own.

'bouncer_routing_file' is the seam for exactly that: it is included by the
ini and is /not/ watched here, so the node that owns it can move traffic
through the admin console without this one restarting the service underneath
it. See @SreBox.PostgresPair@, which owns one.
-}
setup :: Reporter Systemd.Report -> Track' (Binary "systemctl") -> Track' (Binary "pgbouncer") -> BouncerConfig -> Op
setup r systemctl pgbouncerBin cfg =
    Systemd.systemdServiceWatching [configPath cfg, userlistPath cfg] r systemctl trackConfig systemdCfg
  where
    trackConfig :: Track' Systemd.Config
    trackConfig = Track $ \_ -> op "pgbouncer-setup" (deps [configFiles cfg, justInstall pgbouncerBin]) id

    systemdCfg :: Systemd.Config
    systemdCfg = Systemd.Config Systemd.System "/etc/systemd/system" "pgbouncer.service" unit svc install

    unit :: Systemd.Unit
    unit = Systemd.Unit "PgBouncer (from Salmon)" "network-online.target"

    svc :: Systemd.Service
    svc = Systemd.Service Systemd.Simple "postgres" "postgres" "0022" start Systemd.OnFailure Systemd.Process cfg.bouncer_config_dir

    start :: Systemd.Start
    start = Systemd.Start "/usr/sbin/pgbouncer" [Text.pack (configPath cfg)]

    install :: Systemd.Install
    install = Systemd.Install "multi-user.target"