packages feed

salmon-apps-0.1.0.0: src/PgBackup.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

{- | @salmon-pg-backup@: take a Postgres dump, here or on another machine, or
install the job that keeps taking one.

Two axes, chosen independently, which is the whole shape of the binary:

* __what to do__ — @--action dump@ takes one backup now; @--action schedule@
  installs a @cron.d@ entry (and takes one immediately, so a broken
  credential is discovered now rather than at 03:17 tomorrow).
* __where__ — with no @--over@, on this machine. With @--over user\@host@,
  this binary uploads /itself/ to that machine, runs there with the same
  directive (role flipped), and — for @dump@ — pulls the resulting file
  back.

That is four useful combinations out of two flags, and the remote ones need
nothing installed on the target beyond @rsync@, @bash@ and a Postgres client:
the salmon binary that does the work is the one you are already running.

= Why the timestamp is frozen in the directive

A dump is named after the moment it is taken, and the machine that wants to
/fetch/ it is not the machine that takes it. If each side asks its own clock,
the two disagree whenever they land either side of a second — rarely, which
is the worst frequency for a bug of this kind. So @configure@ resolves the
timestamp once, on the controlling machine, and it travels in the directive.
Both ends then name the same file, and re-running the same directive is
idempotent: the node's check sees the dump already there and skips it.

= Credentials

Never on a command line. @--pgpass@, @--pg-service@ and @--client-cert@ all
become environment variables (see
"SreBox.PostgresBackup".'SreBox.PostgresBackup.Credentials'); the default is
peer authentication as a local OS user over the unix socket, which needs no
credential at all and is what a job running on the database host should use.
This binary creates none of them: getting a credential onto a machine is a
separate problem with a separate answer per site.
-}
module PgBackup (
    main,
    Seed (..),
    Spec (..),
    Role (..),
    Action (..),
    RemoteSpec (..),
    configure,
    program,
    remoteDumpPath,
) where

import Control.Monad (when)
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time (defaultTimeLocale, formatTime, getCurrentTime)
import GHC.Generics (Generic)
import Options.Applicative (auto, execParser, fullDesc, header, helper, info, long, metavar, option, optional, progDesc, strOption, value, (<**>), (<|>))
import qualified Options.Applicative as Opt
import Options.Generic (ParseRecord (..))
import System.FilePath ((</>))

import qualified Salmon.Builtin.CommandLine as CLI
import Salmon.Builtin.Extension
import qualified Salmon.Builtin.Nodes.CronTask as Cron
import qualified Salmon.Builtin.Nodes.Debian.OS as OS
import qualified Salmon.Builtin.Nodes.Filesystem as FS
import qualified Salmon.Builtin.Nodes.Postgres as Postgres
import qualified Salmon.Builtin.Nodes.Rsync as Rsync
import qualified Salmon.Builtin.Nodes.Self as Self
import qualified Salmon.Builtin.Nodes.Ssh as Ssh
import Salmon.Op.Configure (Configure (..))
import Salmon.Op.OpGraph (inject)
import Salmon.Op.Ref (mkRef)
import Salmon.Op.Track (Track (..), trackedGraph)
import Salmon.Reporter (reportPrint)

import qualified SreBox.PostgresBackup as Backup

main :: IO ()
main = do
    let desc = fullDesc <> progDesc "Take or schedule a Postgres backup, here or over ssh" <> header "salmon-pg-backup"
    cmd <- execParser (info parseRecord desc)
    CLI.execCommandOrSeed reportPrint configure program cmd

-------------------------------------------------------------------------------
-- Seed

data Seed = Seed
    { seedDatabase :: Text
    , seedPgRole :: Maybe Text
    , seedHost :: Maybe Text
    , seedPort :: Int
    , seedSudoUser :: Maybe Text
    , seedDir :: FilePath
    , seedScript :: Maybe FilePath
    , seedCronUser :: Text
    , seedCredentials :: Backup.Credentials
    , seedPrefix :: Maybe Text
    , seedTimestampFormat :: Text
    , seedSuffix :: Text
    , seedRetentionDays :: Int
    , seedMaxAgeHours :: Int
    , seedHour :: Text
    , seedMinute :: Text
    , seedGcsBucket :: Maybe Text
    , seedGcsPrefix :: Text
    , seedAction :: Action
    , seedOver :: Maybe Text
    , seedRemoteDir :: FilePath
    , seedIdentity :: Maybe FilePath
    , seedKnownHosts :: Maybe FilePath
    , seedFetchInto :: Maybe FilePath
    }
    deriving (Eq, Show)

