AGENTS.md¶
This file provides concise, high-signal guidance for coding agents working in the Aether repository. Start with Rules for agents below; role-specific agents live in .claude/agents/.
Quick Start¶
Build & Test:
make test # All tests (requires Docker for integration)
make test-unit # Unit tests only (no Docker)
make test-race # Whole tree under the Go race detector
make build-agent # Build agent binary
--config=race (.bazelrc) is the supported spelling; prefer it scoped to what
you touched (bazel test --config=race //agent/internal/meshdns:all) — the whole
tree still has known test-only races, so a bare //... run reports failures you
did not cause.
Format & Lint:
make format # Format all code
make lint # Run linters (buf, shellcheck, gocognit)
make format-check # CI-friendly check (fails on drift + buildifier lint)
make actionlint # GitHub Actions workflows (pinned actionlint + ShellCheck)
Toolchain¶
- Bazel: 9.2.0 (via Bazelisk). Use
bazelcommands directly or viaMakefile. - Go: 1.27.1
- Container images: Built with
rules_img, pushed to distroless (gcr.io/distroless/static-debian13:nonroot). - Protobuf: Uses
buf/validatefor validation. - Static analysis:
nogois wired into the Go SDK (go_sdk.nogo(nogo = "//:nogo")), so vet-class analyzers run as a validation action on every Go target — findings failbazel build, not justmake lint. Scope lives innogo.json.
Architecture¶
Binaries:
- agent/cmd/agent — Node DaemonSet. Manages xDS server (Envoy), CNI gRPC server, SPIRE bridge via controller-runtime Manager. Also hosts the agent edge subcommand.
- agent/cmd/proxy-supervisor — Envoy hot-restart supervisor (own binary + own image since #772; PID 1 of the aether-proxy container). A fraction of the agent's size, no k8s, no xDS, no SPIRE — asserted by :deps_test (forbidden packages plus a size ceiling) and scripts/check-proxy-supervisor-deps.sh; for the current module count run go version -m on the built binary.
- agent/cmd/uds-csi — The csi.aether.io CSI node plugin (proposal 039), the only carrier for UDS-delivered workload sockets since chart 2.0.0. Own image + own privileged DaemonSet (udsCsi.enabled, default on); mounts a per-pod tmpfs at /run/aether/uds/<pod-uid> and serves the kubelet plugin-registration API itself. Logic in agent/internal/udscsi; :deps_test and scripts/check-uds-csi-deps.sh keep it free of client-go/controller-runtime/SPIRE.
- agent/cmd/mesh-dns — Slim standalone mesh-DNS daemon (own DaemonSet + image). Serves pods from the record snapshot the agent writes.
- registrar/cmd/registrar — In-cluster Deployment. Proxies external registry (Kubernetes/etcd), maintains endpoint snapshot, streams to agents via gRPC.
- controller/cmd/controller — In-cluster Deployment (leader-elected). Serves the validating + pod-mutating admission webhooks and the MeshConfig→ConfigMap reconciler.
- cni/cmd/cni — CNI plugin binary (Add/Del/Check/GC/Status).
- cni/cmd/cni-install — Init container that installs the CNI plugin binary and config onto the host.
- agent/cmd/proxy-ready — Exec readiness probe for the aether-proxy pod (#673). One flag (--ready-marker), stdlib-only; //agent/cmd/proxy-ready:deps_test fails the build if it grows a dependency. Ships in the proxy-supervisor image (since #772), staged by the install-supervisor initContainer.
- agent/cmd/agent-ready — Exec liveness/readiness probe for the aether-agent pod (proposal 041). Asks the agent over its pod-local --health-socket, so a surge-rolled standby is never answered for by the other agent on the node. Stdlib-only, no net/http; //agent/cmd/agent-ready:deps_test asserts the binary lists no module. Ships as /agent-ready in the agent image.
- agent/cmd/identity-ready — The egress identity gate (#1053): the init container the controller's /mutate webhook injects first into every mesh pod (#1055); it waits on the pod's own SPIRE Workload API until the pod's SVID exists. Ships in the agent image; //agent/cmd/identity-ready:deps_test guards its link set.
- agent/cmd/mesh-dns-ready — Same for the aether-mesh-dns pod (#683); bundled in the mesh-dns image, guarded by //agent/cmd/mesh-dns-ready:deps_test.
- prober/cmd/prober — Synthetic mesh-availability prober (proposal 013). Own chart (charts/prober) + own image; mesh-managed per-node DaemonSet that probes the data plane from the client side and emits aether_probe_requests_total.
Notable packages (beyond the ones named above):
- common/spire — the shared identity wait/readiness model (#740): WaitingSource (never fatal on a missing SPIRE), ReadyChecker, NotReadyDwell (2m, node agent only) / ServiceNotReadyDwell (0). spiretest also serves the fake Workload API and the fake SPIFFE Broker Endpoint the identity tests run against.
- agent/internal/spire — the SPIFFE Broker API client + the SDS bridge (proposal 036, replaced SPIRE's Delegated Identity API): per-pod SubscribeToX509SVID streams keyed by a KubernetesObjectReference (ns/name and UID) over mTLS on --spire-broker-socket; validation contexts from the agent's own Workload API bundle plus the pods' federated bundles. Needs SPIRE >= 1.15.2 with its experimental broker enabled.
- agent/internal/identity — late-bound trust domain, folded in when the first SVID arrives.
- agent/internal/node + controller/internal/nodetaint — proposal 033 taint lifecycle: the agent removes aether.io/agent-not-ready, the controller's leader-elected guard re-arms it.
- agent/internal/cniconflist — re-asserts aether's chained entry in the node's CNI conflist (#645).
- registrar/internal/replicator — leader-elected cross-region etcd mirroring under an origin-heartbeat lease (proposal 006).
- agent/internal/xds/cache/quicpairs.go, agent/internal/xds/quicdemand, agent/internal/xds/server (odcds.go) — east-west QUIC twins: observed (SA, destination) pairs and their persisted demand_confirmed (#1076), the per-stream ODCDS subscription ledger (#1036/#1052), and on-demand twin admission (#1033).
- agent/internal/xds/ack — per-resource delta-xDS ACK/NACK tracking (no admin polling).
- agent/internal/capture — projects mesh-Service authorities into the capture route table (cap_http).
- agent/internal/proxy/hotrestart + agent/internal/supervisorcmd — the Envoy hot-restart supervisor and its command (#772; child-silent signal #1058).
- agent/internal/gatewaystatus — Gateway API status writers scoped to aether's controllerName.
- cni/internal/plugin — the chained CNI plugin, including the TPROXY capture divert (proposal 038) and the mesh-DNS :53 DNAT.
Ports: 18001 per-pod TCP capture (TPROXY target), 18008 inbound TCP + UDP/QUIC, 18009 east/west waypoint, 18021 edge readiness, 18054 host mesh-DNS, 18081 the HTTP proxy port (the per-pod outbound HTTP listener; a port of no service), 18082 L4 TCP + UDP (common/constants/mesh, agent/internal/xds/proxy).
Carried Envoy patches: proxy/bazel/patches/ (applied by proxy/MODULE.bazel; each one listed in proxy/README.md).
Key patterns:
- gRPC servers use Unix domain sockets for node-local communication.
- Agent uses controller-runtime Manager to orchestrate runnables (xDS, CNI, SPIRE, registry).
- Envoy config via snapshot cache (go-control-plane/pkg/cache/v3) with versioned snapshots.
- ServerCallback interface with PreListen allows setup before accepting connections.
Workflow¶
After adding/modifying Go files:
make gazelle # Regenerate BUILD.bazel
make tidy # bazel mod tidy — sync MODULE.bazel's use_repo with go.mod
make deps-audit # go.mod/go.sum hygiene; also a required CI job
Dependencies: add with bazel run @rules_go//go -- get <module>@<version>,
remove with go mod edit -droprequire=<module> (never go get <module>@none:
@none downgrades everything that requires the module). Then make tidy +
make gazelle. An unimported require that only pins a CVE-clean version is
reclassified // indirect, never deleted. See docs/runbook.md § Go dependency
hygiene.
Integration tests: Use testcontainers-go to run etcd in Docker. Run ./bazel/configure_colima.sh once on macOS with Colima to configure Docker socket access.
Test tags:
- size = "medium" and tags = ["integration"] for integration tests.
- Use --test_arg=-test.short to skip integration tests.
Proto & Codegen¶
- Proto files in
api/underaether/cni/v1/,aether/registry/v1/,aether/registrar/v1/,aether/config/v1/(MeshConfig,HTTPFilter,EdgeConfig,EndpointPolicy),aether/kubelet/pluginregistration/v1/(a copy of the kubelet's plugin-registration API; its proto package stayspluginregistration, a wire contract), andaether/agent/v1/(ObservedUpstreams, the agent's persisted demand set: node-local state, not a wire API). - Run
make gazelleafter proto or import changes.
Constraints¶
- Never modify production code when asked to add or fix tests only.
- Never remove existing test cases unless explicitly asked.
- Never run
go mod tidy(orgo mod tidy -e): the generated proto packagesaethermesh.dev/api/aether/*/v1exist only as Bazel outputs, so it cannot resolve them, and-estrips modules that only generated code or a BUILD file needs. Usemake tidy+make deps-audit. - Formatting uses
gofumpt,buildifier,shfmt,buf. - Lint violations fail with
--config=lint(aspect-based). - SPIRE integration is enabled by default on the agent and registrar
(
--spire-enabled=true); use--spire-enabled=falseto disable. The controller is the exception: its--spire-enableddefaults tofalse(it serves the webhook with the Helm self-signed cert unless SPIRE is turned on).
Rules for agents¶
One place for the rules every brief used to repeat. They apply to people too;
an agent has no other way to learn them. The role agents in .claude/agents/
assume this section has been read.
Build, lint, test
- Lint exactly as CI does: bazel build --config=lint --@aspect_rules_lint//lint:fail_on_violation //....
Never run --config=ci locally: it implies --config=remote and needs
the BuildBuddy key CI passes on the command line.
- make format-check before pushing (it also runs buildifier's linter on every
BUILD and .bzl file). make actionlint when a workflow or a composite
action changed. scripts/check-shell-lint.sh when a script was added.
make check-image-digests when an image rule, a binary's x_defs, the
workspace status or a build flag in .bazelrc changed: nothing in a Go image
of this workspace may depend on the commit (#1378; the separately built proxy
image is the exception) or on the name of Bazel's output directory (#1500).
- .bazelrc sets --experimental_output_paths=strip, so Go compiles are
path-mapped and run only sandboxed or remotely: do not pass
--spawn_strategy=local or standalone.
- The race detector goes on the go_test targets you touched
(bazel test --config=race //pkg:pkg_test), never on a wildcard that includes
image targets: that fails in analysis (cgo is off for images).
- Do not build the proxy/ workspace locally unless the task is about it: it is
Envoy. Its checks run in the proxy workflow.
- A plain go build ./... needs the generated proto sources first:
make materialize-go, then make go-build-plain, then make materialize-go-clean.
Secrets and releases
- Never print, cat or paste user.bazelrc, proxy/user.bazelrc or
~/.bazelrc: they hold the BuildBuddy key. Never handle registry credentials.
- Never push an image or a chart by hand. publish.yaml and proxy-release.yml
are the only publishers; leave both alone unless the task names them.
- Never dismiss a code-scanning alert, and never work around one by reshaping
code so the scanner stops seeing it. Report it. One known false positive:
any edit to .github/workflows/publish-verify.yaml can re-raise
actions/untrusted-checkout, and code scanning is a required check, so that
pull request waits for the owner. Prefer changing the scripts that workflow
calls over changing its YAML.
Changes that carry an obligation
- Anything under charts/ needs a bump of that chart's Chart.yaml (CI
enforces it). Read the version on origin/main first: two open chart pull
requests collide, and the second to merge takes the next patch number.
- A chart test goes in the chart's BUILD.bazel; write it first and see it
fail against the unchanged chart. "Writing a template test" in
charts/README.md says which rule to use. In charts/aether, rules_helm's
helm_template_test must pass --set controller.webhook.spire=true (a guard
test enforces it, so no log holds the generated webhook key); any other
assertion that something IS rendered uses the masked
helm_template_match_test, whose document_patterns also reaches every
document of a multi-document template. An off switch is still
helm_template_absent_test and a rejected value helm_template_fail_test.
- Removing a CPU limit from a Go container means deciding GOMAXPROCS in the
same change (without a limit the runtime sizes to the node's cores); see the
agent, mesh-dns and uds-csi values for the pattern.
- New Go code needs tests: the coverage workflow fails a pull request whose
total line coverage drops by more than 1.0 point against main.
- Protos are edition 2023 (never proto3) and change additively; an agent and a
registrar one chart minor apart (the compatibility window, below) must keep
working.
- Some names are read by a harness outside this repository: the agent's pin
metrics and their reason values, the prober's counter and its
AETHER_PROBE_FAIL line, the fields of the proxy's HTTP access log that the
contract lists (and the format operator of each),
the mesh annotations and ports, the chart's workload, container and label
names and its roll strategies. They are listed in test/harnesscontract/external-harness.yaml,
and bazel test //test/harnesscontract:checks fails when the code and that
file disagree. Renaming, removing or adding to a closed set of any of them:
change the file in the same pull request, bump its version when
test/harnesscontract/README.md says so (a test says so too: it compares
the file with external-harness.lock.yaml and prints what that file needs),
and say in the description that the contract changed.
Compatibility window
- "One release" means one minor version of the aether chart, wherever the
repository says it. Something deprecated in a 2.5.x chart stays in every
2.5.x and may be removed in 2.6.0: never within the minor that deprecated it,
and never without having been deprecated in a published chart first. This
covers flags, chart values, metrics and their labels, wire and file formats,
and any tolerance one component keeps for an older peer.
- The one exception is a major version of the aether chart, and only by
the owner's decision: it may remove or change such a thing without a
deprecation in a published chart (chart 3.0.0 did, proposal 043). Its
runbook section is then the announcement: every removal headed BREAKING,
with what breaks, how to find what depends on it on the previous chart, and
what the mixed cluster does during the roll, measured, or a statement of
exactly which mix was not measured. The adjacent-minor skew below is not
promised across a major version.
- Skew to design and test for: components of two adjacent chart minors in one
cluster, in both directions (a roll is not atomic, and a rollback is a roll).
A flag the chart passes must be accepted, or ignored harmlessly, by the
images of the previous minor, and the reverse.
- A deprecation is announced in two places, in the pull request that makes it:
the runbook's "Chart X.Y.Z" section of the chart that ships it (what is
deprecated, what replaces it, the chart minor that removes it), and where the
thing is defined (the flag's help text, the metric's description, the comment
on the value, the template or the code). Write the removing minor as a
version ("removed in chart 2.6.0"), not as "for one release" or "in the next
release": those cannot be dated by whoever reads them later.
- The removal goes in the runbook section of the chart that makes it, with what
an operator who skipped the window sees. charts/README.md, "Compatibility
window", is the operator-facing statement of the same rule; change both
together.
Workflows
- Every action pinned by full commit SHA with the version in a trailing
comment; timeout-minutes on every job; permissions per job, least
privilege; no ${{ }} inside a run: script except through env:.
- Kind, Helm, Bazel, conformance and format steps go through the composite
actions in .github/actions/; the pin tests (//e2e:kind_pin_test,
//e2e:go_pin_test, //e2e:helm_pin_test, //e2e:gateway_api_pin_test)
fail a workflow that bypasses them.
- The API cannot update a branch that touches .github/workflows without the
workflow token scope: rebase over git and push --force-with-lease.
Pull requests
- One concern per pull request. Never merge your own unless told to; never
--admin; squash only. Required on main: ci, proxy, codeql, and the
code-scanning app's CodeQL.
- Pull requests that would each rewrite the same lock or manifest file
(MODULE.bazel.lock, go.sum, a Chart.yaml, one workflow file) are a
stack: use the gh stack extension, with branches named upgrade/<slug>
(the only pattern whose stacked pull requests get the gates). A stack merges
with gh stack merge, never gh pr merge, and needs gh stack sync first
when main has moved.
- main requires every review conversation to be resolved, and Copilot
reviews every pull request. Read each thread, fix it or answer with the
reason, then resolve it. Two rounds at most: after that, hand back and name
the threads still open.
- A check that failed with no step run (no runner, a registry 502) is
infrastructure: say so and re-run it; do not "fix" the pull request.
- Agent-authored commits end with the session's attribution trailers, and the
pull request body says an agent wrote it.
Tests and findings
- A test-only task never changes production code and never removes a test case.
- The one exception to "never remove a test case unless asked": an agent MAY
remove a production function that only tests reference, together with the
test that only calls it, when the pull request shows by whole-module analysis
(deadcode ./... against deadcode -test ./..., or a go/packages reference
count, over the module after make materialize-go; not grep alone) that
nothing else references it. The description names the tool, the command and
its output for each function. A test that also asserts behaviour production
still has is moved to the function production calls, not removed.
A flaky-test fix is proven twice: a stress run with zero failures, and a
temporary mutation showing the test can still fail.
- One issue per finding, with the evidence in it; never an umbrella issue. Do
not comment on envoyproxy/envoy.
- An issue gets its labels when it is filed: a kind (bug, enhancement,
documentation), an area (chart, proxy, controller, observability, soak,
ci),
and minor, blocked or parked where they apply.
- Separate what you measured from what you inferred, and say what you could
not verify.
Clusters
- Experiments run on kind, with the pinned version (e2e/kind-version.sh
refuses an older binary); put the kube context back where you found it
afterwards.
- A shared cluster is read-only unless the owner authorised that specific
write. A helm upgrade, a rollout or a delete is never implied by a task.
- The platform's GitOps repository takes pull requests; its owner merges them.
Git Workflow¶
- Never commit directly to
main. Always create a feature branch (feat/,fix/,deps/, etc.) and open a PR. - Never push to
maindirectly. Usegit push -u origin <branch>for feature branches only. - After merging a PR, delete the feature branch locally (
git branch -d <branch>) and remotely (git fetch --prune originorgit push origin --delete <branch>). - When updating an existing PR branch, use
git commit --amendandgit push --force-with-lease(never force push without--force-with-lease).