Phase 6 — IaC + observability¶
Feature spec for SPEC.md §9 Phase 6: multi-stage non-root Dockerfile; compose (service+Ollama+collector+Jaeger); k8s (deployment, service, PVC, probes); Terraform module for one concrete provider; OTel verified end-to-end in compose.
Status: done — change 1 landed 2026-08-15; change 2 (OTel instrumentation,
JSON logs) has since landed too and been verified end-to-end in compose, with
ingest, retrieve, and synthesize spans all observed in Jaeger (see
infra/README.md's dated "Verification status" table). Nothing here overrides
SPEC.md; where this document makes a decision SPEC.md does not already
contain, it names the ADR that holds it.
infra/ is created by this phase and by no earlier one. SPEC.md §3 is explicit
that empty IaC directories are decoration, which is why the directory arrives
with its contents rather than ahead of them.
1. What Phases 1–4 already provide¶
| Asset | State | Phase 6 use |
|---|---|---|
grk serve (REST + MCP over one runtime) |
shipped Phase 4 | the container's default command |
service/binding.py bind guard |
shipped, refuses non-loopback without acknowledgement | re-established one layer out (ADR-0021 decision 1) |
GET /v1/collections |
shipped, opens nothing | liveness/readiness target (§4.2) |
SQLiteMetadataStore in WAL mode |
shipped Phase 1, ADR-0002 | decides the storage primitive (ADR-0020 decision 1) |
OllamaEmbedder._allow_private_endpoint |
shipped Phase 4, ADR-0014 decision 10 | makes http://ollama:11434 on a bridge network reachable |
service/api.py access log |
id, method, route, tool, status, latency, results — never query text | the record JSON formatting wraps, unchanged (ADR-0022 decision 4) |
service/errors.py typed kind |
shipped, closed mapping | the failure-code attribute and log field |
Two things are not seated:
- Nothing emits a span. There is no tracer, no exporter, no
opentelemetrydependency of any kind in the tree. - There is no health endpoint. The four operations in
service/tools.pyare the complete route set, asserted by a parity test — see §4.2, which is the sharpest constraint in the phase.
2. Done means¶
- A multi-stage, non-root image that builds from the repo root and runs
grk servewith no writable root filesystem. - A compose stack — service, Ollama, OTel collector, Jaeger — that comes up
from
docker compose upwith no credentials and no host ports beyond loopback. - Kubernetes manifests: namespace, PVC, deployment with liveness/readiness/ startup probes, ClusterIP service, and the one-shot ingest Job that makes the deployment reachable at all (an index directory the service will not create has to be created by something).
- A Terraform module for one concrete provider, with the provider choice recorded in an ADR before the module was written.
- OTel spans on
ingest,retrieveandsynthesize— all three of SPEC.md §3's sites — structured JSON logs carrying request id, latency, result counts and typed failure codes, and neither carrying document content or query text. - Every IaC path either verified with the date recorded, or explicitly marked not yet verified with the reason. SPEC.md §1.4 requires the first; SPEC.md §2 forbids inventing it, and §8's regression-test rule is the same principle applied to tests. See §6.
- All gates green: ruff, ruff-format,
mypy --strict, pytest, both coverage gates,mkdocs build --strict, pip-audit, gitleaks. - SPEC.md §9 status updated,
KNOWN_LIMITATIONS.mdupdated, ADRs accepted.
3. Two changes, not one¶
Phase 5 is being built concurrently in the same clone. The phase is therefore split along the line that lets the first half merge regardless of Phase 5's state:
Change 1 — additive. infra/, this spec, ADR-0020/0018/0019, the
deployment guide, nav, and the CI job that gates the infra. Nothing under
src/ changes, so there is no file this change and a Phase 5 change can both
touch.
Change 2 — instrumentation. The opentelemetry-api base dependency, the
otel extra, the tracer helper, spans on Indexer.index_source and
Indexer.index_directory (not Indexer.run — that method has never
existed; see ADR-0022 decision 5's erratum), Retriever.search and
Synthesizer.synthesize, the JSON log formatter, the image gaining
--extra otel, and the compose stack's first real trace.
The third span site was originally deferred out of this phase, on the grounds
that Phase 5 had not built it yet. It has: Phase 5 is done (SPEC.md §9) and this
branch merged main, so providers/synthesis.py is in the tree. ADR-0022
decision 5 withdraws the deferral, and change 2 owes all three sites — the
synthesis span most carefully, since it is the one sitting next to prompt text,
completion text and citation spans, none of which may become an attribute.
Rebased on origin/main immediately before it opens,
so Phase 5's landed work is integrated over rather than around.
The cost of the split, stated so it is not mistaken for an oversight: after change 1 the compose stack runs a collector and a Jaeger that receive nothing. The topology is real and the pipeline can be proved with a synthetic OTLP payload (§6.2), but no groundkit span reaches it until change 2. Every document that describes the stack says this in the place a reader would otherwise assume otherwise.
4. Decisions¶
4.1 Settled before the code — three ADRs¶
- ADR-0020 — the Terraform target. AWS, one EC2 instance, one attached EBS volume, no ingress rules at all, access by SSM port forwarding. The decisive argument is storage, not provider preference: SQLite's WAL shared-memory mapping is not reliable over NFS, which disqualifies every serverless-container-plus-managed- filesystem answer (Fargate+EFS and its equivalents) before compute is even considered.
- ADR-0021 —
what containerisation does to two Phase 4 guarantees. The
127.0.0.1bind cannot hold inside a container, so it is re-established at the publish boundary in all three surfaces; and the read-only claim splits, with/data/corpusbecoming a genuinely read-only mount while/data/indexcannot be one while WAL is in use. - ADR-0022
— the observability shape.
opentelemetry-apiin base so instrumentation needs no guarded imports, SDK and exporter in anotelextra, standardOTEL_*environment configuration rather than groundkit config keys, and an allowlist — not a denylist — for span attributes.
4.2 Probes have no health endpoint to point at, and inventing one is a src/ decision this change does not take¶
ADR-0014 decision 2 check 2 is a parity test: the set of (path, method)
pairs the app registers must equal the set the registry declares, with FastAPI's
own documentation routes excluded by an explicit DOC_PATHS frozenset. That
test is the mechanism by which "this surface exposes exactly the four declared
read-only operations" is a property rather than a claim.
A /healthz route is a fifth pair. Adding it means widening that exclusion set,
which is a change to a security-relevant invariant, in src/, in a phase whose
first change deliberately touches no src/ file.
So the probes target GET /v1/collections, and the limitation is stated
rather than papered over:
- It proves the process is accepting and serving HTTP, and that the index directory is listable. That is a real check — it is not a static handler.
- It does not prove any collection is usable.
handle_list_collectionsreturns[]for a missing or empty index directory rather than failing, so a pod with an unmounted volume reports ready. - Using a real operation as a liveness probe has a specific failure mode worth naming: if the index volume hangs, the probe hangs, the kubelet restarts the pod, and the restart does not fix a hung volume. The manifests therefore give liveness a long period and a high failure threshold, so it reacts to a wedged process rather than to storage weather.
A dedicated health endpoint — its route excluded from the registry parity set
deliberately and with a test asserting the exclusion is exactly one path — is a
named obligation on the phase that next touches service/api.py, not an
omission.
4.3 The image must bind 0.0.0.0, and the flag that admits it stays¶
ADR-0021 decision 1. Recorded here as well because it is the single most
surprising line in infra/docker/Dockerfile: the default command contains
--allow-remote-access, and it is correct that it does. What used to be a
refusal is now a warning, and the manifests are what make the guarantee true.
4.4 Pinned image tags are a claim, and this repo does not make unverified claims quietly¶
Every third-party image in the compose stack is pinned by tag. None of those
tags was pulled while this change was written — no container runtime was
available (§6). A CI step therefore resolves each pinned reference with
docker manifest inspect, which is cheap, needs no layer download, and fails
the build on a tag that does not exist. That is the difference between a pin
and a guess.
5. What is built¶
infra/
README.md verification-status table; read first
docker/Dockerfile multi-stage, non-root, read-only-root-ready
compose/docker-compose.yml service + ollama + otel-collector + jaeger
compose/otel-collector.yaml OTLP in, OTLP to Jaeger + debug out
compose/.env.example names only; no values, ever
k8s/ namespace, NetworkPolicy, pvc, deployment, service
k8s/ingest/ the ingest Job, own kustomization (image override)
k8s/pod-corpus-loader.yaml a `kubectl cp` target; plain -f, no override needed
terraform/aws-ec2/ ADR-0020's module
5.1 Corrected after review¶
Six findings from the automated review of the first push were confirmed against
the tree and fixed; none was a false positive. Recorded here because four of
them share a shape worth naming — a manifest that is accepted, renders, and
does the wrong thing at apply time, which is precisely the class that
kustomize-renders-cleanly and terraform validate-passes cannot catch:
ClusterIPwas described as re-establishing the access boundary. It closes the cluster's edge only; any pod in any namespace can dial it.infra/k8s/networkpolicy.yaml(default-deny ingress) is new, and ADR-0021's claim is corrected rather than quietly softened.- The ingest Job never received the kustomize image override, because a
transformer reaches only its own kustomization's resources — so the
documented
kubectl apply -fended inImagePullBackOffon the unpublished placeholder. It now has its own kustomization atk8s/ingest/. - The documented Kubernetes sequence started the Deployment before the
one-shots, which stalls on a multi-attach error against a
ReadWriteOnceclaim on any multi-node cluster — and works on a single-node one, which is what would have let it ship. create_ssm_vpc_endpointswas advertised as making a NAT-free subnet work. It carries the SSM control channel; bootstrap still needs egress fordnfand the image pull.- The documented private-ECR image could not be pulled: the role carried
SSM permissions only and bootstrap performed no
docker login. ECR is now detected from the image reference, which scopes the IAM grant and keeps the documented example working without a flag anyone has to remember. - The
groundkit-ingesthelper ingested BM25-only while the service served--dense, producing a manifest-less collection whose every dense and hybrid request is refused (ADR-0008) — reported as an index inconsistency three steps from its cause. Both are now rendered from one input.
The review also missed one, found by diffing the merge against origin/main:
merging main into this branch deleted Phase 5's KNOWN_LIMITATIONS.md
section and reverted its SPEC.md §9 row to pending. Both are restored
verbatim. A merge that resolves a shared file wholesale to one side is not
visible in that side's own diff, which is why the check has to be against the
base rather than against the branch.
Change 2 adds, under src/: groundkit/telemetry.py (tracer accessor and the
typed-keyword attribute helper ADR-0022 decision 3 requires), a
logging.Formatter subclass, spans on Indexer.index_source and
Indexer.index_directory (indexer.py — corrected from Indexer.run, which
this document and ADR-0022 decision 5 both originally named and which has
never existed), Retriever.search (retrieval/search.py) and
Synthesizer.synthesize (providers/synthesis.py), and --extra otel in
the image build.
6. Verification — what was executed, and what was not¶
SPEC.md §1.4 requires each IaC path be "verified to work, with the verification date recorded." SPEC.md §2 forbids publishing a number, or a date, that was not generated by a run. Where those meet, the honest answer is a status table with some rows unfilled, and this is it.
6.1 Environment as found¶
| Tool | State on the machine this change was written on |
|---|---|
docker CLI |
present |
| Docker daemon | not running — npipe:////./pipe/dockerDesktopLinuxEngine unreachable |
docker compose CLI |
present (validates files; cannot build or run without the daemon) |
kubectl |
present, no cluster context configured |
terraform |
not installed |
Nothing was built there, no image was pulled, no container ran, no manifest was applied to a cluster, and no Terraform plan was produced.
That is what the CI job in §6.2 is for, and it is not a formality: a runner has
a daemon and registry access, so on this change's first CI run the image built,
ran as uid 10001 under a read-only root, and all six pinned third-party tags
resolved — verifications this machine could not perform at all.
infra/README.md carries the status table, marks which rows were earned on a
runner, and is the file to update when a path is actually exercised.
6.2 The verification each path is owed¶
Recorded as the procedure, not as a result. A date goes into infra/README.md
only next to a row someone has actually run.
- Image.
docker build -f infra/docker/Dockerfile -t groundkit:local ., thendocker run --rm groundkit:local --versionand a check that the container's user is10001and its root filesystem is read-only. - Compose.
docker compose -f infra/compose/docker-compose.yml up -d; ingest the sample corpus through the one-shot run;curlthe loopback publish forGET /v1/collectionsand aPOST /v1/search; confirm the published ports are loopback-only (docker compose psand a bind check from another host on the LAN, which must fail). - The trace pipeline, in two parts. Before change 2, the collector→Jaeger
leg is provable on its own by POSTing a synthetic OTLP/HTTP trace payload to
the collector's
:4318/v1/tracesand finding it in the Jaeger UI. After change 2, the whole leg is provable the way SPEC.md §9 asks: issue a real search and find theretrievespan in Jaeger, with the attribute allowlist holding — no query text anywhere in the span. - Kubernetes.
kubectl kustomize infra/k8srenders offline and is gated in CI. A genuine verification iskubectl apply -k, the ingest Job completing, the deployment reaching ready, andkubectl port-forwardserving a search. That needs a cluster. - Terraform.
terraform fmt -checkandterraform init -backend=false && terraform validateare gated in CI and prove the module is well-formed and its provider schema is satisfied. They do not prove it applies.
What the module derives from its string inputs — the ECR-registry match
that decides an IAM attachment and a docker login, the egress rule the
embedding endpoint needs, and the rendered bootstrap.sh.tftpl — is checked
with terraform console, which resolves locals, variable validations and
templatefile() without configuring a provider. Those runs are local and
dated in infra/terraform/aws-ec2/README.md; they are not gated. A CI
job for them is parked on chore/infra-ci-checks-parked and was cut from
this change on purpose: it re-tests Terraform's own validation engine more
than it tests this module.
Nothing offline reaches a precondition — validate does not evaluate one
and console cannot — so every precondition in the module is unexecuted.
That, not the missing gate, is the module's real exposure, and only a real
verification closes it: an apply into a throwaway account, an SSM
port-forward session, and a search over the tunnel.
CI gates 1, 4 (render only) and 5 on every pull request, so those three cannot rot silently. 2 and 3 need a running daemon and are the operator's to run.
7. Risks and open questions¶
R1 — The compose stack's Ollama leg is the one part that is not air-gap
friendly out of the box. Four images must be pulled once, and the embedding
model must be pulled once into the ollama-models volume. After that the stack
makes no outbound connection: groundkit's only outbound call is to the
configured embedding endpoint, and the collector exports to a sibling container.
The mitigation is documentation plus a pre-seeded volume, and it is the honest
shape of "air-gap friendly" for a stack that includes a model server — the
model has to arrive somehow.
R2 — A pinned tag that has aged out. The pins in the compose file were
chosen for confidence that they exist, not for recency, and no runtime confirmed
them. The docker manifest inspect CI step turns "probably exists" into a gate,
but a tag that exists is not a tag that is current. Refresh them at the first
real verification and record the date beside them.
R3 — Instrumentation lands in files Phase 5 may also be editing.
retrieval/search.py is the likeliest overlap. Mitigated by ordering: change 2
rebases on origin/main and integrates over whatever Phase 5 has landed, rather
than opening in parallel and merging blind. If the overlap turns out to be
sharp, the retrieve span lands and the ingest span waits — spans are independent
of one another in a way most changes are not.
R4 — The core-subset coverage gate and a new src/ module.
groundkit/telemetry.py sits at the package root, so no glob in
[tool.groundkit.coverage].core_subset catches it, and runtime.py's
precedent shows root modules are added to that list explicitly when they
belong there. Telemetry does not decide whether an answer is correct or current,
which is the test runtime.py's entry was argued on — so it stays out of the
core subset and is covered by the whole-package gate like service/.
Decided in change 2, with the reasoning written into pyproject.toml
beside the existing entries (note 4, next to runtime.py's note 3).
Q1 — Does a health endpoint land, and at what cost to the parity test?
§4.2 defers it. The question is not whether a probe target would be nicer — it
would — but whether widening DOC_PATHS-style exclusions is the right shape,
or whether the health route should live on a second ASGI app on a second port
that the registry parity test never sees. The second option keeps the
four-operations invariant literally intact and costs a port. Not decided here;
owed by whichever change next touches service/api.py.
Q2 — Does anything ever make /data/index a read-only mount? ADR-0021
decision 2 says not while WAL is in use, because SQLiteMetadataStore.open
writes on every open. A read-only open path in index/metadata.py — immutable
URI, PRAGMA query_only, no CREATE TABLE IF NOT EXISTS, no chmod — would make
a genuinely read-only serving deployment possible and would strengthen
ADR-0014's read-only claim from a Python-level scoping to a kernel-level one.
That is a real src/ change with its own ADR and is not smuggled into a mount
flag.