salmon-ops-recipes-0.1.0.0: src/SreBox/PostgresMigrations.hs
{-# LANGUAGE DeriveGeneric #-}
module SreBox.PostgresMigrations where
import Control.Comonad (extract)
import Control.Comonad.Cofree (Cofree (..))
import Control.Exception (throwIO)
import Control.Monad (unless)
import Control.Monad.Identity
import Crypto.Hash.SHA256 as SHA256
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.ByteString.Base16 as Base16
import Data.ByteString.Char8 as C8
import Data.Coerce (coerce)
import Data.Dynamic (toDyn)
import Data.Foldable (toList)
import Data.List (stripPrefix)
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import GHC.Generics
import System.FilePath (takeDirectory, (</>))
import System.IO.Error (userError)
import qualified Salmon.Actions.Dot as Dot
import qualified Salmon.Actions.UpDown as UpDown
import qualified Salmon.Builtin.CommandLine as CLI
import Salmon.Builtin.Extension
import Salmon.Builtin.Helpers (collapse)
import Salmon.Builtin.Migrations
import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
import Salmon.Builtin.Nodes.Debian.OS as Debian
import Salmon.Builtin.Nodes.Filesystem as FS
import Salmon.Builtin.Nodes.Postgres (ConnString (..), Database (..), DatabaseName, Password, User, adminScript, connstring, localServer, readPassword, userScript, withPassword)
import qualified Salmon.Builtin.Nodes.Postgres as Postgres
import qualified Salmon.Builtin.Nodes.Rsync as Rsync
import qualified Salmon.Builtin.Nodes.Secrets as Secrets
import qualified Salmon.Builtin.Nodes.Self as Self
import qualified Salmon.Builtin.Nodes.Ssh as Ssh
import Salmon.Op.Eval
import Salmon.Op.G
import Salmon.Op.Graph
import Salmon.Op.OpGraph
import Salmon.Op.Ref
import Salmon.Op.Track
import Salmon.Reporter
-------------------------------------------------------------------------------
data Report
= ApplyMigration !FilePath !Postgres.Report
| UploadFile !Rsync.Report
| UploadSecretFile !Rsync.Report
| UploadSelf !Self.Report
| CallSelf !Self.Report
| CreatePassword !Secrets.Report
| Continuation !(UpDown.Report Extension)
deriving (Show)
-------------------------------------------------------------------------------
data MigrationFile
= MigrationFile
{ path :: FilePath
}
deriving (Show, Generic)
instance FromJSON MigrationFile
instance ToJSON MigrationFile
defaultMigrationReader :: MigrationReader MigrationFile
defaultMigrationReader =
MigrationReader
(pure . MigrationFile)
(parsePredecessors)
id
where
parsePredecessors :: C8.ByteString -> [FilePath]
parsePredecessors txt =
catMaybes $ fmap parsePredecessorLine $ C8.lines txt
parsePredecessorLine :: C8.ByteString -> Maybe FilePath
parsePredecessorLine line =
C8.unpack <$> C8.stripPrefix prefix line
prefix :: C8.ByteString
prefix = "-- migrate.after: "
data MigrateStyle
= MigrateUserScript (Track' (ConnString FilePath)) (ConnString FilePath)
| MigrateAdminScript (Track' DatabaseName) DatabaseName
migrateG ::
Reporter Report ->
Track' (Binary "psql") ->
MigrateStyle ->
G MigrationFile ->
Op
migrateG r psql style g =
migrate r psql style (coerce g)
-- | A helper to turn a migration graph into an Op.
migrate ::
Reporter Report ->
Track' (Binary "psql") ->
MigrateStyle ->
Cofree Graph MigrationFile ->
Op
migrate r psql style (m :< x) =
let
current :: Op
current =
case style of
MigrateUserScript mksetup connstring ->
userScript r' psql mksetup connstring (PreExisting m.path)
MigrateAdminScript mksetup dbname ->
adminScript r' psql localServer.serverPort mksetup dbname (PreExisting m.path)
pred :: Op
pred = evalPred "" x
in
current `inject` pred
where
r' = contramap (ApplyMigration m.path) r
gorec = migrate r psql style
setref lineage actions =
actions{ref = mkRef "migration" (m.path, lineage)}
-- the string in eval pred accumulates left/right branches choices to disambiguate noop nodes by ref
evalPred :: String -> Graph (Cofree Graph MigrationFile) -> Op
evalPred l (Vertices []) = realNoop
evalPred l (Vertices zs) =
op "migrate:deps" (deps $ fmap gorec zs) (setref l)
evalPred l (Overlay g1 g2) =
op "migrate:deps" (deps [evalPred ('l' : l) g1, evalPred ('r' : l) g2]) (setref l)
evalPred l (Connect g1 g2) =
op "migrate:deps" (deps [evalPred ('l' : l) g2 `inject` evalPred ('r' : l) g1]) (setref l)
-------------------------------------------------------------------------------
data MigrationPlan a
= MigrationPlan
{ migration_connstring :: ConnString a
, migration_file :: G MigrationFile
}
deriving (Generic)
instance (ToJSON a) => ToJSON (MigrationPlan a)
instance (FromJSON a) => FromJSON (MigrationPlan a)
-------------------------------------------------------------------------------
data RemoteMigrateConfig t
= RemoteMigrateConfig
{ cfg_migrations :: t (G MigrationFile)
, cfg_remoteMigrationPath :: MigrationFile -> FilePath
, cfg_user :: User
, cfg_database :: Database
, cfg_password :: FilePath
, cfg_extra_users :: [(User, FilePath)]
}
defaultRemoteMigrationDir :: DatabaseName -> FilePath
defaultRemoteMigrationDir dbname =
"tmp/migrations" </> Text.unpack dbname
defaultRemoteMigrationPath :: DatabaseName -> MigrationFile -> FilePath
defaultRemoteMigrationPath dbname m =
defaultRemoteMigrationDir dbname </> shafile <> ".sql"
where
shafile = C8.unpack $ Base16.encode $ SHA256.hash (C8.pack m.path)
remoteMigrateOpaqueSetup ::
(FromJSON directive, ToJSON directive) =>
Text ->
Reporter Report ->
Track' directive ->
Self.Remote ->
Self.SelfPath ->
(Either PrepareRemoteMigrationSetup MigrationSetup -> directive) ->
RemoteMigrateConfig TrackedIO ->
Op
remoteMigrateOpaqueSetup uniquename r simulate selfRemote selfpath toSpec cfg =
op "migrate-remotely" (deps [opaquemigration `inject` uploadsecrets]) $ \actions ->
actions
{ help = Text.unwords ["remotely apply the (opaque) migration", uniquename]
, ref = mkRef "remote-migrate" uniquename
}
where
rsyncRemote :: Rsync.Remote
rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
opaquemigration =
using (unwrapTIO cfg.cfg_migrations) $ \ioMigrations ->
op "opaque-migrate" nodeps $ \actions ->
actions
{ ref = mkRef "opaque-migration" uniquename
, up = do
migrations <- ioMigrations
let prepare = remotePrepare (toList migrations)
let uploads = collapse "." uploadmigrationFile (getCofreeGraph migrations)
let apply = remoteApply migrations
ok <- UpDown.upTree (contramap Continuation r) (pure . runIdentity) (apply `inject` uploads `inject` prepare)
-- 'up' has to throw, not just report, to fail loudly: this is a nested
-- upTree run from inside another node's 'up', so the only way the outer
-- traversal finds out this failed is if this 'up' itself throws.
unless ok (throwIO (userError ("remote migration failed: " <> Text.unpack uniquename)))
, dynamics = [toDyn $ Dot.OpaqueNode "migration"]
}
dbname :: Text
dbname = getDatabase cfg.cfg_database
remoteMigrationPath :: MigrationFile -> FilePath
remoteMigrationPath = cfg.cfg_remoteMigrationPath
remoteMigrationDir :: MigrationFile -> FilePath
remoteMigrationDir = takeDirectory . cfg.cfg_remoteMigrationPath
uploadmigrationFile :: MigrationFile -> Op
uploadmigrationFile m =
Rsync.sendFile
(contramap UploadFile r)
Debian.rsync
(FS.PreExisting m.path)
(rsyncRemote)
(remoteMigrationPath m)
uploadsecrets :: Op
uploadsecrets = op "uploading-secrets" (deps (uploadMainSecret : extraSecrets)) id
where
extraSecrets :: [Op]
extraSecrets = fmap uploadExtraSecret cfg.cfg_extra_users
uploadMainSecret :: Op
uploadMainSecret =
Rsync.sendFile
(contramap UploadSecretFile r)
Debian.rsync
(FS.Generated (pgPassword r) cfg.cfg_password)
(rsyncRemote)
(remotePgSecretPath)
remotePgSecretPath :: FilePath
remotePgSecretPath = Text.unpack $ "tmp/connstring-" <> dbname
uploadExtraSecret :: (User, FilePath) -> Op
uploadExtraSecret (u, p) =
Rsync.sendFile
(contramap UploadSecretFile r)
Debian.rsync
(FS.Generated (pgPassword r) p)
(rsyncRemote)
(remotePgSecretPathForUser u)
remotePgSecretPathForUser :: User -> FilePath
remotePgSecretPathForUser u = Text.unpack $ "tmp/connstring-" <> dbname <> "." <> u.userRole
remoteExtraUsers :: [(User, FilePath)]
remoteExtraUsers = [(u, remotePgSecretPathForUser u) | (u, _) <- cfg.cfg_extra_users]
remoteMigrationPlan :: G MigrationFile -> G MigrationFile
remoteMigrationPlan migrations =
(G $ fmap (MigrationFile . remoteMigrationPath) $ getCofreeGraph migrations)
remotePrepare :: [MigrationFile] -> Op
remotePrepare migrations =
trackedGraph $
Self.uploadAndCallSelf
(contramap UploadSelf r)
(contramap CallSelf r)
"tmp"
selfRemote
selfpath
Ssh.preExistingRemoteMachine
simulate
CLI.Up
( toSpec $
Left $
PrepareRemoteMigrationSetup $
fmap remoteMigrationDir migrations
)
remoteApply :: G MigrationFile -> Op
remoteApply migrations =
trackedGraph $
Self.uploadAndCallSelfAsSudo
(contramap UploadSelf r)
(contramap CallSelf r)
"tmp"
selfRemote
selfpath
Ssh.preExistingRemoteMachine
simulate
CLI.Up
(toSpec $ Right $ MigrationSetup (remoteMigrationPlan migrations) cfg.cfg_user cfg.cfg_database remotePgSecretPath remoteExtraUsers)
data PrepareRemoteMigrationSetup
= PrepareRemoteMigrationSetup
{ prepare_tmp_migration_paths :: [FilePath]
}
deriving (Generic)
instance FromJSON PrepareRemoteMigrationSetup
instance ToJSON PrepareRemoteMigrationSetup
-- | locally running to lay the preparation work for remote
prepareRemoteMigration ::
PrepareRemoteMigrationSetup ->
Op
prepareRemoteMigration setup =
op "prepare-remote-migration" (deps [FS.dir (FS.Directory path) | path <- setup.prepare_tmp_migration_paths]) id
data MigrationSetup
= MigrationSetup
{ setup_migration :: G MigrationFile
, setup_user :: User
, setup_database :: Database
, setup_tmp_secret_path :: FilePath
, setup_extra_users :: [(User, FilePath)]
}
deriving (Generic)
instance FromJSON MigrationSetup
instance ToJSON MigrationSetup
applyUserScriptMigration ::
Reporter Report ->
Track' (Binary "psql") ->
Track' (ConnString FilePath) ->
MigrationSetup ->
Op
applyUserScriptMigration r psql mkConnstring setup =
migrateG r psql style setup.setup_migration
where
style = MigrateUserScript mkConnstring connstring
connstring =
ConnString localServer setup.setup_user setup.setup_tmp_secret_path setup.setup_database
applyAdminScriptMigration ::
Reporter Report ->
Track' (Binary "psql") ->
Track' DatabaseName ->
MigrationSetup -> -- TODO: no user required for admin
Op
applyAdminScriptMigration r psql mkdb setup =
migrateG r psql style setup.setup_migration
where
style = MigrateAdminScript mkdb (setup.setup_database.getDatabase)
pgPassword :: Reporter Report -> Track' FilePath
pgPassword r = Track $ \path ->
Secrets.sharedSecretFile
(contramap CreatePassword r)
Debian.openssl
(Secrets.Secret Secrets.Hex 48 path)
pgConnstringFile :: Reporter Report -> ConnString FilePath -> Track' FilePath
pgConnstringFile r conn =
Track $ \cstringpath ->
op "pg-connstring" (deps [run (pgPassword r) passFile]) $ \actions ->
actions
{ ref = mkRef "connstring" cstringpath
, help = "store connection string at " <> Text.pack cstringpath
, up = do
pass <- readPassword passFile
let contents = connstring (withPassword conn pass)
Text.writeFile cstringpath contents
}
where
passFile :: FilePath
passFile = conn.connstring_user_pass