Skip to content
Open
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
72 changes: 67 additions & 5 deletions hack/create-kind-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}"
KUBECTL_CONTEXT="kind-${KIND_CLUSTER_NAME}"
reg_name="kind-registry"
reg_port="${KIND_REGISTRY_PORT:-5001}"
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM:-2001:4860:4860::8888 2001:4860:4860::8844}"

if [[ $# -gt 0 ]]; then
case "$1" in
Expand All @@ -31,6 +32,8 @@ if [[ $# -gt 0 ]]; then
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
Expand Down Expand Up @@ -146,14 +149,18 @@ if [[ "${IP_FAMILY}" != "ipv4" &&
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.
# the apiserver on. That works for a client on the Docker host itself but not
# behind a VM-hosted daemon (Lima on macOS), which forwards the port to the *v4*
# loopback. localhost is a SAN on the apiserver cert and covers that case -- but
# only where it resolves to ::1, and Debian-family hosts give ::1 the names
# ip6-localhost and ip6-loopback, not localhost. Repointing unconditionally
# therefore trades a working address for a broken one on a Linux host. Ask the
# apiserver instead of guessing from the platform.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. be more concise about the issue it tries to solve, and what the solution is

if [[ "${IP_FAMILY}" == "ipv6" ]]; then
server="$(kubectl config view \
-o jsonpath="{.clusters[?(@.name==\"${KUBECTL_CONTEXT}\")].cluster.server}")"
if [[ "${server}" == "https://[::1]:"* ]]; then
if [[ "${server}" == "https://[::1]:"* ]] &&
! kubectl --context="${KUBECTL_CONTEXT}" get --raw /healthz >/dev/null 2>&1; then
echo "Repointing the kubeconfig for '${KUBECTL_CONTEXT}' at localhost..."
kubectl config set-cluster "${KUBECTL_CONTEXT}" \
--server="https://localhost:${server##*:}" >/dev/null
Expand Down Expand Up @@ -196,6 +203,61 @@ 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
#
# CoreDNS runs dnsPolicy: Default, inheriting the node's IPv4 resolver, which

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. make comments more concise, briefly mention the issue and the fix. IIUC, what this logic did is to replace "forward . /etc/resolv.conf" with "forward . ${IPV6_DNS_UPSTREAM}", and add registry.

# no pod here can reach, so external lookups SERVFAIL. Step 3's registry
# wiring is node-side, so it misses atelet too: that pull runs in atelet's own
# netns, where "kind-registry" NXDOMAINs.
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}')"
search="forward . /etc/resolv.conf"
replace="forward . ${IPV6_DNS_UPSTREAM}"
# $search unquoted: bash 3.2 splices the quotes in literally. Replacing just

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. consider removing it

# the target leaves kind's trailing "{ max_concurrent 1000 }" in place.
patched="${corefile/$search/$replace}"
if [[ "${patched}" == "${corefile}" ]]; then
echo "error: '${search}' not found in the CoreDNS Corefile" >&2
echo " the Corefile layout changed upstream; update this block" >&2
exit 1
fi

# Its own server block, not a hosts entry in .:53. A query is served by the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. consider removing this

# one block whose zone is its longest suffix, so only "${reg_name}" arrives
# here -- which is why this hosts needs no fallthrough to avoid NXDOMAINing
# every other name.
patched="${patched}
${reg_name}:53 {
errors
hosts {
${reg_v6} ${reg_name}
}
}"

# 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

# Its own script so it can be re-run against a live cluster: the hosts entry
# above is a snapshot of an address the registry can move off (#1049).
KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" REG_NAME="${reg_name}" \
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM}" "${ROOT}"/hack/verify-ipv6-dns.sh
fi

# 5. Document the local registry in kube-public ConfigMap
echo "Documenting local registry in cluster..."
cat <<EOF | kubectl --context="${KUBECTL_CONTEXT}" apply -f -
Expand Down
128 changes: 128 additions & 0 deletions hack/verify-ipv6-dns.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. remove this file, we can rely on e2e tests to provide signals


# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Preconditions:
# IP_FAMILY=ipv6 hack/create-kind-cluster.sh
#
# Checks the two things the CoreDNS rewrite in create-kind-cluster.sh provides:
# a pod can resolve an external name (a GCS one -- that is where atelet pulls
# sandbox tarballs from) and reach the local registry by name. That script runs
# this last; re-run it by hand after the registry moves (#1049).

set -o errexit -o nounset -o pipefail

KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-${KIND_CLUSTER_NAME:-kind}}"
REG_NAME="${REG_NAME:-kind-registry}"
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 "Verifies pod DNS on the IPv6-only kind cluster '${KUBECTL_CONTEXT}'."
echo
echo "Configured through the environment:"
echo " KUBECTL_CONTEXT Context to check (default: kind-\${KIND_CLUSTER_NAME:-kind})."
echo " REG_NAME Name of the local registry container (default: kind-registry)."
echo " IPV6_DNS_UPSTREAM The resolvers CoreDNS was pointed at; reported on failure only."
exit 0
;;
*)
echo "error: unknown argument '$1'; see --help" >&2
exit 1
;;
esac
fi

kube() { kubectl --context="${KUBECTL_CONTEXT}" "$@"; }

echo "Verifying DNS from a pod..."
# Probe from a pod, not the node: the node is dual-stack and passes either way.
# The registry leg fetches rather than resolves -- the hosts entry is AAAA-only,
# which fails nslookup's A query but satisfies getaddrinfo. Legs share one
# stream and only the last exit status survives, hence the markers; read the log
# after the pod terminates, because an attach drops lines.

# Ask each replica directly as well as through the Service, so one sick replica
# is named instead of showing up as a coin flip -- and take the addresses from
# the EndpointSlice, filtered on ready, not from the pod list. A rollout leaves
# the previous generation running for its 30s grace period: still a listed pod,
# still unable to resolve, but already cut out of the Service. Querying it would
# fail a cluster that works.
legs=""
for ip in $(kube -n kube-system get endpointslices -l kubernetes.io/service-name=kube-dns \
-o jsonpath='{range .items[*].endpoints[?(@.conditions.ready==true)]}{.addresses[0]}{" "}{end}'); do
legs="${legs}
nslookup storage.googleapis.com ${ip} >/dev/null 2>&1 || echo \"REPLICA_BAD ${ip}\""
done

probe_pod="coredns-probe-$$"
trap 'kube delete pod "${probe_pod}" --now --ignore-not-found --wait=false >/dev/null 2>&1 || true' EXIT
kube run "${probe_pod}" --restart=Never --image=busybox:1.36 --command -- \
sh -c "if out=\$(nslookup storage.googleapis.com 2>&1); then
echo RESOLVE_OK
else
echo \"resolve failed: \$(echo \"\$out\" | tail -2 | tr '\n' ' ')\"
fi${legs}
if out=\$(wget -T10 -O/dev/null http://${REG_NAME}:5000/v2/ 2>&1); then
echo REGISTRY_OK
else
echo \"registry fetch failed: \$(echo \"\$out\" | tail -1)\"
fi" >/dev/null

if ! kube wait --for=jsonpath='{.status.phase}'=Succeeded \
"pod/${probe_pod}" --timeout=120s >/dev/null; then
echo "error: the probe pod never finished, so CoreDNS is unverified" >&2
kube describe pod "${probe_pod}" | sed 's/^/ /' >&2
exit 1
fi
probe="$(kube logs "${probe_pod}")"

# A replica that fails while the Service still answers is the case the per-replica
# legs exist to catch, so it has to fail the check too: the Service round-robins,
# so one sick replica out of two is not a warning, it is half the pods in the
# cluster unable to resolve.
bad_replicas="$(printf '%s\n' "${probe}" | sed -n 's/^REPLICA_BAD //p')"

if [[ "${probe}" != *RESOLVE_OK* || "${probe}" != *REGISTRY_OK* || -n "${bad_replicas}" ]]; then
if [[ "${probe}" != *RESOLVE_OK* ]]; then
echo "error: a pod cannot resolve an external name" >&2
echo " IPV6_DNS_UPSTREAM is '${IPV6_DNS_UPSTREAM}'; set it to a reachable resolver" >&2
elif [[ "${probe}" == *REGISTRY_OK* ]]; then
echo "error: the Service resolves, but these CoreDNS replicas do not:" >&2
while read -r ip; do
echo " ${ip}" >&2
done <<<"${bad_replicas}"
echo " resolution is a coin flip until they are replaced" >&2
else
# The Corefile holds a snapshot of the address; the registry may have moved
# off it (#1049), which is not the same fault as a registry that is down.
want="$(kube -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' \
| awk -v n="${REG_NAME}" '$2 == n {print $1}')"
have="$(docker inspect "${REG_NAME}" \
--format '{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}' 2>/dev/null || true)"
echo "error: DNS works but a pod cannot reach '${REG_NAME}'" >&2
if [[ -n "${have}" && "${want}" != "${have}" ]]; then
echo " the Corefile says [${want}] but the registry is at [${have}]" >&2
echo " re-create the cluster, or patch the hosts entry to match" >&2
else
echo " check the registry container is up and on the 'kind' network" >&2
fi
fi
echo " probe output was:" >&2
printf '%s\n' "${probe}" | sed 's/^/ /' >&2
exit 1
fi
Loading