@@ -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.
530536func (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