instance ParseRecord Seed where
    parseRecord =
        build <**> helper
      where
        build =
            Seed
                <$> strOption (long "database" <> metavar "DB" <> Opt.help "database to dump")
                <*> optional (strOption (long "pg-role" <> metavar "ROLE" <> Opt.help "connect as this database role (default: whatever the OS user maps to)"))
                <*> optional (strOption (long "host" <> metavar "HOST" <> Opt.help "connect over TCP to this host (default: the local unix socket, which is the only place peer auth exists)"))
                <*> option auto (long "port" <> value 5432 <> Opt.showDefault <> Opt.help "port, when --host is given")
                <*> sudoUserP
                <*> strOption (long "dir" <> value "/var/backups/postgresql" <> Opt.showDefault <> Opt.help "where dumps are written, on whichever machine runs the dump")
                <*> optional (strOption (long "script" <> metavar "PATH" <> Opt.help "where the generated script is written (default: /opt/salmon/postgres/backup-<db>.sh)"))
                <*> strOption (long "cron-user" <> value "root" <> Opt.showDefault <> Opt.help "the user the cron entry runs as")
                <*> credentialsP
                <*> optional (strOption (long "prefix" <> metavar "NAME" <> Opt.help "dump filename prefix (default: the database name)"))
                <*> strOption (long "timestamp-format" <> value "%Y%m%d_%H%M%S" <> Opt.showDefault <> Opt.help "date(1) format for the timestamp in the filename")
                <*> strOption (long "suffix" <> value ".sql.gz" <> Opt.showDefault <> Opt.help "dump filename suffix")
                <*> option auto (long "retention-days" <> value 7 <> Opt.showDefault <> Opt.help "delete local dumps older than this")
                <*> option auto (long "max-age-hours" <> value 26 <> Opt.showDefault <> Opt.help "how old the newest dump may be before the freshness check fails")
                <*> strOption (long "hour" <> value "3" <> Opt.showDefault <> Opt.help "schedule: hour")
                <*> strOption (long "minute" <> value "17" <> Opt.showDefault <> Opt.help "schedule: minute (an odd one, so a fleet does not stampede)")
                <*> optional (strOption (long "gcs-bucket" <> metavar "BUCKET" <> Opt.help "also copy each dump to this GCS bucket"))
                <*> strOption (long "gcs-prefix" <> value "" <> Opt.help "path prefix inside the bucket")
                <*> actionP
                <*> optional (strOption (long "over" <> metavar "USER@HOST" <> Opt.help "do the work on this machine instead, by uploading this binary to it"))
                <*> strOption (long "remote-dir" <> value "/tmp" <> Opt.showDefault <> Opt.help "where this binary is uploaded on the remote")
                <*> optional (strOption (long "ssh-identity" <> metavar "PATH" <> Opt.help "ssh private key for --over"))
                <*> optional (strOption (long "ssh-known-hosts" <> metavar "PATH" <> Opt.help "known_hosts file for --over"))
                <*> optional (strOption (long "fetch-into" <> metavar "DIR" <> Opt.help "with --over and --action dump: pull the dump back into this local directory"))

        -- `--sudo-user` has a default, so it can never be *absent*; opting
        -- out needs a flag of its own.
        sudoUserP =
            Opt.flag' Nothing (long "no-sudo" <> Opt.help "run pg_dump as the current user rather than via sudo")
                <|> (Just <$> strOption (long "sudo-user" <> value "postgres" <> Opt.showDefault <> Opt.help "run pg_dump as this OS user"))

        actionP =
            Opt.option
                (Opt.eitherReader parseAction)
                (long "action" <> value DumpNow <> metavar "dump|schedule" <> Opt.help "take one backup now, or install the periodic job")

        parseAction "dump" = Right DumpNow
        parseAction "schedule" = Right InstallSchedule
        parseAction other = Left ("expected dump or schedule, got: " <> other)

        -- xor by construction: the first branch that matches wins, and the
        -- flags are distinct, so no two credential mechanisms can be given.
        credentialsP =
            (Backup.PassFile <$> strOption (long "pgpass" <> metavar "PATH" <> Opt.help "a .pgpass file (PGPASSFILE)"))
                <|> ( Backup.ServiceFile
                        <$> strOption (long "pg-service-file" <> metavar "PATH" <> Opt.help "a libpq service file (PGSERVICEFILE)")
                        <*> strOption (long "pg-service" <> metavar "NAME" <> Opt.help "the service to use from it (PGSERVICE)")
                    )
                <|> ( Backup.ClientCertificate
                        <$> strOption (long "client-cert" <> metavar "PATH" <> Opt.help "client certificate (PGSSLCERT)")
                        <*> strOption (long "client-key" <> metavar "PATH" <> Opt.help "client key (PGSSLKEY)")
                        <*> strOption (long "client-ca" <> metavar "PATH" <> Opt.help "CA certificate (PGSSLROOTCERT)")
                    )
                <|> pure Backup.PeerAuth

