Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions .github/workflows/e2e-ipv6.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ To quickly set up the complete environment:

2. Run the following steps:
```shell
# create cluster and local registry
# create cluster and local registry (IPv4; IP_FAMILY=dual|ipv6 overrides)
hack/create-kind-cluster.sh

# install ate, valkey, rustfs
Expand Down
28 changes: 28 additions & 0 deletions cmd/atenet/internal/router/xds.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,32 @@ func (x *XdsServer) buildTracing() *hcmv3.HttpConnectionManager_Tracing {
}
}

// dualStackAdditionalAddresses returns the IPv6 half of a dual-stack ingress
// listener, to pair with a primary 0.0.0.0 socket on the same port.
//
// Ipv4Compat must stay false here: it would clear IPV6_V6ONLY and collide with
// the primary IPv4 wildcard on the same port, and Envoy rejects the whole
// listener when an additional address fails to bind. The admin socket in
// manifests/ate-install/atenet-router.yaml is a single socket, so it sets the
// opposite.
func dualStackAdditionalAddresses(port uint32) []*listenerv3.AdditionalAddress {
return []*listenerv3.AdditionalAddress{
{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: "::",
Ipv4Compat: false,
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: port,
},
},
},
},
},
}
}

func (x *XdsServer) buildListener() *listenerv3.Listener {
hcm := x.buildHcm("ingress_http")

Expand All @@ -812,6 +838,7 @@ func (x *XdsServer) buildListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.ingressPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down Expand Up @@ -859,6 +886,7 @@ func (x *XdsServer) buildHttpsListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.httpsPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down
35 changes: 35 additions & 0 deletions cmd/atenet/internal/router/xds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ func TestXdsServer_UpdateSnapshot(t *testing.T) {
if sa.GetAddress() != "0.0.0.0" {
t.Errorf("Expected address '0.0.0.0', got %s", sa.GetAddress())
}

addrs := l.GetAdditionalAddresses()
if len(addrs) == 0 {
t.Fatalf("Expected an additional address on %s, got none", IngressHTTPListener)
}

asa := addrs[0].GetAddress().GetSocketAddress()
if asa.GetAddress() != "::" {
t.Errorf("Expected additional address '::', got %s", asa.GetAddress())
}
if asa.GetIpv4Compat() {
t.Errorf("Expected additional address Ipv4Compat to be false")
}
if asa.GetPortValue() != 8081 {
t.Errorf("Expected additional port 8081, got %d", asa.GetPortValue())
}
}
}

Expand Down Expand Up @@ -195,6 +211,25 @@ func TestXdsServer_UpdateSnapshot_WithHttps(t *testing.T) {
if sa.GetPortValue() != 8443 {
t.Errorf("Expected port 8443, got %d", sa.GetPortValue())
}
if sa.GetAddress() != "0.0.0.0" {
t.Errorf("Expected address '0.0.0.0', got %s", sa.GetAddress())
}

addrs := l.GetAdditionalAddresses()
if len(addrs) == 0 {
t.Fatalf("Expected an additional address on %s, got none", IngressHTTPSListener)
}

asa := addrs[0].GetAddress().GetSocketAddress()
if asa.GetAddress() != "::" {
t.Errorf("Expected additional address '::', got %s", asa.GetAddress())
}
if asa.GetIpv4Compat() {
t.Errorf("Expected additional address Ipv4Compat to be false")
}
if asa.GetPortValue() != 8443 {
t.Errorf("Expected additional port 8443, got %d", asa.GetPortValue())
}

// Verify the TLS config references the serving cert via SDS rather
// than embedding it: inline filename DataSources are read only once
Expand Down
151 changes: 146 additions & 5 deletions hack/create-kind-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,38 @@ set -o errexit -o nounset -o pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}"
KUBECTL_CONTEXT="kind-${KIND_CLUSTER_NAME}"
reg_name="kind-registry"
reg_port="5001"
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM:-2001:4860:4860::8888 2001:4860:4860::8844}"

if [[ $# -gt 0 ]]; then
case "$1" in
-h|--help)
echo "Usage: $0"
echo "Creates the kind cluster '${KIND_CLUSTER_NAME}' and a local registry container on port ${reg_port}."
echo
echo "Configured through the environment:"
echo " KIND_CLUSTER_NAME Name of the cluster to create (default: kind)."
echo " IP_FAMILY Address families for pods and Services: ipv4, ipv6 or dual (default: ipv4)."
echo " IPV6_DNS_UPSTREAM Space-separated IPv6 resolvers CoreDNS forwards to when IP_FAMILY=ipv6"
echo " (default: Google Public DNS). Override where those are unreachable."
exit 0
;;
esac
fi

# Only ipFamily is set; kind's per-family podSubnet/serviceSubnet defaults are
# already what we want.
IP_FAMILY="${IP_FAMILY:-ipv4}"
case "${IP_FAMILY}" in
ipv4|ipv6|dual)
;;
*)
echo "error: IP_FAMILY must be one of ipv4, ipv6, dual (got '${IP_FAMILY}')" >&2
exit 1
;;
esac

mkdir -p "${ROOT}/bin"

Expand All @@ -33,6 +63,9 @@ if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true
fi

if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != "true" ]; then
# Published on both loopback families so `ko` reaches localhost:5001 whichever
# one its resolver picks. The node side is separate: it goes over the "kind"
# network in step 4.
docker run \
-d --restart=always \
--label created-by=agent-substrate \
Expand All @@ -57,7 +90,7 @@ else
echo "/dev/kvm not available: micro-VM support disabled (gVisor still works)."
fi

echo "Creating kind configuration for cluster '${KIND_CLUSTER_NAME}'..."
echo "Creating kind configuration for cluster '${KIND_CLUSTER_NAME}' (ipFamily=${IP_FAMILY})..."
cat <<EOF > "${ROOT}/bin/kind-config.yaml"
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
Expand All @@ -83,18 +116,65 @@ featureGates:
PodCertificateRequest: true
runtimeConfig:
"certificates.k8s.io/v1beta1": "true"
networking:
ipFamily: ${IP_FAMILY}
EOF

echo "Deleting existing kind cluster '${KIND_CLUSTER_NAME}' if it exists..."
"${ROOT}"/hack/kind.sh delete cluster --name "${KIND_CLUSTER_NAME}" || true

# kind reuses an existing "kind" network as-is, so one created by an older kind
# or while the daemon had IPv6 off (kind falls back to a v4-only network rather
# than failing) leaves the nodes with no v6 address — seen much later as pods
# stuck at ContainerCreating. Deleting the cluster does not drop the network
# either: the registry is still attached to it. Step 4 reconnects the registry.
if [[ "${IP_FAMILY}" != "ipv4" &&
"$(docker network inspect kind --format '{{.EnableIPv6}}' 2>/dev/null || echo absent)" == "false" ]]; then
echo "The 'kind' Docker network exists without IPv6; recreating it..."
docker network disconnect kind "${reg_name}" 2>/dev/null || true
if ! docker network rm kind >/dev/null; then
echo "error: could not remove the 'kind' Docker network. Something else is still" >&2
echo " attached to it; disconnect it and re-run:" >&2
echo " docker network inspect kind --format '{{json .Containers}}'" >&2
exit 1
fi
fi

echo "Creating kind cluster '${KIND_CLUSTER_NAME}'..."
"${ROOT}"/hack/kind.sh create cluster --name "${KIND_CLUSTER_NAME}" --config "${ROOT}/bin/kind-config.yaml"

# 2.5 Enable Proxy ARP on kind nodes for gVisor loopback pod-to-pod networking
echo "Enabling Proxy ARP on kind nodes..."
# A daemon with IPv6 off hands kind a v4-only network whatever it asked for.
if [[ "${IP_FAMILY}" != "ipv4" &&
"$(docker network inspect kind --format '{{.EnableIPv6}}')" != "true" ]]; then
echo "error: the 'kind' Docker network has no IPv6, so the nodes have no v6 address." >&2
echo " Enable IPv6 in the Docker daemon and re-run. On Linux, add to" >&2
echo " /etc/docker/daemon.json and restart dockerd:" >&2
echo ' {"ipv6": true, "ip6tables": true}' >&2
exit 1
fi

# For ipv6 kind writes a kubeconfig pointing at [::1], the address it published
# the apiserver on, which only works for a client on the Docker host itself: a
# VM-hosted daemon (Lima on macOS) forwards the port to the *v4* loopback, so
# every kubectl below fails at connect. localhost is a SAN on the apiserver
# cert and lets the client pick a family that works from either side.
if [[ "${IP_FAMILY}" == "ipv6" ]]; then
server="$(kubectl config view \
-o jsonpath="{.clusters[?(@.name==\"${KUBECTL_CONTEXT}\")].cluster.server}")"
if [[ "${server}" == "https://[::1]:"* ]]; then
echo "Repointing the kubeconfig for '${KUBECTL_CONTEXT}' at localhost..."
kubectl config set-cluster "${KUBECTL_CONTEXT}" \
--server="https://localhost:${server##*:}" >/dev/null
fi
fi

# 2.5 Enable Proxy ARP/NDP on kind nodes for gVisor loopback pod-to-pod networking
echo "Enabling Proxy ARP/NDP on kind nodes..."
for node in $("${ROOT}"/hack/kind.sh get nodes --name "${KIND_CLUSTER_NAME}"); do
# Unconditional: harmless on a v6-only cluster, where the nodes still carry
# IPv4 on the Docker bridge, and proxy_ndp just supports IPv6 if configured.
docker exec "${node}" sysctl net.ipv4.conf.all.proxy_arp=1
docker exec "${node}" sysctl net.ipv6.conf.all.proxy_ndp=1
done

# 2.6 When KVM is available: make /dev/kvm usable inside the node and label
Expand All @@ -103,7 +183,7 @@ if [ "${HAS_KVM}" = "1" ]; then
echo "Preparing kind nodes for micro-VM (kata + cloud-hypervisor) runtime..."
for node in $("${ROOT}"/hack/kind.sh get nodes --name "${KIND_CLUSTER_NAME}"); do
docker exec "${node}" chmod 666 /dev/kvm
kubectl label node "${node}" ate.dev/sandboxClass=microvm --overwrite
kubectl --context="${KUBECTL_CONTEXT}" label node "${node}" ate.dev/sandboxClass=microvm --overwrite
done
fi

Expand All @@ -123,9 +203,70 @@ if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}
docker network connect "kind" "${reg_name}"
fi

# 4.5. Give CoreDNS an IPv6 forwarder and a registry entry (ipv6 only)
#
# CoreDNS runs dnsPolicy: Default, so it inherits the node's Docker-generated
# /etc/resolv.conf, which always names an IPv4 resolver. Pods here have no IPv4
# address, so without this every external lookup SERVFAILs and anything that
# fetches at runtime -- atelet pulling the gVisor tarball, for one -- never
# starts. Step 3 wired the registry into containerd on the *node*, which does
# not help a pod: atelet pulls actor images from its own netns, where
# "kind-registry" NXDOMAINs. Two Corefile clauses fix both.
if [[ "${IP_FAMILY}" == "ipv6" ]]; then
echo "Repointing CoreDNS at an IPv6 resolver and teaching it '${reg_name}'..."
reg_v6="$(docker inspect "${reg_name}" \
--format '{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')"
if [[ -z "${reg_v6}" ]]; then
echo "error: '${reg_name}' has no IPv6 address on the 'kind' network" >&2
exit 1
fi

corefile="$(kubectl --context="${KUBECTL_CONTEXT}" -n kube-system get cm coredns \
-o jsonpath='{.data.Corefile}')"
# fallthrough is load-bearing: without it every name that is not the registry
# NXDOMAINs, trading one outage for a worse one. Both sides are left unquoted
# -- bash 3.2 would splice the quotes in literally.
search="forward . /etc/resolv.conf"
replace="hosts {
${reg_v6} ${reg_name}
fallthrough
}
forward . ${IPV6_DNS_UPSTREAM}"
patched="${corefile/$search/$replace}"
if [[ "${patched}" == "${corefile}" ]]; then
echo "error: '${search}' not found in the CoreDNS Corefile" >&2
echo " a silent no-op here is the whole failure mode; inspect it by hand" >&2
exit 1
fi

# A YAML patch file avoids escaping the Corefile's newlines into JSON.
{ printf 'data:\n Corefile: |\n'; printf '%s\n' "${patched}" | sed 's/^/ /'; } \
> "${ROOT}/bin/coredns-patch.yaml"
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system patch cm coredns \
--type=merge --patch-file "${ROOT}/bin/coredns-patch.yaml"
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system rollout restart deploy/coredns
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system rollout status deploy/coredns \
--timeout=120s

# Probe from a pod, never from the node: the node is dual-stack and resolves
# both names either way, so a node-side check proves nothing. The registry leg
# fetches rather than resolves, because the hosts entry above is AAAA-only and
# `nslookup kind-registry` fails on its A query even though every real client
# (getaddrinfo, and so containerd and atelet) is satisfied by the AAAA.
echo "Verifying DNS from a pod..."
if ! kubectl --context="${KUBECTL_CONTEXT}" run "coredns-probe-$$" \
--rm --attach --quiet --restart=Never --image=busybox:1.36 --command -- \
sh -c "nslookup storage.googleapis.com >/dev/null &&
wget -q -T10 -O/dev/null http://${reg_name}:5000/v2/"; then
echo "error: a pod cannot resolve an external name and reach '${reg_name}'" >&2
echo " IPV6_DNS_UPSTREAM is '${IPV6_DNS_UPSTREAM}'; set it to a reachable resolver" >&2
exit 1
fi
fi

# 5. Document the local registry in kube-public ConfigMap
echo "Documenting local registry in cluster..."
cat <<EOF | kubectl apply -f -
cat <<EOF | kubectl --context="${KUBECTL_CONTEXT}" apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
Expand Down
7 changes: 5 additions & 2 deletions manifests/ate-install/atenet-egress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,13 @@ data:
envoy.yaml: |
admin:
address:
socket_address: { address: 0.0.0.0, port_value: 15000 }
# ipv4_compat: the drainer dials this on IPv4 loopback (--envoy-admin-address, below).
socket_address: { address: "::", ipv4_compat: true, port_value: 15000 }
static_resources:
listeners:
- name: egress
address:
socket_address: { address: 0.0.0.0, port_value: 443 }
socket_address: { address: "::", ipv4_compat: true, port_value: 443 }
filter_chains:
# Named so ext_proc can read it back as xds.filter_chain_name. Must
# match EgressFilterChainName in
Expand Down Expand Up @@ -379,6 +380,8 @@ metadata:
namespace: ate-system
spec:
type: ClusterIP
# Prefer, not Require: Require fails Service creation on a single-stack cluster.
ipFamilyPolicy: PreferDualStack
selector:
app: atenet-egress
ports:
Expand Down
7 changes: 6 additions & 1 deletion manifests/ate-install/atenet-router.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ data:
admin:
address:
socket_address:
address: 0.0.0.0
# ipv4_compat clears IPV6_V6ONLY, so this one socket serves both
# families; dataplane.go probes /ready over the IPv4 loopback.
address: "::"
ipv4_compat: true
port_value: 9901

node:
Expand Down Expand Up @@ -339,6 +342,8 @@ metadata:
namespace: ate-system
spec:
type: ClusterIP
# Prefer, not Require: Require fails Service creation on a single-stack cluster.
ipFamilyPolicy: PreferDualStack
selector:
app: atenet-router
ports:
Expand Down
Loading