Declaring your services¶
How a project says which services make it up, what each one declares, and how Seal finds them without a registry to keep in agreement.
Your root Tiltfile is the service list¶
There is no seal.json, no services array, no path registry. A project’s
own root Tiltfile is a real, checked-in file, and what it include()s is
what the project deploys:
v1alpha1.extension_repo(name='seal', url='https://github.com/vdel/seal')
v1alpha1.extension(name='seal', repo_name='seal', repo_path='tilt/seal')
load('ext://seal', 'select_k8s_overlay', 'publish_images')
if publish_images:
default_registry('ghcr.io/your-org')
include('services/api/Tiltfile')
include('services/ui/Tiltfile')
include('third-party/nginx/Tiltfile')
select_k8s_overlay(k8s_dir='k8s', default_overlay='dev')
Reading it top to bottom:
The
v1alpha1.*calls register Seal’s extension, once, session-wide. Every Tiltfileinclude()d afterwards canload('ext://seal', ...)directly. See Registering the Tilt extension.default_registry()is Tilt’s own call, unmodified. Whether to skip it – indev, say, where there’s nothing to push to and no registry to log into – is your project’s choice. It has to run before anyinclude(): Tilt only rewrites image refs for builds declared after it.One
include()per service. Your own services, third-party ones, anything with a Tiltfile. Put a dependency ahead of what depends on it.select_k8s_overlay(k8s_dir=..., default_overlay=...)goes last, after every service is included. It deploys one of<k8s_dir>/’s overlays as a kustomize overlay – whichever--k8s_overlaynames, or thedefault_overlayyou pass it.
If your outcome tree needs wiring in, register_outcome_runner() goes at
the very end – see Outcome tests.
A service’s own Tiltfile¶
Each service declares itself in one call:
load('ext://seal', 'seal_service')
seal_service(
'example-api',
context='.',
dockerfile='Dockerfile',
live_update=[...],
development_stage='development', # this service's own stage names
runtime_stage='runtime', # omit any it doesn't have
test_stage='test', # omit if it has no tests
junit_tests_directory='/app/tests-results/', # required with test_stage
reset='deploy/reset.sh', # omit if it owns no state
)
seal_service() is the one-call version of what you’d otherwise write by
hand: build this service’s image and – where asked for – wire up its
test-result copy-back and its reset resource. It says nothing about
credentials; which object carries those is the overlay’s to say. Every
argument is in the
Tilt extension reference.
A few of them are worth knowing before you write your first one:
development_stage,runtime_stage,test_stageare this service’s own Dockerfile stage names, one per--build_type. Seal names the kind of image a run wants; your service names what to build for it, so your Dockerfile can spell its stages however it likes. Each is optional, and leaving one out says something: noruntime_stagebuilds the Dockerfile’s last stage (right for a single-stage service); nodevelopment_stagefalls back to the runtime one (right for a worker that runs the same image everywhere); notest_stagemeans this service has no tests of its own. Givingtest_stageis what turns on this service’s result syncback and its own verdict resource – so CI fails pointing at whichever service actually broke, rather than at one combined check.junit_tests_directoryis required whenevertest_stageis given. Seal fails loudly rather than guessing where your Dockerfile writes results. What it finds there has to include a JUnit report – see How a service’s tests run.test_commandis what Seal runs, inside the running container, to produce that report. Required whenevertest_stageis given.resetis a script that puts the state this service owns back to a known baseline. See Deterministic test state.
A service with no services/<name>/.env at all resolves nothing – there’s
nothing to opt out of.
Adding a service¶
A service is a directory under services/ and four things in it:
A
Dockerfile, with a stage per kind of run.A
Tiltfilecallingseal_service()– copy the one above.A
.env, if it needs credentials – see Credentials.Kubernetes manifests, under your
k8s/overlay.
Then add include('services/<name>/Tiltfile') to your root Tiltfile.
A third-party service has no source to build, so it declares no
seal_service() at all: write <wherever>/<name>/Tiltfile by hand
and include() it the same way.
Tip
A service you just added not showing up in Tilt almost always means your
root Tiltfile doesn’t include() it. Tilt re-evaluates the whole Tiltfile
from scratch on every run, so there’s nothing stale to clear.
Readiness, and what seal ci refuses to start without¶
seal ci exits successfully only once Tilt reports every resource
ready. That is exactly what makes it usable as a gate: green means the
environment finished coming up, not that it started to.
That guarantee is only as good as Kubernetes’ idea of “ready” – and a
container with no readinessProbe is marked Ready the instant its process
starts, before it can serve a request or run a task. One such Deployment
quietly turns the whole run’s claim into a weaker one without ever failing.
So seal ci refuses to start until every Deployment your overlays deploy
declares a readinessProbe on every container.
Each overlay is built to find out, rather than its files read one by one. A
kustomize patch is a Deployment document too, and an entry in its
containers may be introducing a container, amending one a base declares,
or deleting it – three things the file alone cannot tell apart. Building
settles it, and means a patch adding a volumeMount needn’t restate a probe
that lives in the base. A finding names the overlay it came from: the same
container can be fine in the shape you work in and missing a probe in the
shape that ships.
The same check, on its own:
seal check # over ./k8s
seal check --k8s-dir deploy # look somewhere else, just this once
Keep your manifests somewhere other than k8s/? Name it in
SEAL_K8S_DIR, which both commands read. It’s an environment variable
rather than a flag because seal ci hands every argument it’s given
straight through to tilt ci, leaving it none of its own to spend.
The check walks the manifest tree rather than consulting a list, so a
Deployment added later is covered by having been added. It reads the
documents as written rather than running kustomize, so a container a patch
introduces is checked where it’s declared; documents that declare no
containers – a patch that only changes replicas – have nothing to say
about readiness and are left alone.
seal up runs none of these checks. A half-finished manifest should not
stand between you and your cluster.
Which probe is right is your service’s business, not Seal’s – an HTTP endpoint where there’s a server, a command where there isn’t:
# a worker with no port to poll: ask the process itself
readinessProbe:
exec:
command: ["sh", "-c", "celery -A config inspect ping -d \"celery@$(hostname)\""]
What is deliberately not checked is which resource waits on which (Tilt’s
resource_deps). Declaring those edges reduces crash-loop churn while an
environment comes up and is worth doing, but tilt ci waits for every
resource whatever order they started in – so ordering isn’t what makes the
signal trustworthy, and inferring the right edges would mean guessing at
dependencies the manifests only imply.
How discovery actually works¶
Two mechanisms, and neither needs a path registry.
From Starlark. Every Tilt builtin that takes a relative path resolves it
against whichever Tiltfile’s top-level code is currently executing – not
the file where the calling function is defined. A plain function call
doesn’t change that; only include()/load() do. So a Seal helper
several calls deep can read a bare relative path – seal_service()’s
own reset script, for one – and correctly reach that service’s own
file. This is what lets a service’s own directory be the only thing that
knows where it is.
From Python. seal up/seal ci need the same information
to fill each overlay’s objects before Tilt starts. They get it by running tilt alpha tiltfile-result against your root Tiltfile: that fully evaluates it
– the same include()s and seal_service() calls tilt up would
run – without needing a live cluster, and prints the resulting model as
JSON. Every image build appears as one ImageTargets[] entry whose selector
is the service’s name and whose build context is its directory.
Per-service test-result reporting¶
Giving seal_service() a test_stage and a junit_tests_directory
turns on that service’s own result syncback and verdict check, entirely
self-contained – no central registry has to know about it in advance. Each
tested service gets its own seal_tests_verdict_<service_name> Tilt
resource, so tilt ci fails pointing straight at whichever service’s tests
actually failed.
Results land in tests-results/<service_name>/, beside where outcome
results land.
How a service’s tests run¶
Your test stage carries the suite. It doesn’t run it:
FROM development AS test
COPY ./tests /app/tests
COPY ./Makefile /app
Seal runs it, inside the container, once the service is ready:
seal_service('example-api', test_stage='test',
junit_tests_directory='/app/tests-results/',
test_command='make unit-tests')
That gives the tests the container’s own environment – this service’s Secret, its database, its broker – so a test needing any of them is an ordinary test of this service instead of something that has to become an outcome test to run at all.
The one thing your command has to do is leave a JUnit report in
junit_tests_directory. That report is the whole verdict – Seal
reads it, not an exit code, not a status file – and most runners take one
flag to produce it (pytest --junit-xml=..., karma-junit-reporter,
--reporter junit). No report, a report holding no test cases, and a report
that doesn’t parse are each a failure, so a suite that never ran can’t
pass for one that did.
What your command must not do is anything about failing. Seal discards the suite’s exit code itself: the results reach your project by being copied out of the container after the run, so a run that stopped at a failing test would take the report of what failed with it.
Where a service declares a reset too, Seal restores the baseline on
both sides of the run:
service ready → reset → this service's tests → reset → outcome tests
The first restore means your tests read this service’s state from a known starting point; the second means an outcome test does too, whatever your tests did. You don’t wire either.
A service that declares no reset has nothing putting its run ahead of the
outcome suite. Name it there if that matters:
register_outcome_runner(
base_url='http://web:8000',
resource_deps=[
reset_resource_name('example-api'),
tests_run_resource_name('example-ui'),
],
)
Tip
tilt trigger seal_tests_run_<service> re-runs one service’s suite
against the session that’s already up.