Skip to content

ci: run the IPv6-only e2e job with the DNS fixes and their tests [DO NOT MERGE — signal only] - #1084

Draft
Yuan Gao (ygao-g) wants to merge 22 commits into
agent-substrate:mainfrom
ygao-g:ipv6-ci-integration-dns
Draft

ci: run the IPv6-only e2e job with the DNS fixes and their tests [DO NOT MERGE — signal only]#1084
Yuan Gao (ygao-g) wants to merge 22 commits into
agent-substrate:mainfrom
ygao-g:ipv6-ci-integration-dns

Conversation

@ygao-g

@ygao-g Yuan Gao (ygao-g) commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Part of #246. Signal only; it will never be merged.

#1065 shows the IPv6-only job green, but green there proves less than it looks. Both suites reach actors by port-forwarding atenet-router and passing the actor name as a Host header, so nothing ever queries the actor DNS zone and no test picks an address family — the job is green whether the zone is correct or not.

This branch is #1065's fourteen commits unchanged, plus the four PRs that close that blind spot: #874 and #938 fix the zone, and #1082 and #1083 assert it. The run shows whether the DNS and ingress paths actually work on IPv6-only, rather than only that nothing crashed.

See #1065 for the provenance of its fourteen. On top of them, verified verbatim by git patch-id:

commit subject source
4e59e8a5 atenet/dns: answer non-A actor queries instead of SERVFAIL #874
d01e016f internal/ipfamily: add ClusterIPsByFamily #938
3646ec27 atenet/dns: hoist the Corefile generation stamp to a package var #938
c729992c atenet/dns: publish the router's IPv6 ClusterIP as an AAAA #938
93768def e2e: add an in-cluster DNS client and router IP family helpers #1082
a7ac9422 e2e: assert the actor DNS zone answers benign rcodes #1082
a449a067 e2e: assert the router ingress works on every IP family #1083
f27a8170 ateomnet: give the actor IPv6 only when the worker pod has it #1057

The last row is the fifteenth commit #1065 gained after its run. It does not touch the IPv6-only job; without it the IPv4 e2e-test is red on the two egress tests, because the actor is given an IPv6 address on a cluster with no route for it.

make verify passes on the branch. TestActorIngressPerFamily is expected to skip on IPv6-only — it compares two families and the cluster allocates one.

🤖 Generated with Claude Code

Yuan Gao (ygao-g) and others added 22 commits August 19, 2026 16:49
On a fresh IP_FAMILY=ipv6 cluster nothing resolves from inside a pod and
no actor boots: CoreDNS inherits the node's IPv4 resolver, which a
v6-only pod cannot reach, and "kind-registry" NXDOMAINs in atelet's own
netns. Point the forward at an IPv6 upstream, overridable with
IPV6_DNS_UPSTREAM, and give the registry its own server block, so it is
asked for nothing but its own name. IPv4 and dual-stack clusters are
unchanged, and atenet-egress still crashloops on v6-only for an
unrelated Envoy bind bug.

Asking once was not enough to prove that: about half of fresh clusters
do not answer the first query, and a pod that goes unanswered stays
unanswered, so the check re-asks with a new pod and prints what the pod
saw when it gives up. It lives in hack/verify-ipv6-dns.sh rather than
inline, because the registry block records an address the registry can
move off and there was no way to re-check a cluster without rebuilding
it.
The HTTP and HTTPS ingress listeners bound 0.0.0.0 only, so on a
dual-stack cluster Envoy answered on the router Service's IPv4
ClusterIP and on nothing at all for IPv6. Each primary socket now
carries an additional "::" address on the same port.

Ipv4Compat stays false on the additional address: clearing IPV6_V6ONLY
would collide with the primary already bound to that port. Leaving the
primary alone is what keeps an IPv4-only cluster unchanged -- with the
caveat that a node lacking AF_INET6 entirely could not bind "::" and
the listener would not come up. First of three commits binding atenet's
gateways dual-stack.