-------------------------------------------------------------------------------
-- Spec

{- | Which side of the hand-off a directive is for. 'OnTarget' is what the
uploaded copy of this binary receives; it names no remote of its own, which
is what stops a directive bouncing forever.
-}
data Role = Controller | OnTarget
    deriving (Eq, Show, Generic)

instance FromJSON Role
instance ToJSON Role

data Action = DumpNow | InstallSchedule
    deriving (Eq, Show, Generic)

instance FromJSON Action
instance ToJSON Action

data RemoteSpec = RemoteSpec
    { rs_user :: Text
    , rs_host :: Text
    , rs_selfDir :: FilePath
    , rs_identity :: Maybe FilePath
    , rs_knownHosts :: Maybe FilePath
    }
    deriving (Eq, Show, Generic)

instance FromJSON RemoteSpec
instance ToJSON RemoteSpec

data Spec = Spec
    { role :: Role
    , action :: Action
    , backup :: Backup.PgBackupConfig
    , stamp :: Maybe Text
    -- ^ the frozen timestamp; see the module header
    , remote :: Maybe RemoteSpec
    , fetchInto :: Maybe FilePath
    , selfPath :: Maybe Self.SelfPath
    }
    deriving (Eq, Show, Generic)

instance FromJSON Spec
instance ToJSON Spec

configure :: Configure IO Seed Spec
configure = Configure $ \seed -> do
    when (seed.seedFetchInto /= Nothing && seed.seedOver == Nothing) $
        fail "--fetch-into only means something with --over (without it, the dump is already local)"
    when (seed.seedFetchInto /= Nothing && seed.seedAction /= DumpNow) $
        fail "--fetch-into only means something with --action dump"
    -- Frozen here, on the controlling machine, so both ends of a driven
    -- backup name the same file. A scheduled job must NOT have one, or every
    -- run would overwrite a single dump forever.
    frozen <- case seed.seedAction of
        InstallSchedule -> pure Nothing
        DumpNow -> do
            now <- getCurrentTime
            pure (Just (Text.pack (formatTime defaultTimeLocale (Text.unpack seed.seedTimestampFormat) now)))
    self <- traverse (const Self.readSelfPath_linux) seed.seedOver
    remoteSpec <- traverse (parseOver seed) seed.seedOver
    pure $
        Spec
            { role = Controller
            , action = seed.seedAction
            , backup = backupConfig seed frozen
            , stamp = frozen
            , remote = remoteSpec
            , fetchInto = seed.seedFetchInto
            , selfPath = self
            }
  where
    parseOver :: Seed -> Text -> IO RemoteSpec
    parseOver seed spec =
        case Text.breakOn "@" spec of
            (user, rest)
                | Just host <- Text.stripPrefix "@" rest
                , not (Text.null user)
                , not (Text.null host) ->
                    pure
                        RemoteSpec
                            { rs_user = user
                            , rs_host = host
                            , rs_selfDir = seed.seedRemoteDir
                            , rs_identity = seed.seedIdentity
                            , rs_knownHosts = seed.seedKnownHosts
                            }
            _ -> fail ("--over wants USER@HOST, got: " <> Text.unpack spec)

