salmon-ops-recipes-0.1.0.0: test/Test/FilesystemSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{- | Layer 1 coverage for "Salmon.Builtin.Nodes.Filesystem"'s @check@.
'FS.checkFileContents' is the second real check in the tree (after
'Salmon.Builtin.Nodes.Systemd.checkService') and the one that reaches the
most graphs, since nearly every recipe writes a config file. These run
against a throwaway temp directory rather than a fake filesystem, because
what the check actually asserts — "the bytes on disk are these bytes" — is
not a thing a fake can be wrong about in the interesting way.
Two of the cases below are about consequences rather than the check itself.
'skippedFileKeepsItsMtime' is the one 'Salmon.Builtin.Nodes.Systemd' depends
on: systemd decides a unit needs reloading from its file's mtime, so a node
that rewrote identical bytes on every pass made every pass look like a
changed unit. And 'redeclaredContentsAreRewritten' is the shape of (I6) —
the same path declared with different contents — which is the case a check
is the only thing that can notice.
-}
module Test.FilesystemSpec (tests) where
import qualified Data.ByteString.Char8 as C8
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Bits ((.&.))
import System.Directory (
createDirectory,
createDirectoryIfMissing,
createFileLink,
doesDirectoryExist,
doesFileExist,
getModificationTime,
pathIsSymbolicLink,
removeFile,
)
import System.FilePath ((</>))
import qualified System.Posix.Files as Posix
import qualified System.Posix.User as PosixUser
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
import Salmon.Actions.UpDown (CheckResult (..), Report (..))
import Salmon.Builtin.Extension (Extension)
import Salmon.Op.Actions (shorthand)
import qualified Salmon.Builtin.Nodes.Filesystem as FS
import Test.Harness (runDown, runUp, runUpCapturing, withTempDir)
tests :: TestTree
tests =
testGroup
"Salmon.Builtin.Nodes.Filesystem"
[ testCase "a file that is not there yet needs writing" missingIsFailure
, testCase "a file with the right bytes is satisfied" matchingIsSuccess
, testCase "a file of the right length but the wrong bytes is not" sameSizeIsNotEnough
, testCase "the reason never quotes the contents" reasonKeepsSecrets
, testCase "a second pass skips the file instead of rewriting it" secondPassSkips
, testCase "and so leaves its mtime alone, which systemd reads" skippedFileKeepsItsMtime
, testCase "a clobbered file is written again" clobberedIsRewritten
, testCase "a deleted file is written again" deletedIsRewritten
, testCase "the same path redeclared with new contents is rewritten" redeclaredContentsAreRewritten
, testGroup
"down tolerates an effect that was never created"
[ testCase "a file already gone is not a teardown failure" downOfAbsentFileSucceeds
, testCase "a directory already gone is not a teardown failure" downOfAbsentDirSucceeds
, testCase "a file's down still takes the enclosing directory with it" downRemovesBoth
, testCase "but a directory holding something undeclared still fails" downOfNonEmptyDirFails
]
, testGroup
"ownedFile: recursive tree ownership"
[ testCase "a missing directory is a Failure, same as a missing file" ownedMissingDirIsFailure
, testCase "a directory is checkable at all (not doesFileExist's permanent 'missing')" ownedDirectoryIsCheckable
, testCase "up recurses into nested files and subdirectories" ownedUpChownsDescendants
, testCase "ownedMode is applied to the top entry only, never descendants" ownedModeNotAppliedToDescendants
, testCase "a dangling symlink inside the tree is chowned itself, never followed" ownedTreeDoesNotFollowSymlinks
]
]
-- | The node under test, and the check it carries, for one path/contents pair.
contentsAt :: FilePath -> Text -> FS.FileContents Text
contentsAt path body = FS.FileContents path body
checkOf :: FS.FileContents Text -> IO CheckResult
checkOf = FS.checkFileContents
nodeOf :: FS.FileContents Text -> IO Bool
nodeOf = runUp . FS.filecontents
-------------------------------------------------------------------------------
missingIsFailure :: IO ()
missingIsFailure = withTempDir $ \d -> do
verdict <- checkOf (contentsAt (d </> "absent.conf") "hello\n")
assertBool "missing is a Failure" (isFailure verdict)
matchingIsSuccess :: IO ()
matchingIsSuccess = withTempDir $ \d -> do
let want = contentsAt (d </> "a.conf") "hello\n"
assertBool "up succeeded" =<< nodeOf want
assertEqual "the bytes match" Success =<< checkOf want
{- | The whole reason this is a byte comparison and not
'Salmon.Actions.UpDown.skipIfFileExists': a file of exactly the right length
holding the wrong thing is the failure mode a config node most needs to
catch, and it is the one an existence check calls satisfied. It also pins
that the size comparison is a fast path in front of the answer rather than
the answer.
-}
sameSizeIsNotEnough :: IO ()
sameSizeIsNotEnough = withTempDir $ \d -> do
let path = d </> "same-size.conf"
let want = contentsAt path "aaaaa\n"
assertBool "up succeeded" =<< nodeOf want
writeFile path "bbbbb\n"
verdict <- checkOf want
assertBool "same length, different bytes, still a Failure" (isFailure verdict)
{- | Failure text goes into reports. The files this node writes include
pgbouncer userlists and postgrest configurations with signing keys in them,
so the reason says which file and never what is in it.
-}
reasonKeepsSecrets :: IO ()
reasonKeepsSecrets = withTempDir $ \d -> do
let path = d </> "secret.conf"
let want = contentsAt path "password = hunter2\n"
assertBool "up succeeded" =<< nodeOf want
writeFile path "password = swordfish\n"
verdict <- checkOf want
case verdict of
Failure why -> do
assertBool "names the file" (C8.pack path `C8.isInfixOf` C8.pack (show why))
assertBool "not what we would write" (not ("hunter2" `substr` why))
assertBool "nor what is there" (not ("swordfish" `substr` why))
other -> fail ("expected a Failure, got " <> show other)
{- | The behaviour change this check lands on every existing caller: a
'filecontents' node whose bytes already match stops being re-applied.
-}
secondPassSkips :: IO ()
secondPassSkips = withTempDir $ \d -> do
let want = contentsAt (d </> "twice.conf") "hello\n"
first <- runUpCapturing (FS.filecontents want)
assertEqual "written the first time" ["file-contents"] (evals first)
second <- runUpCapturing (FS.filecontents want)
assertEqual "skipped the second time" [] (evals second)
assertEqual "and reported as a skip" ["file-contents"] (skips second)
{- | The consequence 'Salmon.Builtin.Nodes.Systemd.checkService' was waiting
for. systemd answers @NeedDaemonReload@ from the unit file's mtime, so a node
that rewrote byte-identical contents on every pass reported a changed unit on
every pass — and the unit check would then reload and restart a service with
nothing wrong with it. Verified against a real @systemctl --user@ unit while
writing this: rewriting identical bytes flips @NeedDaemonReload@ to @yes@.
-}
skippedFileKeepsItsMtime :: IO ()
skippedFileKeepsItsMtime = withTempDir $ \d -> do
let path = d </> "unit.service"
let want = contentsAt path "[Service]\nExecStart=/bin/true\n"
assertBool "up succeeded" =<< nodeOf want
before <- getModificationTime path
assertBool "second up succeeded" =<< nodeOf want
after <- getModificationTime path
assertEqual "the file was not touched at all" before after
clobberedIsRewritten :: IO ()
clobberedIsRewritten = withTempDir $ \d -> do
let path = d </> "clobbered.conf"
let want = contentsAt path "hello\n"
assertBool "up succeeded" =<< nodeOf want
writeFile path "something else entirely\n"
reports <- runUpCapturing (FS.filecontents want)
assertEqual "written again" ["file-contents"] (evals reports)
assertEqual "and back to what it should say" "hello\n" =<< readFile path
deletedIsRewritten :: IO ()
deletedIsRewritten = withTempDir $ \d -> do
let path = d </> "deleted.conf"
let want = contentsAt path "hello\n"
assertBool "up succeeded" =<< nodeOf want
removeFile path
reports <- runUpCapturing (FS.filecontents want)
assertEqual "written again" ["file-contents"] (evals reports)
{- | (I6)'s shape, from the node's end. Two declarations of one path with
different contents are one 'Salmon.Op.Ref.Ref' — @filecontents@ keys on the
path alone — so nothing above the node can tell they differ. Only the check
can, and now it does.
-}
redeclaredContentsAreRewritten :: IO ()
redeclaredContentsAreRewritten = withTempDir $ \d -> do
let path = d </> "redeclared.conf"
assertBool "the first declaration went up" =<< nodeOf (contentsAt path "first\n")
reports <- runUpCapturing (FS.filecontents (contentsAt path "second\n"))
assertEqual "the second was evaluated, not skipped" ["file-contents"] (evals reports)
assertEqual "and the file says the new thing" "second\n" =<< readFile path
{- | A @down@ that throws is contained by marking every /predecessor/
'Salmon.Actions.UpDown.Blocked', so a node whose effect was never created
blocks the teardown of everything it was declared on top of. Absence is this
node's effect being absent, and teardown never consults a node's check (that
answers "does my effect need creating"), so tolerating it is the node's own
business. Found by a GCP sandbox that could not remove its working directory
because an earlier teardown had already removed the file inside it.
-}
downOfAbsentFileSucceeds :: IO ()
downOfAbsentFileSucceeds = withTempDir $ \d -> do
let path = d </> "never-written.conf"
assertBool "tearing down a file that was never written succeeds" =<< runDown (FS.filecontents (contentsAt path "unused\n"))
downOfAbsentDirSucceeds :: IO ()
downOfAbsentDirSucceeds = withTempDir $ \d -> do
let path = d </> "never-created"
assertBool "tearing down a directory that was never created succeeds" =<< runDown (FS.dir (FS.Directory path))
-- | Tolerating absence must not turn `down` into a no-op.
downRemovesBoth :: IO ()
downRemovesBoth = withTempDir $ \d -> do
let dir = d </> "workdir"
let path = dir </> "written.conf"
assertBool "the node went up" =<< nodeOf (contentsAt path "body\n")
assertBool "the file is there" =<< doesFileExist path
assertBool "teardown succeeded" =<< runDown (FS.filecontents (contentsAt path "body\n"))
assertEqual "the file is gone" False =<< doesFileExist path
assertEqual "and so is the directory it brought with it" False =<< doesDirectoryExist dir
{- | The signal worth keeping: something in the directory was never declared
(or did not go down), which is exactly the case that caught 'Podman.login'
leaving its authfile behind.
-}
downOfNonEmptyDirFails :: IO ()
downOfNonEmptyDirFails = withTempDir $ \d -> do
let dir = d </> "occupied"
assertBool "the directory went up" =<< runUp (FS.dir (FS.Directory dir))
writeFile (dir </> "undeclared.txt") "left behind\n"
assertEqual "teardown of a non-empty directory fails" False =<< runDown (FS.dir (FS.Directory dir))
assertBool "and leaves it standing" =<< doesDirectoryExist dir
-------------------------------------------------------------------------------
{- | The current process's own username\/group, so tests can declare "this
tree is owned by me" -- a chown any unprivileged test process is always
allowed to perform on files it already owns -- without needing root.
-}
selfOwnership :: IO (Text, Text)
selfOwnership = do
user <- Text.pack <$> PosixUser.getEffectiveUserName
gid <- PosixUser.getEffectiveGroupID
group <- Text.pack . PosixUser.groupName <$> PosixUser.getGroupEntryForID gid
pure (user, group)
ownedMissingDirIsFailure :: IO ()
ownedMissingDirIsFailure = withTempDir $ \d -> do
(user, group) <- selfOwnership
let owner = FS.FileOwnership (d </> "never-made") (Just user) (Just group) 0o755
verdict <- FS.checkOwnership owner
assertBool "missing directory is a Failure" (isFailure verdict)
{- | Before (I5)'s fix this used 'doesFileExist', which is 'False' for a
directory -- so a directory-shaped 'ownedFile' reported permanently
"missing", however many times 'up' ran.
-}
ownedDirectoryIsCheckable :: IO ()
ownedDirectoryIsCheckable = withTempDir $ \d -> do
(user, group) <- selfOwnership
let path = d </> "handed-over"
createDirectory path
let owner = FS.FileOwnership path (Just user) (Just group) 0o755
assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
assertEqual "the directory now checks as Success" Success =<< FS.checkOwnership owner
{- | A rootfs handed to an unprivileged user has package-installed
subdirectories (etc\/ssh\/sshd_config.d, say) that only the top-level chown
used to reach. 'up' must walk the whole tree.
-}
ownedUpChownsDescendants :: IO ()
ownedUpChownsDescendants = withTempDir $ \d -> do
(user, group) <- selfOwnership
let top = d </> "rootfs-etc-ssh"
let sub = top </> "sshd_config.d"
createDirectoryIfMissing True sub
writeFile (sub </> "99-salmon-test.conf") "# nothing\n"
let owner = FS.FileOwnership top (Just user) (Just group) 0o755
assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
assertEqual "the whole tree now checks as Success" Success =<< FS.checkOwnership owner
-- | 'ownedMode' is a statement about the directory entry itself, not a
-- recursive chmod -- a config file wanting 0644 and a host key wanting
-- 0600 underneath the same handed-over directory must not both end up at
-- whatever single mode the caller gave the top of the tree.
ownedModeNotAppliedToDescendants :: IO ()
ownedModeNotAppliedToDescendants = withTempDir $ \d -> do
(user, group) <- selfOwnership
let top = d </> "mode-scoped"
let nested = top </> "keep-this-mode.conf"
createDirectory top
writeFile nested "unchanged\n"
Posix.setFileMode nested 0o600
let owner = FS.FileOwnership top (Just user) (Just group) 0o755
assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
nestedMode <- (.&. 0o7777) . Posix.fileMode <$> Posix.getFileStatus nested
assertEqual "the nested file's own mode survived untouched" 0o600 nestedMode
{- | A symlink under a handed-over tree is chowned itself
('setSymbolicLinkOwnerAndGroup'); its target is somebody else's business,
and a dangling one must not make the walk throw trying to stat through it.
-}
ownedTreeDoesNotFollowSymlinks :: IO ()
ownedTreeDoesNotFollowSymlinks = withTempDir $ \d -> do
(user, group) <- selfOwnership
let top = d </> "with-a-symlink"
createDirectory top
let link = top </> "dangling"
createFileLink "/nonexistent-salmon-test-target" link
let owner = FS.FileOwnership top (Just user) (Just group) 0o755
assertBool "up succeeded despite the dangling symlink" =<< runUp (FS.ownedFile owner)
assertBool "the symlink is still a symlink, not resolved/replaced" =<< pathIsSymbolicLink link
assertEqual "and the tree checks as Success" Success =<< FS.checkOwnership owner
-------------------------------------------------------------------------------
isFailure :: CheckResult -> Bool
isFailure (Failure _) = True
isFailure _ = False
substr :: Text -> Text -> Bool
substr needle hay = C8.pack (show needle) `C8.isInfixOf` C8.pack (show hay)
-- | Only the file node: 'FS.filecontents' brings its enclosing directory
-- with it, and that one has no check of its own.
evals :: [Report Extension] -> [Text]
evals rs = [shorthand act | Eval act <- rs, shorthand act == "file-contents"]
skips :: [Report Extension] -> [Text]
skips rs = [shorthand act | Skip act <- rs, shorthand act == "file-contents"]