(cherry picked from commit 501991d)
The Envoy admin socket bound 0.0.0.0, and the atenet-router Service
carried no ipFamilyPolicy -- which the API server defaults to
SingleStack, one IPv4 ClusterIP and nothing else. Between them the
router had no IPv6 address to answer on. The socket now binds "::" with
ipv4_compat, one socket for both families, and the Service asks for
PreferDualStack.

bootstrap.v3.Admin takes a single address and has no
additional_addresses, so the ingress listeners' shape is not available
here; ipv4_compat is what makes the one socket serve both families.
It is load-bearing: dataplane.go health-checks the admin listener over
http://127.0.0.1:9901/ready, so a bare "::" would report the dataplane
component of /statusz unhealthy. Prefer, not Require, keeps the Service
valid on a single-stack cluster; spec.ipFamilies is left alone because
the primary family is immutable and the API server appends the
secondary itself.

(cherry picked from commit 2a21292)
The gateway's admin and :443 sockets bound 0.0.0.0, so on an
IPv6-primary cluster the kubelet probed the pod on its only address
and atenet-egress crashlooped -- Envoy started fine and logged "admin
address: 0.0.0.0:15000" -- while an actor's CONNECT had no v6 path in.
Both sockets now bind "::" with ipv4_compat, and the Service asks for
PreferDualStack so a dual-stack cluster hands out an IPv6 ClusterIP to
reach them on.

One socket here rather than the ingress listeners' pair: IPv4 peers
then arrive as ::ffff: addresses, and nothing on this path reads the
peer -- actor identity comes from the client certificate and the access
log records the cert SAN. ipv4_compat also has to stay on the admin
socket, because the ext-proc sidecar's drainer dials 127.0.0.1:15000
and envoydrain.go reads a refusal there as "Envoy already exited",
skipping the drain silently. Last of three.

(cherry picked from commit 2549657)
Both gateway admin sockets bind "::" with ipv4_compat, and the flag is
what keeps their in-pod callers working: dataplane.go health-checks the
router's over IPv4 loopback, and envoydrain.go dials the egress one the
same way and reads a refusal as "Envoy already exited", skipping the
drain without reporting an error. No Go test, golden file, or verify
script read either manifest, so dropping the flag would have failed
silently.

make verify now rejects an admin socket that binds "::" without it.

(cherry picked from commit 4b478a0)
The CONNECT-terminating listeners landed after the first commit of this
series, so they kept a bare 0.0.0.0 socket while ingress HTTP and HTTPS
gained their "::" pair. Give them the same additional address, so all
four of the router's socket listeners answer on both families.

Both are port-gated and no e2e suite configures them yet, which is why
nothing caught this; the internal main_internal listener has no socket
and needs nothing.

(cherry picked from commit 54c727d)
TCPOriginalDestination read only the IPv4 SOL_IP/SO_ORIGINAL_DST, so an
actor's IPv6 connection redirected into the transparent egress listener
had no destination to dial and the proxy failed it. Read
IP6T_SO_ORIGINAL_DST too, falling back to it only when the IPv4 lookup
returns ENOENT, so unrelated IPv4 failures keep their own error.

One step towards dual-stack actor networking; the actor veth and its
nftables rules are still IPv4-only.

Co-authored-by: Yuan Gao <ypgao@google.com>
(cherry picked from commit d8527b5)
Both ateom herders defaulted the actor ingress flags to "0.0.0.0:443"
and "0.0.0.0:444", which reads as IPv4-only. It never was: Go treats an
unspecified address as a wildcard and binds it dual-stack, so the
sockets already served both families. Spell the defaults ":443" and
":444" so the flag says what it does, and note why in a comment.

Part of the dual-stack actor networking series; no behavior change.