backupConfig :: Seed -> Maybe Text -> Backup.PgBackupConfig
backupConfig seed frozen =
    Backup.PgBackupConfig
        { Backup.pgb_database = seed.seedDatabase
        , Backup.pgb_role = seed.seedPgRole
        , Backup.pgb_server = fmap (\h -> Postgres.Server h seed.seedPort) seed.seedHost
        , Backup.pgb_sudoUser = seed.seedSudoUser
        , Backup.pgb_dir = seed.seedDir
        , Backup.pgb_scriptPath =
            maybe ("/opt/salmon/postgres/backup-" <> Text.unpack seed.seedDatabase <> ".sh") id seed.seedScript
        , Backup.pgb_osUser = seed.seedCronUser
        , Backup.pgb_credentials = seed.seedCredentials
        , Backup.pgb_naming =
            Backup.NamingPolicy
                { Backup.np_prefix = maybe seed.seedDatabase id seed.seedPrefix
                , Backup.np_timestampFormat = seed.seedTimestampFormat
                , Backup.np_suffix = seed.seedSuffix
                }
        , Backup.pgb_fixedTimestamp = frozen
        , Backup.pgb_retentionDays = seed.seedRetentionDays
        , Backup.pgb_schedule = Cron.dailyAt seed.seedHour seed.seedMinute
        , Backup.pgb_gcs = fmap (\b -> Backup.GcsDestination b seed.seedGcsPrefix) seed.seedGcsBucket
        , Backup.pgb_maxAge = fromIntegral seed.seedMaxAgeHours * 3600
        }

-------------------------------------------------------------------------------
-- Program

program :: Track' Spec
program = Track $ \spec -> case spec.role of
    OnTarget -> onTarget spec
    Controller -> case spec.remote of
        Nothing -> onTarget spec
        Just rs -> driven spec rs

{- | The work itself, on whichever machine is running it. Reached either
directly (no @--over@) or as the uploaded copy.
-}
onTarget :: Spec -> Op
onTarget spec =
    op "pg-backup-target" (deps [work]) $ \actions ->
        actions
            { help = Text.unwords ["backs up", spec.backup.pgb_database, "on this machine"]
            , ref = mkRef "pg-backup-target" (spec.backup.pgb_database, spec.backup.pgb_dir)
            }
  where
    work = case spec.action of
        InstallSchedule -> Backup.postgresBackup reportPrint OS.bash spec.backup
        DumpNow -> case spec.stamp of
            -- The named form is what makes a re-run of the same directive a
            -- no-op, and it is the only form whose output another machine
            -- can name in advance.
            Just st -> Backup.namedBackup reportPrint OS.bash spec.backup st
            Nothing -> Backup.recentBackup reportPrint OS.bash spec.backup

{- | Upload this binary to the target, run it there, and (for a dump) pull
the result back.

The fetch is injected onto the remote call, not merely declared beside it:
rsync would otherwise be racing the dump it is fetching.
-}
driven :: Spec -> RemoteSpec -> Op
driven spec rs =
    op "pg-backup-driven" (deps [maybe remoteWork id fetch]) $ \actions ->
        actions
            { help = Text.unwords ["backs up", spec.backup.pgb_database, "on", rs.rs_host]
            , ref = mkRef "pg-backup-driven" (rs.rs_user, rs.rs_host, spec.backup.pgb_database)
            }
  where
    opts =
        Ssh.ClientOpts
            { Ssh.optIdentity = rs.rs_identity
            , Ssh.optKnownHosts = rs.rs_knownHosts
            }

    remoteWork :: Op
    remoteWork =
        trackedGraph $
            Self.uploadAndCallSelfAsSudoWith
                opts
                reportPrint
                reportPrint
                rs.rs_selfDir
                (Self.Remote rs.rs_user rs.rs_host)
                (maybe (error "configure should have read the self path") id spec.selfPath)
                Ssh.preExistingRemoteMachine
                program
                CLI.Up
                spec{role = OnTarget, remote = Nothing}

    fetch :: Maybe Op
    fetch = do
        into <- spec.fetchInto
        st <- spec.stamp
        let local = into </> Backup.dumpNameFor spec.backup.pgb_naming st
        pure $
            Rsync.receiveFileWith
                opts
                reportPrint
                OS.rsync
                (Track FS.dir)
                (Rsync.Remote rs.rs_user rs.rs_host)
                (remoteDumpPath spec st)
                local
                `inject` remoteWork

{- | Where the dump will be on the target, given the frozen timestamp.

Exposed because it is the contract between the two halves: the remote writes
this path and the controller fetches it, and nothing else reconciles them.
-}
remoteDumpPath :: Spec -> Text -> FilePath
remoteDumpPath spec st =
    spec.backup.pgb_dir </> Backup.dumpNameFor spec.backup.pgb_naming st