How to write and test salmon ops
On Fri, 25 Sep 2026, by @lucasdicioccio, 4185 words, 24 code snippets, 5 links, 0images.
Generated from resources/howto-ops.md — the repository is the canonical source, and may be ahead of this page.
How to write and test salmon ops
This is a cookbook. It is written to be unambiguous rather than elegant: copy the
patterns below, adapt the names/fields, and you will produce a valid Op. If you
want the conceptual model instead (why any of this exists), read
salmon-core.md first. This document assumes you already know
Haskell syntax but not necessarily this codebase.
Every code sample below is a paraphrase of real code in this repository. When in doubt, grep for the cited file/function and copy its shape exactly.
1. The one type you need: Op
type Op = OpGraph Identity Actions'An Op is a single graph node plus everything needed to run it: how to bring it
up, how to tear it down, how to know if it’s already up, and what it depends on.
You almost never construct one by hand — you call the op helper.
op :: ShortHand -> Identity (Graph Op) -> (Extension -> Extension) -> OpShortHand(aText) — a short, human-readable kind name, e.g."directory","file-contents","pg-user". Used inRefconstruction (see below) and inTree/Dotoutput. Pick something stable — changing it changes the node’s identity.Identity (Graph Op)— this node’s dependencies. Almost always built with one of the two helpers below, never by hand:nodeps :: Identity (Graph Op)— no dependencies.deps :: [Op] -> Identity (Graph Op)— depends on (is preceded by) this list of ops, all co-occurring at the same level (no ordering is implied between them — see §3 for ordering).
(Extension -> Extension)— a function that fills in the actual behavior on top of a blank no-opExtension. You almost always write this as a record update on theactionsargument:\actions -> actions { help = ..., up = ... }.
2. The Extension record — what you fill in
data Extension = Extension
{ help :: Text -- one-line description
, notes :: [Text] -- longer free-form notes
, ref :: Ref -- dedup identity (see §2.1)
, up :: IO () -- bring this node's own effect into being
, managed :: Maybe (Output -> IO ExitCode)
-- ...or *be* the effect, for as long as it runs (see §2.2)
, check :: IO CheckResult -- is my effect already in place? (see §4)
, down :: IO () -- undo this node's own effect
, dynamics :: [Dynamic] -- arbitrary typed metadata, see §7
}You only ever need to touch help, ref, up, down, and — if the node
needs idempotency beyond what up itself can guarantee — check. managed
is Nothing for all but a handful of nodes; see §2.2 if yours is one of
them.
check answers one question about the node’s own effect, with six possible
answers: Success (it is in place), Skipped (someone decided to treat it as
satisfied — only Query.forceSkip produces this), Completed (it ran to
completion and stopped on purpose), Failure reason (it is not in place —
the ordinary answer on a first run, not an error report), Unknown (a check
ran and could not tell), and Immaterial (there is nothing here worth asking
about — applying the effect costs about what finding out would; this is the
default when you write no check). upTree skips the first three and runs
up for the last three.
check earns its keep twice, and the second time is easy to miss. In a one-shot
run up it is an optimisation: it saves an up you didn’t need. Under run serve, which tends its nodes between commands (Salmon.Actions.Upkeep), it
is the only thing that can notice your effect going away — nothing else in
the model looks. A node with no check answers Immaterial, and the upkeep
FSM parks it: brought up once, then blocked on its mailbox rather than woken
on a timer to be told the same thing. (An explicit Unknown is different: the
FSM keeps asking, and keeps not acting on it — a loop that treated “I could not
tell” as “so run up” would spin.) So if your node is something that can stop
being true on its own — a service, a mount, a firewall rule, a file something
else might clobber — write a check; it is the difference between a node that
gets applied and a node that gets supervised. Netfilter.rule’s
skipIfNftRuleExists is the template, Systemd.checkService is the worked
example of what a real one costs, and Filesystem.checkFileContents is the
one most node authors will want to copy: it compares the bytes it would write
with the bytes that are there, which is exact where skipIfFileExists would
call a file holding the wrong thing satisfied.
There is one alternative to writing a check, and it is narrow:
Salmon.Op.Supervision.supReapply opts a parked node into re-running up on
the loop instead of asking. Only sound when up is both genuinely cheap and
genuinely idempotent — Filesystem.dir sets it (createDirectoryIfMissing
costs about what doesDirectoryExist would, so there’s nothing to compare);
a build, a clone, or anything that talks to the network should write a
check instead, or leave itself parked.
up and down default to pure () (a no-op) if you don’t set them — this is
useful for pure “grouping” nodes (see §3) that only exist to bundle
dependencies, with no effect of their own.
2.1 ref: dedup identity
ref = mkRef "directory" pathmkRef :: (Hashable key) => Text -> key -> Ref builds a Ref from a kind tag
plus anything Hashable that uniquely identifies this instance of the node
— usually the primary key of whatever you’re describing (a file path, a
database name, a (src, tgt) pair for a copy). Two Ops with the same Ref
are treated as the same node by the graph traversal (upTree/downTree):
reachable via two different paths or not, it is one node, applied once.
Getting ref right is the
single most important thing when writing a new node — get it wrong (too
coarse, e.g. reusing one Ref for two different files) and you silently skip
work; get it wrong the other way (varying per-call for what should be the same
resource) and you silently do the work twice. Forgetting ref altogether is the
first kind: the default is mkRef "noop" shorthand, so every node sharing a
ShortHand collapses into one.
Note what the identity key is not: the node’s behaviour. filecontents keys
on the path alone, so an equal Ref means “the same effect site”, not an equal
node — two Ops writing different bytes to one path are one node, and only one
of them wins. Both traversals go through Salmon.Op.Dag, so the rule is the
same in both directions: the last representative wins, and the loser is
reported as Conflicting so the collision is at least visible.
Tightening a key (putting a content digest in it, say) is a legitimate per-node
fix, but think about it per node: it is right for a file and wrong for a
long-running service, where every config tweak would become a different node.
2.2 managed: when the node is a running process
up :: IO () describes an effect that persists once it has been made: the
file stays written, the route stays installed. A long-running process does
not — nothing keeps it alive but something watching it. That is what
managed is for:
managed :: Maybe (Output -> IO ExitCode)It blocks for as long as the node is up and returns the reason it stopped,
which means the node’s own thread is in scope for the process’s entire
lifetime and the handle never has to escape. Three things follow that a
check alone cannot give you: an exit status (so “don’t restart a service
that exited cleanly” is expressible), promptness (a death is noticed at once
rather than at the end of a check delay that may be a minute), and identity
that survives pid reuse.
Don’t write one from scratch — use Salmon.Builtin.Nodes.Daemon:
Daemon.daemon reportPrint (Daemon.defaultDaemon "webserver" (proc "nginx" ["-g", "daemon off;"]))or, if your node needs dependencies or a liveness check of its own, build
it around Daemon.runDaemon, which is the same action without the node
wrapped round it. Either way you get the teardown: a signal (SIGTERM by
default, daemon_stop.stop_signal otherwise) to the process group, a grace
period, then SIGKILL. Writing that yourself is easy to get
subtly wrong.
Three things to know before reaching for this:
- only
run servehonours it.run up/run downcallup, and a one-pass driver has nowhere to put an action that never returns. So a node like this shouldthrowIOfromup(that’s whatDaemon.daemondoes) rather than no-op into a world that then believes it is up. - prefer systemd where there is systemd.
Systemd.systemdServicehands the whole problem to an init system that is better at it and survives salmon exiting.managedis for where that is not available: a container, a test harness, or salmon-as-init itself. - the
Outputargument is where your process’s own lines go — into the node’s bounded ring, which is what an operator reads when it has failed and what tells a watchdog it is still making progress.Daemon.runDaemonhandles the piping.
3. The canonical example: Filesystem.hs
Read salmon-ops/src/Salmon/Builtin/Nodes/Filesystem.hs end to end — it is the
smallest complete example of every pattern in this document. Key excerpts:
A node with no dependencies:
newtype Directory = Directory {directoryPath :: FilePath}
dir :: Directory -> Op
dir directory =
op "directory" nodeps $ \actions ->
actions
{ help = Text.pack $ "ensures " <> path <> " exists, including subdirs"
, ref = mkRef "directory" path
, up = createDirectoryIfMissing True path
, down = removeDirectoryIfPresent path
, dynamics = [supervised defaultSupervision{supReapply = True}]
}
where
path = directory.directoryPathA node that depends on another node it builds internally:
data FileContents a = FileContents {filePath :: FilePath, contents :: a}
filecontents :: (EncodeFileContents a) => FileContents a -> Op
filecontents fcontents =
op "file-contents" (deps [enclosingdir]) $ \actions ->
actions
{ help = Text.pack $ "writes " <> path <> " with some contents"
, ref = mkRef "file-contents" path
, check = checkFileContents fcontents
, up = ByteString.writeFile path =<< encodeFileContents fcontents.contents
, down = removeFileIfPresent path
}
where
enclosingdir = dir (Directory $ takeDirectory path)
path = fcontents.filePathNote both downs tolerate the effect already being gone
(removeDirectoryIfPresent/removeFileIfPresent, not removeDirectory/
removeFile): a down that throws leaves its node “still standing” and
blocks the teardown of everything under it (§5), so down needs the same
run-twice safety as up.
Note the pattern: filecontents doesn’t take a Directory as an argument — it
derives the directory it needs (takeDirectory path) and constructs that
dependency itself, inline, in a where clause. This is idiomatic: a node
should make its own prerequisites, not expect a caller to remember to also
build them. As long as the derived dir call produces the same Ref every
time it’s reachable, it dedups correctly no matter how many other nodes also
depend on “the same” directory.
A node combining several dependencies with explicit ordering
(replaceDirectory in the same file):
replaceDirectory :: FilePath -> FilePath -> FilePath -> Op
replaceDirectory src tgt trash =
op "replace-dir" (deps [delete3 `inject` move2 `inject` move1]) $ \actions ->
actions
{ help = Text.pack $ "replace " <> src <> " " <> tgt
, ref = mkRef "replace-dir" (src, tgt)
}
where
move1 = moveDirectory tgt trash $ \actions -> actions{check = skipIfDirectoryIsMissing tgt}
move2 = moveDirectory src tgt id
delete3 = destroyDirectory trashThis node has no up/down of its own (they default to no-ops) — it’s a
pure orchestration node whose entire job is wiring three other ops together in
order. This is a completely normal and common pattern: not every op call
needs to do anything itself.
3.1 deps vs inject vs overlaid
deps [a, b, c]— this node is preceded bya,b, andc, each also preceded by whatever they depend on. No ordering is implied amonga,b,cthemselves.a `inject` b(fromSalmon.Op.OpGraph) — “bmust happen beforea”: addsbas a predecessor ofavia an ordered graph connection. Chain it to express a strict sequence:c `inject` b `inject` ameans runa, thenb, thenc.a `overlaid` b— “aandbco-occur, no ordering implied between them” (the unordered counterpart toinject).
Use deps [...] for the normal “these are my prerequisites” case. Reach for
inject only when two ops need a specific order that the dependency structure
alone wouldn’t otherwise guarantee (e.g. “delete the trash dir” must happen
after “move src into place”, not just alongside it).
4. Idempotency: making up safe to run twice
upTree may run the same Op graph repeatedly (that’s the point — it’s meant
to converge a system to a target state, not run once and be thrown away).
up must be safe to call when the target state already holds. In rough
order of preference:
- Use a
replace-shaped command if the tool has one. E.g.ip route replaceinstead ofip route add(seeSalmon.Builtin.Nodes.Routes), or SQLALTER SYSTEM SETinstead of an insert. - Use a
CREATE ... IF NOT EXISTS/CREATE OR REPLACEform if it exists for what you’re creating. - Guard a bare
CREATEwith a conditional inside aDOblock, when the tool allows aCREATEwith noIF NOT EXISTSform to run inside one — e.g. PostgresCREATE ROLEhas noIF NOT EXISTS, but can run inside:(seeDO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'myuser') THEN CREATE ROLE myuser; END IF; END $$;Postgres.CreateUser/CreateGroupinsalmon-ops/src/Salmon/Builtin/Nodes/Postgres.hs). - Shell-level check-then-act, when even that isn’t available — e.g.
Postgres refuses to run
CREATE DATABASEinside a transaction/DOblock at all, soPostgres.CreateDBdoes:psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'mydb'" | grep -q 1 \ || psql -c 'CREATE DATABASE mydb' - Append-if-missing, for config file lines with no SQL/CLI equivalent at
all:
grep -qxF '<line>' file || echo '<line>' >> file(seePostgres.ensureHbaLineScript, forpg_hba.conf). - A
check-based skip check, when the underlying tool has no idempotent verb at all — nothing toreplace, noIF NOT EXISTS— soupitself cannot be made safe to re-run. Instead, check before running whether the effect already exists, and reportSuccessif so:skipIfNftRuleExists :: Chain -> Rule -> IO CheckResult skipIfNftRuleExists c rule = do (code, out, _err) <- readCreateProcessWithExitCode (proc "nft" ["list", "chain", ...]) "" pure $ if renderedRuleText rule `isInfixOf` out then Success else Failure "no such rule in the chain"This is the same shape as the built-inop "netfilter-rule" (deps [...]) $ \actions -> actions { check = skipIfNftRuleExists chain rule , up = addRule ... , ... }skipIfFileExists/skipIfDirectoryIsMissing/skipIfNetworkExistshelpers inSalmon.Actions.UpDown/Salmon.Builtin.Nodes.Podman— when you add a new node wrapping a command with no idempotent “set” verb (e.g.nft add rule,podman network create), this is the template to copy: write askipIfXExists :: ... -> IO CheckResultthat shells out to a read-only “does X exist” check, and wire it intocheck.
check defaults to pure Immaterial — “asking would cost what applying costs”,
which upTree reads as “run up” — if you don’t set it, so options 1–5 above
need no check at all; only option 6 does. That default is a claim, not an
absence, and for options 1–5 it is a true one: the whole reason those nodes are
idempotent is that re-applying them is cheap. It is false for anything that
can stop being true on its own, which is why such a node needs option 6. Note also that a check that throws is
contained: it is read as Failure, so the node is evaluated and the rest of
the traversal is unaffected.
5. Failure: how up/down report errors
up :: IO () and down :: IO () have no return value to signal failure — the
only way a failure becomes visible to the graph traversal (upTree/
downTree in Salmon.Actions.UpDown) is if up/down throws. A thrown
exception is caught by the traversal, the node is reported Failed, and every
node that (transitively) depends on it is reported Blocked and not
evaluated (for upTree; downTree blocks the opposite direction — a node’s
own predecessors — since teardown walks top-to-bottom). Both upTree and
downTree return IO Bool: True iff nothing was Failed/Blocked.
Getting this right is mostly automatic if you build up on top of
Salmon.Builtin.Nodes.Binary.untrackedExec (which almost every builtin does
via withBinary) — it already checks the subprocess exit code and throws
CommandFailed on non-zero, so a normal up = someCommand r' ... needs no
extra handling.
Two cases do need you to add explicit handling:
- If your node is built on the lower-level
withBinaryIO/CommandIO(used when you need to redirect a subprocess’s stdin/stdout yourself, e.g.WireGuard.privateKey/publicKey), you get a rawProcessHandleback instead of a checked result — callBinary.checkExitCode labelon it yourself (it throwsCommandFailedSimpleon non-zero). - If your node’s
uprecursively runs its own nestedupTree(e.g. to drive a remote machine, seePostgresMigrations.remoteMigrateOpaqueSetup), you must check the returnedBoolyourself andthrowIOif it’sFalse— the outer traversal has no other way to learn the nested one failed:up = do ok <- upTree r nat nestedGraph unless ok (throwIO (userError "nested upTree failed"))
Never swallow an exception inside up/down (e.g. via catch/handle) just
to “keep going” — that defeats Failed/Blocked propagation and makes a real
failure look like success to the traversal and to anything scripting around
this binary’s exit code.
All of the above is the one-shot drivers (run up/run down). Under run serve a node is tended rather than applied once (Salmon.Actions.Upkeep):
a failing up is retried on a backing-off delay, and a dependant waits for
its dependency to recover rather than being reported Blocked for good. The
node’s Supervision (§7) says what to do when it keeps failing; see
serve-supervision.md. The rule for up itself is
the same either way: throw.
6. Composing with Track/Tracked — when a node needs a value from elsewhere
Sometimes one node’s construction genuinely needs a value that another part
of the graph produces or that the caller controls (not just an ordering
dependency) — e.g. “grant these rights to this database owner role, which
some other part of the setup created.” That’s what Track/Tracked are for:
newtype Track m n a = Track { run :: a -> OpGraph m n }A Track' a (i.e. Track Identity Actions' a) is “given an a, I know how to
build the Op that provisions it.” You’ll see this passed around as a
parameter so callers can plug in different strategies for “how do I get an
a” without the node itself caring — e.g. Postgres.database takes a
Track' (Binary "psql") so callers can point it at a locally-installed
psql binary or a differently-provisioned one.
ignoreTrack :: Track' a is a real, always-available Track that discards its
input and produces a no-op Op — use it when you have a value already in hand
and don’t need the Track machinery to produce anything more, only to
satisfy a function signature that expects one (this is extremely common; grep
existing recipes for ignoreTrack to see the pattern used dozens of times).
You do not need Track/Tracked for the vast majority of new nodes — only
reach for it when you’re deliberately decoupling “how do I build a value” from
“what do I do with it,” the same way Postgres.hs and the recipes in
salmon-ops-recipes/src/SreBox/ do it. See salmon-core.md
§Track for the type-level reasoning.
7. dynamics — attaching arbitrary metadata to a node
dynamics :: [Dynamic] lets you stash arbitrary typed metadata on a node that
some other part of the codebase can later recover by type, without changing
Extension itself. A few real uses:
-
placeholder(inSalmon.Builtin.Extension) stashes aPlaceHolder Textso dot-graph rendering can show a label without the node needing realup/downbehavior. -
getDynamics/collectDynamicswalk a graph and pull out everyDynamicof a chosen type — used e.g. to flatten “which of these ops are actually remote calls” out of a graph for special handling. -
Debian.Package.debstashes aPackage, which thebatchPackagesrewrite (Salmon.Op.Rewrite) collects across the whole graph into oneapt-getinvocation. -
A
Salmon.Op.Supervision.Supervisionstates how the node wants to be tended: whether to put it back when itschecksays the effect has gone (Always/OnFailure/Never, defaulting toOnFailure), how long its silence may last before somebody should worry, and —supStrategy— what its going away means for the nodes standing on it.Salmon.Actions.Upkeepreads it back with the samegetDynamics. One line, and a node with no opinion needs none:dynamics = [supervised defaultSupervision{supWatchdog = Just (seconds 30)}]Amend
defaultSupervisionrather than spelling out every field: the record has grown three times and will again, and a node that only cares about its watchdog should not have to have an opinion about giving up.supStrategyis the one field authored for somebody else’s benefit. The default,OneForOne, is that putting this node back is a statement about this node and nothing downstream is disturbed.RestForOnesends every dependant back toWaitUp, to be brought up again on top of whatever this node turns into — Erlang’s strategy of the same name, read along dependency edges. A configuration file is the case for it:-- the services reading this file are bounced when it is rewritten dynamics = [supervised defaultSupervision{supStrategy = RestForOne}]Note it goes on the config node, not on the services: the file’s author knows their content is load-bearing, while each service reading it would otherwise have to know, separately, that it might change underneath. Only
run serveacts on it (a one-shot pass has no “already up” to send back from), and it costs nothing at all until a node opts in. The full field list (supStableAfter,supGiveUpAfter,supReapply, …) is inserve-supervision.md§6.
That last one is what the field is really for, and it’s worth understanding
the shape. A node says “I am a Package” without knowing what will be done
about it, and a later pass collects the set and acts on it. The later pass is
the only place that can act: your Track' directive is a function of one
directive in isolation, so it can’t see the other declarations run serve
currently holds, or which way each of their nodes is wanted. A Rewrite runs
after the graph has been folded to a Ref-keyed DAG, where both of those
exist — so it can batch across declarations and split an install batch from a
removal batch, neither of which a recipe could express. Register one with
CommandLine.execCommandOrSeedWithRewrites rather than applying an Op -> Op
inside your Track'.
You will rarely need to add a new dynamic type; if you find yourself wanting
one, search for existing toDyn/fromDynamic/getDynamics usages first. The
question to ask is whether some later, wider pass needs to know this about
your node — if the answer is “only this node cares”, it’s a field on your own
value type, not a dynamic.
8. Writing a new builtin node — a worked template
Put a new node under salmon-ops/src/Salmon/Builtin/Nodes/<Concern>.hs (one
module per concern — see the existing list: Filesystem, Systemd,
Debian.Package, Podman, Postgres, Netfilter, Ssh, WireGuard, etc.).
Minimal template:
module Salmon.Builtin.Nodes.Widget where
import qualified Data.Text as Text
import Salmon.Builtin.Extension
import Salmon.Op.Ref (mkRef)
import Salmon.Reporter
-- | Whatever this node needs to do its job.
data Report = CreatedWidget !WidgetName | RemovedWidget !WidgetName
deriving (Show)
newtype WidgetName = WidgetName Text
deriving (Eq, Ord, Show)
widget :: Reporter Report -> WidgetName -> Op
widget r name =
op "widget" nodeps $ \actions ->
actions
{ help = "creates a widget named " <> getWidgetName name
, ref = mkRef "widget" name
, up = createWidget name >> runReporter r (CreatedWidget name)
, down = removeWidget name >> runReporter r (RemovedWidget name)
}Points worth calling out explicitly since they’re easy to get subtly wrong:
Reporter ris the standard way a node emits structured events (notprint/logging directly) — every builtin module defines its ownReportsum type and takes aReporter Reportas its first argument, thencontramaps it into sub-calls (contramap SomeConstructor r) when composing with other nodes’ reporters. Look at any existing module (e.g.PostgresInit .hs’sReporttype, which wrapsPostgres.Report/Self.Report/etc.) to copy the pattern for a module that composes several builtins.- Don’t forget idempotency (§4) before calling it done — this is the most commonly-missed step for a first-draft node.
- Real subprocess calls should go through
Salmon.Builtin.Nodes.Binary(withBinary/untrackedExec) rather than rawSystem.Processcalls, so failure propagation (§5) is automatic.
9. Wiring a node into a CLI binary (seed → spec → ops)
If you’re building a whole binary (not just adding one node to an existing
recipe), every salmon binary follows the same two-subcommand shape via
Salmon.Builtin.CommandLine.execCommandOrSeed:
my-salmon config <seed-args...> # seed (CLI-friendly) -> JSON directive on stdout
my-salmon run up|down|tree|dag # reads a JSON directive on stdin, executes/prints it
my-salmon run serve # reads seed declarations as lines on stdin, converges and tends them
(run serve is the long-running driver; see
serve-supervision.md. There is also a query
subcommand — query show/query plan/query extract-directive — for
looking at or excluding part of the graph; see specs/advance-querying.md.)
You need three things (see salmon-apps/src/Migrator.hs as the worked,
complete example):
- A
Seedtype — parsed from CLI args (Options.Generic/optparse-applicative). - A
Spectype —FromJSON/ToJSON, the thing that actually gets piped over stdin/stdout betweenconfigandrun. - A
Configure IO Seed Spec(Configure { gen :: Seed -> IO Spec }) plus aTrack' Spec(i.e.program :: Track' Spec,program = Track $ \spec -> ... build an Op from spec ...) that turns theSpecinto the actualOpgraph.
main :: IO ()
main = do
cmd <- execParser (info parseRecord ...)
CLI.execCommandOrSeed reportPrint configure program cmd
program :: Track' Spec
program = Track $ \spec -> op "program" (deps [...]) id
configure :: Configure IO Seed Spec
configure = Configure $ \seed -> ... build a Spec from seed ...Migrator.hs itself calls CLI.execCommandOrSeedWithRewrites, which is the
same plus a reporter for run serve’s own reports and a list of Rewrite
phases to run over the graph (§7). Use execCommandOrSeed if you have no
rewrites.
This split exists so config generation (impure, human-parametrized, runs on
the commanding machine) and execution (must be IO/hermetic, meant to run
unattended, e.g. piped to a remote box over ssh) stay separate, independently
inspectable steps — the JSON Spec is the contract between them.
10. Testing ops
Tests for salmon-ops-recipes live under salmon-ops-recipes/test/Test/ and
run via cabal test salmon-ops-recipes. All the plumbing you need is in
Test.Harness (salmon-ops-recipes/test/Test/Harness.hs) — read that file;
this section is a guide to it, not a replacement for it.
Tests are organized into four tiers by IO cost/blast-radius (Layers 0–3; this section covers the first three, which are what a new node almost always needs). Pick the cheapest tier that actually exercises what you changed.
Layer 0 — structural, no side effects
Assert on the shape of the graph itself, not on any effect. Use
Salmon.Builtin.Extension.evalDeps :: Op -> Cofree Graph Op to walk the graph
and check what’s in it (e.g. “does this graph contain a node with this
Ref”, “how many nodes does it have”). No IO happens at all. Good for
checking wiring/composition logic (did I actually connect the deps I meant
to) without needing any real environment.
Layer 1 — sandboxed IO, no external services
Run the real up/down for real, but only against something throwaway and
local — a temp directory, an in-process value — nothing that needs a running
service. Use:
runUp :: Op -> IO Bool -- runs upTree, returns True iff everything succeeded
runDown :: Op -> IO Bool -- runs downTree, same contract
withTempDir :: (FilePath -> IO a) -> IO a -- auto-cleaned scratch dir
runUpCapturing :: Op -> IO [UpDown.Report Extension] -- full trace, for asserting Skip/Eval/Blocked directlyExample shape (see JWTSigningSpec.hs for a real one):
testCase "writes the file" $ withTempDir $ \dir -> do
let op = someNodeThatWritesInto dir
ok <- runUp op
assertBool "expected up to succeed" ok
contents <- readFile (dir </> "expected-file")
contents @?= "expected contents"Always check the Bool runUp/runDown return (via assertBool) in
addition to any postcondition check — a postcondition alone can’t tell you
whether the traversal itself reported Failed/Blocked somewhere it
shouldn’t have.
Layer 2 — real system services via disposable podman containers
For anything that needs a real service (postgres, a systemd unit, apt) to
verify against, dogfood the project’s own Podman builtins as the sandbox
provisioner — do not hand-roll podman run/podman rm shell-outs. Guard the
whole test with requireExecutable "podman" so CI/dev machines without
podman skip loudly instead of failing:
testCase "creates a real database" $ requireExecutable "podman" $
withContainer (Podman.Image "debian:bookworm")
(Podman.PortMapping "15432" "5432" Podman.TCPPort) $ \cid -> do
-- one-time sandbox prep that isn't part of the recipe under test:
podmanExec_ cid ["apt-get", "update", "-qq"]
withShimmedPath cid ["apt-get", "sudo", "bash"] $ do
let op = MyRecipe.setupSomething ...
ok <- runUp op
assertBool "recipe should fully succeed" ok
-- postcondition: check it for real, inside the container
(code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-lqt"]
assertBool ("expected db in: " <> out) ("mydb" `isInfixOf` out)Key pieces, all from Test.Harness:
withContainer :: Podman.Image -> Podman.PortMapping -> (String -> IO a) -> IO a— pulls the image, starts a uniquely-named container, hands you its container id, and guarantees teardown (via the realPodmandownaction, not a manualpodman rm) even on exception.withShimmedPath :: String -> [String] -> IO a -> IO a— for recipes that shell out to real binaries by name (apt-get,sudo,psql, …) with no indirection to mock: writes lookalike wrapper scripts for each named command that forward into the container viapodman exec, and prepends them toPATHfor the duration of the action. This lets you run the recipe’s actual, unmodified command construction and graph wiring against the sandbox, instead of testing a mocked stand-in. Figure out which commands to shim by reading which binaries the recipe under test actually invokes (seePostgresInitSpec.hs’sshimmedCommandsfor a worked example and comment explaining the reasoning).podmanExec_ :: String -> [String] -> IO ()— run a setup command inside the container, discard output,erroron non-zero exit. For sandbox prep only (e.g.apt-get install sudo), not for the thing under test.podmanExecCapture :: String -> [String] -> IO (ExitCode, String, String)— same, but for postcondition checks: hands back everything instead of throwing.
A real bug this pattern caught (documented at the top of
PostgresInitSpec.hs): a builtin used to hardcode a Postgres cluster version
that didn’t match the container’s actual installed version, so the recipe
silently failed to start the cluster — a Layer 0 test would never have caught
this, because the graph shape was perfectly correct; only running it for
real against a real (if disposable) Debian container did.
Layer 3 — a whole machine, via a qemu VM
For what a container can’t exercise well (systemd as PID 1, real network
interfaces), Test.Harness also boots a qemu VM from a debootstrapped rootfs
(withVm/withVmAt, then sshToVm/scpToVm), dogfooding
Salmon.Builtin.Nodes.LinuxBridge/Qemu as the provisioner. It needs root or
the one-time setcap grants described on hasVmPrivileges, and skips loudly
otherwise; see QemuSmokeSpec.hs for the smallest example and
specs/qemu-test-vms.md for the design. Layer 2 and 3 specs contend for
process-global resources (PATH shims, one test bridge), so test/Main.hs
runs them inside one sequentialTestGroup — add yours there, not to the
concurrent list.
Choosing a layer: a quick decision guide
- Changed how ops are wired together (deps/ordering/ref dedup)? → Layer 0 is probably enough.
- Changed what a node actually writes/does, but it’s pure filesystem/local state? → Layer 1.
- Changed anything that shells out to a real service’s CLI (
psql,pg_ctlcluster,nft,systemctl,apt-get,podman, …) or depends on that service’s actual behavior? → Layer 2 — a Layer 0/1 test would pass even if the actual command is wrong, wrong-ordered, or the tool’s real idempotency behavior doesn’t match what you assumed. - Needs a real init system, real interfaces, or more than one machine? → Layer 3.
Running the tests
cabal test salmon-ops-recipes
Layer 2 tests are skipped loudly (not failed) if podman isn’t on PATH —
check the test output for SKIPPED: lines if you expect Layer 2 coverage and
don’t see it exercised.