(cherry picked from commit 51b2cbe)
The actor veth and pod eth0 sit in the same netns, and only
net.ipv4.ip_forward was enabled there, so ip6_forward() dropped every
actor IPv6 packet, DNS queries included, on a dual-stack or IPv6-only
cluster. Write net.ipv6.conf.all.forwarding as well, treating a missing
path as nothing to enable so a netns with IPv6 compiled out still comes
up.

Part of agent-substrate#945; the actor veth itself is still IPv4-only, so nothing
generates that traffic yet.

Co-authored-by: Yuan Gao <ypgao@google.com>
(cherry picked from commit 29fe4bc)
… rules

Add an fd00:169:254::/126 point-to-point pair alongside the existing IPv4
addresses, with a matching ::/0 default route, and move the actor nftables
table from ip to inet so a single table carries both families. Each payload
match now guards on NFPROTO to stay off the other family's packets, and
teardown lists the inet family too: naming the wrong family there dumps
empty, takes the "already clean" path, and silently leaks the table.

Assign the IPv6 addresses with IFA_F_NODAD instead of writing the accept_dad
sysctl. The ateom container is unprivileged, so containerd mounts /proc/sys
read-only and the write failed with EROFS, taking SetupActorNetwork and every
actor start down with it on both sandbox classes. A root-gated assertion pins
the flag; the existing tests run as real root, where the sysctl is writable
and the bug is invisible.

(cherry picked from commit 1d36862)
…bled

An IPv4-only cluster leaves net.ipv6.conf.all.disable_ipv6=1 in the worker
pod netns, which is the default on IPv4-only GKE, and netlink there rejects
the veth's IPv6 address with EPERM. The assignment sits on the path of every
SetupActorNetwork call site, so actor startup went from working to failing
outright and the actor never left ResumeGoldenActor. Gate the IPv6 address
and default route on a per-link disable_ipv6 read, leaving the interior
IPv4-only on those clusters instead of failing.

A root-gated test covers a netns with IPv6 disabled; reverting the gate
reproduces the EPERM against it.

(cherry picked from commit 42e7c46)
The egress Envoy pinned dns_lookup_family to V4_ONLY, so it asked only
for A records. On an IPv6-only cluster no upstream name resolves and no
actor can reach the internet. AUTO tries AAAA and falls back to A, so
IPv4-only clusters behave as before.

One step of the IPv6 egress work, and not the one that unblocks it --
actor egress still stops earlier, in atunnel's original-destination
lookup.

(cherry picked from commit de81578)
Runs the full install plus the demo and networking e2e suites against a
single-stack IPv6-only kind cluster, and asserts the cluster really is
v6-only so a green run cannot quietly become a second IPv4 run. It stays
out of the e2e-test merge gate, so it reports IPv6 status without being
able to block a PR, and it runs on every PR for now so the results are
visible; the TODO on the trigger records the intended ci/ipv6 label gate.

ubuntu-latest has no IPv6 egress, so the job stands up tayga for NAT64
and points CoreDNS at an upstream resolver through the well-known
prefix. DNS64 is scoped to a catch-all server block: synthesizing AAAA
over the cluster zones destroys the v6-only ClusterIP answers and the
control plane never comes up.

(cherry picked from commit 748e841)
The probe attached to the pod to collect its markers, and an attach can
end before the last write arrives. A CI run lost the registry marker
that way, so the check reported a registry it could not reach -- and
then refused to re-probe, because only the resolve leg was treated as a
settling race.

Wait for the pod to terminate and read its log instead, and close the
probe with a PROBE_DONE marker so a short read is re-probed rather than
read as a failed fetch. A registry that really is down still fails on
the first attempt.
Before, the actor zone answered A queries and failed everything else --
AAAA for a valid actor, and any name in the zone that is not an actor.
A failure reads as a temporary error rather than an answer, so clients
retry it and then give up on the name; Alpine actors could not resolve
each other at all, even on an IPv4-only cluster. After, those queries
return a correct empty answer, and one that resolvers can cache.

