salmon-ops-recipes-0.1.0.0: test/Test/MigratorTemplateSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{- | Layer 3 for @salmon-migrator config template@: the real binary, on a real
Postgres, in a VM.
"Test.PostgresTemplateSpec" already covers the nodes against a container,
with a hand-written build. This covers what only the shipped binary can: that
the migrator's own graph -- cluster, owner role and password file, admin
migrations, owner migrations over TCP -- runs as the template's nested build,
and that what comes out is a template anybody can clone with plain SQL.
What the test drives, all inside the guest:
1. @config template … | run up@ from two migration files;
2. checks the result is locked, stamped, and refuses a connection;
3. runs it again and checks the template was __not__ rebuilt (same oid);
4. clones it with @CREATE DATABASE … TEMPLATE@ and looks inside: the row the
owner migration wrote, the extension the superuser migration created, and
the owner role still owning the table -- roles are cluster-wide, so a
clone inherits the template's ownership rather than its creator's;
5. changes a migration, runs again, and checks the template __was__ rebuilt
(new oid), a new clone sees the change, and the old clone does not.
= Prerequisites
Everything "Test.PgBackupSpec" needs except rsync (the binary goes over
@scp@), and a rootfs of its own for the reason given there -- two VMs on one
9p rootfs corrupt it:
> sudo mkdir -p /var/lib/salmon-test-vms/pg-template/root
> sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ /var/lib/salmon-test-vms/pg-template/root/
> sudo $(cabal list-bin salmon-qemu-host-setup-fixture) "$USER" /var/lib/salmon-test-vms/pg-template/root
and @cabal build salmon-migrator@ first. The guest has no route out, so every
package the migrator's graph installs must already be in the rootfs;
@pg-master@'s has them all (postgresql, postgresql-client, postgresql-common,
openssl).
-}
module Test.MigratorTemplateSpec (tests) where
import Control.Concurrent (threadDelay)
import Control.Monad (unless)
import Data.List (isInfixOf, isPrefixOf)
import qualified Data.Text as Text
import System.Directory (doesFileExist, findExecutable)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.Process (readProcessWithExitCode)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
import Test.Harness (VmAccess (..), hasVmPrivileges, quoteForRemoteShell, scpToVm, sshToVm, testVmAddr4, withTempDir, withVmAt)
tests :: TestTree
tests =
testGroup
"salmon-migrator config template (Layer 3, a real database in a VM)"
[ testCase "builds, skips, clones with plain SQL, and rebuilds on a changed migration" buildsAndRebuilds
]
-- | This spec's __own__ rootfs; see the module header.
rootfs :: FilePath
rootfs = "/var/lib/salmon-test-vms/pg-template/root"
templateDb, ownerRole, workDir :: String
templateDb = "fixture_tpl"
ownerRole = "fixture_owner"
workDir = "/root/tpl"
canaryRow :: String
canaryRow = "salmon-template-canary"
-------------------------------------------------------------------------------
buildsAndRebuilds :: IO ()
buildsAndRebuilds = requirePrereqs $ \binary ->
withVmAt testVmAddr4 rootfs $ \vm -> withTempDir $ \tmp -> do
waitForPostgres vm
resetGuest vm
run_ vm ["mkdir", "-p", workDir </> "migrations/superuser", workDir </> "migrations/owner"]
scpToVm vm binary "/root/salmon-migrator"
-- CREATE EXTENSION needs a superuser, so finding it in a clone shows
-- the admin migrations went into the template too, not only the
-- owner's.
let superuser = "CREATE EXTENSION IF NOT EXISTS pgcrypto;\n"
ownerV1 =
unlines
[ "CREATE TABLE IF NOT EXISTS fixture (v text);"
, "INSERT INTO fixture VALUES ('" <> canaryRow <> "');"
]
ownerV2 = ownerV1 <> "CREATE TABLE IF NOT EXISTS fixture_two (v text);\n"
upload vm tmp "superuser.sql" superuser (workDir </> "migrations/superuser/tip.sql")
upload vm tmp "owner.sql" ownerV1 (workDir </> "migrations/owner/tip.sql")
-- 1. build
migrate vm
(sql vm "postgres" (catalogQuery "datistemplate::text || '|' || datallowconn::text") >>=) $
assertEqual "the template is locked" "true|false"
comment <- sql vm "postgres" (catalogQuery "shobj_description(oid, 'pg_database')")
assertBool ("the template is stamped with its inputs: " <> comment) ("salmon-template:" `isPrefixOf` comment)
(code, _, err) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-d", templateDb, "-tAc", quoteForRemoteShell "SELECT 1"]
assertBool "a locked template refuses a connection" (code /= ExitSuccess)
assertBool ("and says why: " <> err) ("not currently accepting connections" `isInfixOf` err)
-- 2. unchanged inputs are not rebuilt
built <- templateOid vm
migrate vm
(templateOid vm >>=) $ assertEqual "unchanged migrations leave the template as it was" built
-- 3. clone with plain SQL, as any consumer would
run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("CREATE DATABASE fixture_copy TEMPLATE " <> templateDb)]
(sql vm "fixture_copy" "SELECT v FROM fixture" >>=) $
assertEqual "the owner migration's row is in the copy" canaryRow
(sql vm "fixture_copy" "SELECT extname FROM pg_extension WHERE extname = 'pgcrypto'" >>=) $
assertEqual "the superuser migration's extension is in the copy" "pgcrypto"
(sql vm "fixture_copy" "SELECT tableowner FROM pg_tables WHERE tablename = 'fixture'" >>=) $
assertEqual "the copy keeps the template's owner, not its creator" ownerRole
-- 4. a changed migration rebuilds
upload vm tmp "owner.sql" ownerV2 (workDir </> "migrations/owner/tip.sql")
migrate vm
rebuilt <- templateOid vm
assertBool "a changed migration rebuilds the template" (rebuilt /= built)
run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("CREATE DATABASE fixture_copy_two TEMPLATE " <> templateDb)]
(sql vm "fixture_copy_two" "SELECT count(*) FROM pg_tables WHERE tablename = 'fixture_two'" >>=) $
assertEqual "a new copy sees the change" "1"
(sql vm "fixture_copy_two" "SELECT count(*) FROM fixture" >>=) $
assertEqual "rebuilt from nothing: the row is written once, not twice" "1"
(sql vm "fixture_copy" "SELECT count(*) FROM pg_tables WHERE tablename = 'fixture_two'" >>=) $
assertEqual "an existing copy was taken once, and does not" "0"
where
catalogQuery :: String -> String
catalogQuery col = "SELECT " <> col <> " FROM pg_database WHERE datname = '" <> templateDb <> "'"
templateOid vm = sql vm "postgres" (catalogQuery "oid::text")
{- | @config template … | run up@, run inside the guest from its work
directory, so the migration paths in the directive are the guest's.
-}
migrate :: VmAccess -> IO ()
migrate vm =
run_
vm
[ "bash"
, "-c"
, quoteForRemoteShell $
unwords
[ "set -eo pipefail;"
, "cd " <> workDir <> ";"
, "/root/salmon-migrator config template"
, "--superuser-root migrations/superuser"
, "--owner-root migrations/owner"
, "--db " <> templateDb
, "--db-owner " <> ownerRole
, "--db-passfile " <> workDir <> "/owner.pass"
, "> directive.json;"
, "/root/salmon-migrator run up < directive.json"
]
]
upload :: VmAccess -> FilePath -> FilePath -> String -> FilePath -> IO ()
upload vm tmp name contents remote = do
writeFile (tmp </> name) contents
scpToVm vm (tmp </> name) remote
-- | One value, as the @postgres@ OS user.
sql :: VmAccess -> String -> String -> IO String
sql vm db query = do
(code, out, err) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-X", "-d", db, "-tAc", quoteForRemoteShell query]
unless (code == ExitSuccess) $
assertBool ("query failed on " <> db <> ": " <> query <> "\n" <> err) False
pure (filter (/= '\n') out)
run_ :: VmAccess -> [String] -> IO ()
run_ vm args = do
(code, out, err) <- sshToVm vm args
unless (code == ExitSuccess) $
assertBool ("command failed in the guest: " <> show args <> "\n--- stdout ---\n" <> lastLines out <> "\n--- stderr ---\n" <> err) False
where
-- `run up` reports every node; the failure is at the end
lastLines = unlines . reverse . take 40 . reverse . lines
{- | Undoes whatever a previous run left in the rootfs, which persists (9p).
The role goes too, not only the databases: the migrator creates it with the
password in the pass file, and never changes the password of a role that
already exists, so a fresh pass file against a surviving role fails the
owner migrations on authentication.
-}
resetGuest :: VmAccess -> IO ()
resetGuest vm = do
let psql q = run_ vm ["sudo", "-u", "postgres", "psql", "-X", "-tAc", quoteForRemoteShell q]
psql "DROP DATABASE IF EXISTS fixture_copy WITH (FORCE)"
psql "DROP DATABASE IF EXISTS fixture_copy_two WITH (FORCE)"
-- a DO block rather than \\gexec: psql -c will not mix SQL with a meta-command
psql ("DO $$ BEGIN IF EXISTS (SELECT FROM pg_database WHERE datname = '" <> templateDb <> "') THEN ALTER DATABASE " <> templateDb <> " IS_TEMPLATE false; END IF; END $$")
psql ("DROP DATABASE IF EXISTS " <> templateDb <> " WITH (FORCE)")
psql ("DROP ROLE IF EXISTS " <> ownerRole)
run_ vm ["rm", "-rf", workDir]
-- | As in "Test.PgBackupSpec": sshd answering says nothing about Postgres.
waitForPostgres :: VmAccess -> IO ()
waitForPostgres vm = go (30 :: Int)
where
go 0 = fail "postgres never accepted a connection in the VM"
go n = do
(code, _, _) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT 1"]
if code == ExitSuccess
then pure ()
else threadDelay 2000000 >> go (n - 1)
-------------------------------------------------------------------------------
requirePrereqs :: (FilePath -> IO ()) -> IO ()
requirePrereqs act = do
privileged <- hasVmPrivileges
qemu <- findExecutable "qemu-system-x86_64"
scp <- findExecutable "scp"
rootfsOk <- doesFileExist (rootfs </> "etc/issue")
case () of
_
| not privileged -> skip "no VM privileges (run salmon-qemu-host-setup-fixture, or use sudo)"
| Nothing <- qemu -> skip "qemu-system-x86_64 not on PATH"
| Nothing <- scp -> skip "scp not on PATH on the host"
| not rootfsOk ->
skip
( "no rootfs at "
<> rootfs
<> ". It must be this spec's own (two VMs on one 9p rootfs corrupt it). Build it with:\n"
<> " sudo mkdir -p "
<> rootfs
<> "\n sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ "
<> rootfs
<> "/\n sudo $(cabal list-bin salmon-qemu-host-setup-fixture) \"$USER\" "
<> rootfs
)
| otherwise -> resolveBinary >>= act
skip :: String -> IO ()
skip why = putStrLn ("SKIPPED: " <> why)
-- | @cabal list-bin@, last non-blank line: under sudo, cabal prints notices first.
resolveBinary :: IO FilePath
resolveBinary = do
(code, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "salmon-migrator"] ""
case (code, reverse (filter (not . null) (lines out))) of
(ExitSuccess, path : _) -> pure path
_ -> error ("could not locate salmon-migrator (build it first): " <> err)