How Seal fits together¶
One page for the model behind everything else: what Seal considers a project, what each piece is responsible for, and where the line between “yours” and “Seal’s” falls.
A project is a directory with a Tiltfile and services¶
seal run, seal up and seal ci all walk up from where
you invoke them looking for a directory with both its own Tiltfile and
a services/ subdirectory. That directory is the project root. A service’s
own directory has a Tiltfile too, but never its own nested services/, so
the search is unambiguous.
Everything else is relative to that root:
my-project/
Tiltfile # the entry point -- and the service list
services/
api/
Tiltfile # this service's build, tests, reset
Dockerfile
.env # this service's credentials schema
ui/
...
third-party/
nginx/Tiltfile # off-the-shelf services with no source to build
k8s/
dev/ # one kustomize overlay per manifest shape
outcomes/
seal-test-config.json # what runs each group of promises
<group>/helpers/ # what a group's tests share
<group>/<epic>/<outcome>/
prompt.md # the promise
quarantine.md # optional: why a failure here doesn't fail a run
... # the test translated from it
tests-results/ # where results land, generated
There is no config file, anywhere¶
This is the single idea most worth internalising. Seal never keeps a registry that has to be kept in agreement with the tree:
Which services exist is whatever your root
Tiltfileinclude()s.seal up/seal cidiscover them by asking Tilt itself to evaluate that Tiltfile (tilt alpha tiltfile-result) and reading back the image builds.Which credentials a service needs is whatever key names appear in its own
services/<name>/.env. There’s no separate schema file, and the same file is the schema in dev, CI, staging and production alike.Which promises a project makes is whatever directories exist under
outcomes/. Walking the tree is the whole discovery mechanism.Where a service’s files are is wherever its own
Tiltfileis. Tilt resolves a relative path against whichever Tiltfile is executing, so Seal’s helpers read.envwith a bare relative path and get that service’s own.
The one thing a walk can’t answer is which image runs a directory of
promises, and that’s the whole of what outcomes/seal-test-config.json
declares – so it’s also the one place the tree and a declaration can drift,
which is why seal check compares them.
The two halves¶
ext://seal (Starlark) is what your Tiltfiles call – the one
extension a project registers:
seal_service()– one call per service, wiring up its image build, its test-result syncback and its reset. Not its credentials: which object carries those is the overlay’s to say, not this call’s.select_k8s_overlay()– deploys one of<k8s_dir>/’s overlays.register_outcome_runner()– turns your whole outcome tree into Tilt resources.reset_resource_name(),k8s_overlay,publish_images– the names and flags a project needs to refer to the above.
See the Tilt extension reference.
seal (the CLI) is what you run instead of tilt:
Command |
What it’s for |
|---|---|
|
Local development. Credentials, then |
|
The gate. Checks, credentials, |
|
One command with a service’s |
|
The three static checks, on their own. |
|
List, run, translate and review promises, and say what a change reaches in the tree. |
See the CLI reference.
Two kinds of test, and only one of them is the gate¶
A service’s own tests run inside that service’s own running container,
built from its Dockerfile’s test stage – which is what lets them use its
real database and credentials. Declaring test_stage= on
seal_service() turns on the copy-back of their results and a
per-service resource that fails the run unless the JUnit report they left
says every test passed – so CI points at whichever service actually broke.
Outcome tests live at the project root, not under any one service, because driving several services at once is what makes them outcome tests. Each one sits beside the promise it was translated from. They run as one container per group, from inside the cluster, one outcome at a time.
The gate rests on the second kind, and on two properties of it:
Every promise runs, in one pass. A fix scoped to the test somebody was looking at can break one nobody re-ran, so what has to be reportable is “every promise this project has translated still holds” – which is why
seal ciruns the whole suite and names no subset.No test can be edited without a human.
CODEOWNERSover the outcome tree plus branch protection is what makes “the tests pass” mean the application was fixed rather than the test rewritten.
seal check refuses a tree that breaks either – an outcome with no
prompt to trace it back to, or an outcome the repository’s CODEOWNERS
doesn’t reach.
Red is fixed in the application, not in the test¶
Those two properties decide what a green suite means. The other half of the model is what a red one is for.
A suite passes identically whether the application was fixed or the test was rewritten, and that difference is the whole gate. So getting back to green is a loop with the test held still: run the whole suite from a known baseline, confirm each failure, fix the application, run the whole suite again.
seal outcomes run --reset --all --confirm
Three things in that command, each answering a way a loop goes wrong:
--resetputs every service that declared one back to its baseline first, so this run measures the application rather than the application plus whatever the last iteration left in it.--allruns every promise, because a fix scoped to the one that was red can break another nothing re-ran.--confirmre-runs each failure on its own, from a reset. A promise that then holds is not something an application fix will settle – the test either doesn’t own the data it asserts on or isn’t deterministic – and chasing it costs real time for nothing.
seal outcomes touched says whether an iteration stayed inside the
application. It reports rather than refusing: changing a promise is a
legitimate thing to do, and what it costs is a code owner’s approval.
Quarantine is the one declared exception. A quarantine.md in an
outcome’s own directory, whose content is the reason, makes a failure there
non-blocking while somebody fixes the test’s determinism. It is still run and
still reported, and never counted as passed. It lives inside the outcome so
the CODEOWNERS rule over the tree already covers it – which is what stops
it being a way to a green suite that never went near the application.
One credential path, four scopes¶
Local development, CI, staging and production all resolve the same
services/<name>/.env, the same way. The only thing that differs is which
credentials environment a run selects, and so which store each reference is
read from – the .env line itself is identical everywhere, because it
names an item and never a store.
That’s deliberate: separate resolution paths per scope drift, and a value that can be edited somewhere disconnected from the store’s own sharing and audit story is a value nobody really owns. See Credentials.
What your project owns¶
Seal knows nothing about your application: no default port, path,
framework or service name. Where the generic side needs something only you
can say, you say it – through your Tiltfile, your .env, or an environment
variable like SEAL_K8S_DIR.
Concretely, these are yours and Seal never guesses at them:
the address an outcome test reaches your application at (
register_outcome_runner(base_url=...));which resources have to be serving, and which services at a known baseline, before an outcome test means anything (
resource_deps=);what putting a service’s state back actually involves (
reset=);which readiness probe is right for a container;
your registry, your manifests, your promises.