A unit test pins the whole rendered zone as a literal, so editing the
name pattern or the suffix fails there rather than passing silently.
Splits a Service's cluster IPs into its IPv4 and IPv6 entries, returning
"" for a family the Service has no address in. No behavior change on its
own -- nothing calls it until the AAAA change later in this series. It
is shared rather than package-local because a Service with no
ipFamilyPolicy is SingleStack, so one empty family is the steady state
on every cluster, not an error, and each caller would otherwise have to
decide that for itself.

Unit tests cover single- and dual-stack Services and the unallocated and
malformed cases.
No behavior change -- buildTemplate() already ran once, from init(). The
next commit renders the Corefile on every call instead, where a stamp
taken inline would differ each time: reconcile compares the render
against the file on disk, so it would rewrite and reload CoreDNS every
tick.
Before, an actor name never resolved over IPv6: the zone published the
router's primary cluster IP, always as an A record whatever family it
was. On a dual-stack cluster the v6 address went unpublished; on an
IPv6-only cluster the record was malformed, so every A query for an
actor name failed. After, the zone publishes an address record per
family the router has an address in, and answers empty for a family it
has none in.

Unit tests pin the rendered zone for each family combination, verified
against the pinned coredns/coredns:1.11.1.
Nothing in the e2e harness could query the actor DNS zone. Suites reach
actors by port-forwarding atenet-router and passing the actor name as a
Host header, so the zone CoreDNS actually serves went unasserted, and a
suite that wanted to check it had no way to distinguish an empty answer
from a server failure. Adds a DNS client that port-forwards the atenet
DNS Service and reports the rcode class alongside the addresses, plus a
helper for the router's ClusterIP in each family.

First of two commits; the tests that use these follow. clusterIPsByFamily
here is a stopgap that agent-substrate#938 replaces with internal/ipfamily.
The zone answered A queries and failed everything else -- AAAA for a
valid actor, and any name in the zone that is not an actor -- and no
test caught it, because Go's resolver masks a SERVFAIL that musl treats
as fatal. These assert the rcode class rather than the record: a non-A
qtype and a name that misses the actor regex must come back NODATA or
NXDOMAIN, and an A query must carry the router's ClusterIP. A separate
test covers the AAAA record, skipped where the router has no v6 address.

Second of two commits. The assertions are red until agent-substrate#874 and agent-substrate#938 land,
so this stays a draft until then. Part of agent-substrate#246.
Nothing checked that the router's dataplane listeners bind more than an
IPv4 socket, and nothing reached an actor over the router's IPv6
ClusterIP. Every other path a test has into the router -- a
port-forward, the pods/proxy and services/proxy subresources -- is
mediated by the API server, which picks the family, so no existing test
could have caught a listener that lost its IPv6 socket. Reads the bound
addresses from Envoy's own admin /listeners, and drives an in-cluster
probe pod at the router over each ClusterIP in turn.

Red until agent-substrate#911 binds those sockets, so this stays a draft until then.
The per-family probe skips on a single-stack cluster. Part of agent-substrate#246.
LinkIPv6Enabled answers whether the kernel will accept an IPv6 address on
a link, not whether the cluster routes IPv6. IPv4-only kind leaves
disable_ipv6 at 0, so the actor got fd00:169:254::2 and a ::/0 route it
could not use, Go's destination sorting preferred the AAAA of any
dual-stack host, and the egress fetch died mid-response -- the IPv4 e2e
job has been red since. Pair the capability read with a check that the
worker pod's own eth0 carries a global IPv6 address, and the actor stays
IPv4-only wherever the pod is.

Decide it once in the pod netns and pass it into ConfigureActorVeth. The
interior namespace is created fresh, so its own sysctl always said IPv6
was available whatever the pod's families were. Root-gated tests cover a
pod without IPv6 and a pod whose new links have IPv6 disabled per link;
dropping either half of the check reproduces its failure.

(cherry picked from commit 298b6d3)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants