Skip to content

Commit f61bc26

Browse files
kradalbyclaude
andcommitted
integration: gate operator install on cluster DNS and dump CoreDNS state
TestK8sOperator failed on the arm64 CI runner with the operator crashlooping on "lookup kubernetes.default.svc ... i/o timeout": in-cluster DNS was not servable when the operator started, but the only signal was a downstream crashloop with no view of CoreDNS itself. Wait for CoreDNS to roll out and the kube-dns Service to have a ready endpoint before installing the operator, so a DNS-dependent workload never starts ahead of name resolution. If DNS never becomes servable the test now fails at that exact boundary instead of as an opaque operator crash. Extend the diagnostics dump with CoreDNS pod status, CoreDNS logs (Corefile parse errors surface there), the kube-dns endpoints, and the Corefile plus custom config, so a DNS failure shows whether CoreDNS is down or the Service has no backend rather than leaving it to guesswork. The test passes locally; this targets the environment-specific DNS failure seen only on the arm64 runner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2bdeb9f commit f61bc26

1 file changed

Lines changed: 59 additions & 11 deletions

File tree

integration/k3sic/k3sic.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ const (
9797
// tailscaleNamespace is where the operator and its proxies are installed.
9898
tailscaleNamespace = "tailscale"
9999

100+
// kubeSystemNamespace holds CoreDNS and the rest of the k3s system addons.
101+
kubeSystemNamespace = "kube-system"
102+
100103
// dockerImportTimeout bounds the host-side build/export and the in-container
101104
// containerd import, which move multi-hundred-MB image tarballs.
102105
dockerImportTimeout = 600 * time.Second
@@ -107,6 +110,8 @@ var (
107110
errHelmNotInTarball = errors.New("helm binary not found in release tarball")
108111
errNoCACert = errors.New("no CA certificate configured; pass k3sic.WithCACert")
109112
errImageBuild = errors.New("building CA-baked image failed")
113+
114+
errNoKubeDNSEndpoints = errors.New("kube-dns Service has no ready endpoints yet")
110115
)
111116

112117
// K3sInContainer represents a k3s cluster running in a single privileged
@@ -525,16 +530,17 @@ RUN update-ca-certificates
525530
//
526531
// It installs a coredns-custom ConfigMap (a k3s-native extension point: keys
527532
// ending in .server are imported as additional server blocks) holding a hosts
528-
// block for the Headscale name, then restarts CoreDNS so the imported file is
529-
// loaded (CoreDNS's reload watches only the Corefile, not imported files).
533+
// block for the Headscale name, which CoreDNS's reload plugin picks up. It then
534+
// waits for in-cluster DNS to be servable so a DNS-dependent workload (the
535+
// operator) does not start before resolution works.
530536
func (k *K3sInContainer) ConfigureCoreDNSHost(hostname, ip string) error {
531537
// k3s deploys CoreDNS via its addon manager shortly after the node reports
532538
// Ready, so WaitForRunning can return before the deployment exists. Wait for
533539
// it before patching, otherwise the restart below races and fails with
534540
// "deployments.apps \"coredns\" not found".
535541
err := k.pool.Retry(func() error {
536542
_, stderr, err := k.Execute([]string{
537-
kubectlBin, "-n", "kube-system", "get", "deployment", "coredns",
543+
kubectlBin, "-n", kubeSystemNamespace, "get", "deployment", "coredns",
538544
})
539545
if err != nil {
540546
return fmt.Errorf("coredns deployment not present yet (stderr: %s): %w", stderr, err)
@@ -573,14 +579,48 @@ data:
573579
return fmt.Errorf("applying coredns-custom (stderr: %s): %w", stderr, err)
574580
}
575581

576-
// Deliberately NO `rollout restart deployment/coredns`: on a slower CI runner
577-
// that restart can leave CoreDNS unable to serve in-cluster DNS
578-
// (kubernetes.default.svc) for minutes, which crashes the operator before it
579-
// ever reaches Headscale. k3s's CoreDNS runs the `reload` plugin and the
580-
// ConfigMap is mounted into the pod, so the custom block is picked up
581-
// automatically (kubelet sync + reload, well within the operator's install
582-
// and registration windows) without disrupting the existing resolver.
583-
return nil
582+
// Deliberately NO `rollout restart deployment/coredns`: k3s's CoreDNS runs the
583+
// `reload` plugin and the ConfigMap is mounted into the pod, so the imported
584+
// block is picked up automatically without disrupting the running resolver.
585+
//
586+
// Gate on in-cluster DNS being servable before returning. The operator's very
587+
// first action is a kube API call resolved via kubernetes.default.svc, so if
588+
// CoreDNS is not Ready or the kube-dns Service has no endpoints, the operator
589+
// crashloops on a DNS timeout with no hint as to why. Failing here instead
590+
// pins the cause at its source (DumpDiagnostics then dumps CoreDNS state).
591+
return k.waitForClusterDNS()
592+
}
593+
594+
// waitForClusterDNS blocks until CoreDNS is rolled out and the kube-dns Service
595+
// has at least one ready endpoint — i.e. in-cluster name resolution is actually
596+
// servable, which every workload (starting with the operator) depends on.
597+
func (k *K3sInContainer) waitForClusterDNS() error {
598+
_, stderr, err := k.Execute([]string{
599+
kubectlBin, "-n", kubeSystemNamespace, "rollout", "status",
600+
"deployment/coredns", "--timeout=150s",
601+
})
602+
if err != nil {
603+
return fmt.Errorf("coredns did not become available (stderr: %s): %w", stderr, err)
604+
}
605+
606+
return k.pool.Retry(func() error {
607+
// A populated endpoint set means a CoreDNS pod is serving :53 and
608+
// kube-proxy has a backend to DNAT the kube-dns ClusterIP to; empty means
609+
// in-cluster lookups will time out no matter how long a client waits.
610+
out, stderr, err := k.Execute([]string{
611+
kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns",
612+
"-o", "jsonpath={.subsets[*].addresses[*].ip}",
613+
})
614+
if err != nil {
615+
return fmt.Errorf("reading kube-dns endpoints (stderr: %s): %w", stderr, err)
616+
}
617+
618+
if strings.TrimSpace(out) == "" {
619+
return errNoKubeDNSEndpoints
620+
}
621+
622+
return nil
623+
})
584624
}
585625

586626
// DumpDiagnostics logs cluster state useful for debugging a failed operator
@@ -595,6 +635,14 @@ func (k *K3sInContainer) DumpDiagnostics() {
595635
// A crashlooping operator's fatal error is in the previous container.
596636
{kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--previous", "--tail=200"},
597637
{kubectlBin, "-n", tailscaleNamespace, "get", "statefulsets,pods", "-o", "wide"},
638+
// In-cluster DNS: the operator's first dependency. A "lookup
639+
// kubernetes.default.svc ... i/o timeout" crash means CoreDNS is not
640+
// serving, so capture its pod state, logs (Corefile parse errors land
641+
// here), the Service endpoints, and the Corefile + custom config.
642+
{kubectlBin, "-n", kubeSystemNamespace, "get", "pods", "-l", "k8s-app=kube-dns", "-o", "wide"},
643+
{kubectlBin, "-n", kubeSystemNamespace, "logs", "-l", "k8s-app=kube-dns", "--tail=100"},
644+
{kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", "-o", "wide"},
645+
{kubectlBin, "-n", kubeSystemNamespace, "get", "configmap", "coredns", "coredns-custom", "-o", "yaml"},
598646
} {
599647
out, stderr, err := k.Execute(c)
600648
label := strings.Join(c, " ")

0 commit comments

Comments
 (0)