salmon-ops-recipes-0.2.0.0: test/Test/QemuShutdownSpec.hs
{-# LANGUAGE ScopedTypeVariables #-}
{- | Layer 1 (a unix socket in a temp directory, no qemu, no root): the
monitor-socket half of "Salmon.Builtin.Nodes.Qemu".'shutdown' against a fake
monitor. It shows the three answers the down hook depends on: a guest that
powers off (the socket goes away, no hard stop needed), one that ignores ACPI
(still listening at the deadline, so the caller must stop it hard) and a VM
that was never running (nothing to send to).
The run state ('runState', 'pause', 'resume') is read two ways: the parser on
what a real monitor printed (QEMU 8.2.2, captured, terminal echo and all),
and the three calls against a real @qemu-system-x86_64@ started with no disk
and no guest -- a process, not a VM -- skipped loudly where qemu is missing.
-}
module Test.QemuShutdownSpec (tests) where
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, modifyMVar_, readMVar, tryPutMVar)
import Control.Exception (SomeException, bracket, try)
import Control.Monad (forever, void)
import qualified Data.ByteString.Char8 as C8
import Network.Socket hiding (shutdown)
import qualified Network.Socket.ByteString as SocketBS
import Salmon.Builtin.Nodes.Qemu (RunState (..), interpretStatus, listening, pause, reset, resume, runState, shutdown)
import System.Directory (findExecutable, removeFile)
import System.IO (hPutStrLn, stderr)
import System.Process (CreateProcess (..), StdStream (..), proc, terminateProcess, waitForProcess, withCreateProcess)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Qemu.shutdown (monitor socket)"
[ testCase "a guest that powers off: True, and the command was sent" powersOff
, testCase "a guest that ignores ACPI: False at the deadline" ignoresAcpi
, testCase "no monitor listening: True, nothing sent" neverRan
, testCase "reset sends system_reset" sendsReset
, testCase "info status: running, paused, and neither" readsStatus
, testCase "info status: half an answer is no answer" partialStatus
, testCase "no monitor listening: the run state is unknown" unknownWithoutMonitor
, testCase "a real qemu: paused, resumed, paused again" realMonitor
]
-- | What QEMU 8.2.2's monitor writes back for @info status@ on a fresh connection.
captured :: String -> C8.ByteString
captured status =
C8.pack $
"QEMU 8.2.2 monitor - type 'help' for more information\r\n"
<> "(qemu) i\ESC[K\ESC[Din\ESC[K\ESC[D\ESC[Dinfo status\ESC[K\r\n"
<> "VM status: "
<> status
<> "\r\n(qemu) "
readsStatus :: IO ()
readsStatus = do
interpretStatus (captured "running") @?= Just Running
interpretStatus (captured "paused") @?= Just Paused
-- qemu started with -S, before anything told it to go
interpretStatus (captured "paused (prelaunch)") @?= Just Paused
interpretStatus (captured "shutdown") @?= Just (OtherState "shutdown")
partialStatus :: IO ()
partialStatus = do
interpretStatus (C8.pack "QEMU 8.2.2 monitor\r\n(qemu) info status\r\n") @?= Nothing
-- the line is there but not finished: "paused" may yet follow "VM status: "
interpretStatus (C8.pack "(qemu) info status\r\nVM status: ") @?= Nothing
interpretStatus (C8.pack "(qemu) info status\r\nVM status: runn") @?= Nothing
unknownWithoutMonitor :: IO ()
unknownWithoutMonitor = withSystemTempDirectory "qemu-mon" $ \dir -> do
st <- runState (dir </> "absent.sock")
st @?= Nothing
ok <- pause (dir </> "absent.sock")
ok @?= False
{- | qemu itself, with nothing to run: @-S@ starts it stopped, which is the
state 'pause' produces, so the three calls can be read back without a guest.
-}
realMonitor :: IO ()
realMonitor = do
qemu <- findExecutable "qemu-system-x86_64"
case qemu of
Nothing -> hPutStrLn stderr "SKIPPED: `qemu-system-x86_64` not found on PATH; the monitor's run state is only checked against captured output"
Just bin -> withSystemTempDirectory "qm" $ \dir -> do
let sock = dir </> "m.sock"
cp =
(proc bin ["-display", "none", "-machine", "accel=tcg", "-m", "32", "-S", "-monitor", "unix:" <> sock <> ",server,nowait"])
{ std_in = NoStream
, std_out = NoStream
, std_err = NoStream
}
withCreateProcess cp $ \_ _ _ ph -> do
waitListening sock (50 :: Int)
runState sock >>= (@?= Just Paused)
resume sock >>= (@?= True)
runState sock >>= (@?= Just Running)
pause sock >>= (@?= True)
runState sock >>= (@?= Just Paused)
terminateProcess ph
void (waitForProcess ph)
where
waitListening _ 0 = fail "qemu never opened its monitor"
waitListening sock n = do
up <- listening sock
if up then pure () else threadDelay 100000 >> waitListening sock (n - 1)
-- | The commands received: the liveness probes (connect, hang up) send nothing and are dropped.
commands :: Fake -> IO [String]
commands fake = filter (not . null) <$> readMVar (received fake)
-- | What the fake monitor received, and whether it exits on @system_powerdown@.
data Fake = Fake {received :: MVar [String]}
withFake :: Bool -> (FilePath -> Fake -> IO a) -> IO a
withFake exitsOnPowerdown act =
withSystemTempDirectory "qemu-mon" $ \dir -> do
let path = dir </> "mon.sock"
got <- newMVar []
listener <- socket AF_UNIX Stream defaultProtocol
bind listener (SockAddrUnix path)
listen listener 5
gone <- newEmptyMVar
tid <- forkIO $ forever $ do
(conn, _) <- accept listener
void $ forkIO $ do
line <- readLine conn
modifyMVar_ got (pure . (line :))
if exitsOnPowerdown && line == "system_powerdown"
then do
close listener
void (try @SomeException (removeFile path))
void (tryPutMVar gone ())
else pure ()
close conn
r <- act path (Fake got)
killThread tid
void (try @SomeException (close listener))
pure r
where
readLine conn = do
bs <- SocketBS.recv conn 4096
pure (takeWhile (/= '\n') (C8.unpack bs))
powersOff :: IO ()
powersOff = withFake True $ \path fake -> do
ok <- shutdown 5 path
ok @?= True
commands fake >>= (@?= ["system_powerdown"])
ignoresAcpi :: IO ()
ignoresAcpi = withFake False $ \path fake -> do
ok <- shutdown 1 path
ok @?= False
commands fake >>= (@?= ["system_powerdown"])
neverRan :: IO ()
neverRan = withSystemTempDirectory "qemu-mon" $ \dir -> do
ok <- shutdown 1 (dir </> "absent.sock")
ok @?= True
sendsReset :: IO ()
sendsReset = withFake False $ \path fake -> do
ok <- reset path
ok @?= True
threadDelay 100000
commands fake >>= (@?= ["system_reset"])