package cluster import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "time" ) type nodeReadyList struct { Items []nodeReadyItem `json:"items"` } type nodeReadyItem struct { Metadata struct { Name string `json:"name"` Annotations map[string]string `json:"annotations"` Labels map[string]string `json:"labels"` } `json:"metadata"` Spec struct { Unschedulable bool `json:"unschedulable"` Taints []struct { Key string `json:"key"` TimeAdded time.Time `json:"timeAdded"` } `json:"taints"` } `json:"spec"` Status struct { Conditions []struct { Type string `json:"type"` Status string `json:"status"` } `json:"conditions"` } `json:"status"` } type readyNodeCandidate struct { Name string Unschedulable bool } type podDeleteList struct { Items []struct { Metadata struct { Namespace string `json:"namespace"` Name string `json:"name"` DeletionTimestamp *time.Time `json:"deletionTimestamp"` } `json:"metadata"` Spec struct { NodeName string `json:"nodeName"` } `json:"spec"` } `json:"items"` } // RunPostStartAutoHeal runs one orchestration or CLI step. // Signature: (o *Orchestrator) RunPostStartAutoHeal(ctx context.Context) error. // Why: gives the long-running daemon a narrow, testable repair entrypoint for // post-start drift without rerunning the full startup flow. func (o *Orchestrator) RunPostStartAutoHeal(ctx context.Context) error { return o.postStartAutoHeal(ctx) } // postStartAutoHeal runs one orchestration or CLI step. // Signature: (o *Orchestrator) postStartAutoHeal(ctx context.Context) error. // Why: centralizes bounded post-start repair actions so recurring outage // patterns only trigger the specific remediation they need. func (o *Orchestrator) postStartAutoHeal(ctx context.Context) error { if o.runner.DryRun { return nil } errs := []string{} requestReconcile := false if err := o.ensureRequiredNodeLabels(ctx); err != nil { errs = append(errs, fmt.Sprintf("required node labels: %v", err)) } longhornRepairs, err := o.reconcileLonghornKubernetesReadiness(ctx) if err != nil { errs = append(errs, fmt.Sprintf("longhorn/kubernetes readiness drift: %v", err)) } else if longhornRepairs > 0 { requestReconcile = true o.log.Printf("post-start auto-heal repaired %d Longhorn/Kubernetes readiness drift item(s)", longhornRepairs) } releasedCordons, err := o.enforceRecoveryCordonLeases(ctx) if err != nil { errs = append(errs, fmt.Sprintf("recovery cordon lease check: %v", err)) } else if releasedCordons > 0 { requestReconcile = true } vaultRecovered, err := o.autoRecoverSealedVault(ctx) if err != nil { errs = append(errs, fmt.Sprintf("vault auto-recovery: %v", err)) } else if vaultRecovered { requestReconcile = true if err := o.rerunVaultK8sAuthConfigJob(ctx); err != nil { errs = append(errs, fmt.Sprintf("vault k8s auth config rerun: %v", err)) } } cleaned, err := o.cleanupTerminatingPodsOnUnavailableNodes(ctx) if err != nil { errs = append(errs, fmt.Sprintf("dead-node terminating pod cleanup: %v", err)) } else if cleaned > 0 { requestReconcile = true } serviceRepairs, err := o.healUnreadyConfiguredServiceBackends(ctx) if err != nil { errs = append(errs, fmt.Sprintf("configured service backend repair: %v", err)) } else if len(serviceRepairs) > 0 { requestReconcile = true sort.Strings(serviceRepairs) o.log.Printf("post-start auto-heal repaired configured service backend(s): %s", joinLimited(serviceRepairs, 8)) } imagePullCredentialRepairs, err := o.healImagePullCredentialSync(ctx) if err != nil { errs = append(errs, fmt.Sprintf("image-pull credential sync repair: %v", err)) } else if len(imagePullCredentialRepairs) > 0 { requestReconcile = true sort.Strings(imagePullCredentialRepairs) o.log.Printf("post-start auto-heal refreshed image-pull credential sync deployment(s): %s", joinLimited(imagePullCredentialRepairs, 8)) o.noteStartupAutoHeal(fmt.Sprintf("refreshed image-pull credential sync deployment(s): %s", joinLimited(imagePullCredentialRepairs, 8))) } repairedProxies, err := o.repairBrokenKubeletProxies(ctx) if err != nil { errs = append(errs, fmt.Sprintf("kubelet proxy auto-repair: %v", err)) } else if repairedProxies > 0 { o.log.Printf("post-start auto-heal repaired %d broken kubelet proxy node(s)", repairedProxies) } if requestReconcile { o.bestEffort("request flux reconcile after post-start auto-heal", func() error { return o.requestFluxReconcile(ctx) }) } if len(errs) > 0 { return errors.New(strings.Join(errs, "; ")) } return nil } // autoRecoverSealedVault runs one orchestration or CLI step. // Signature: (o *Orchestrator) autoRecoverSealedVault(ctx context.Context) (bool, error). // Why: lets the daemon repair a later Vault reseal without waiting for a new // bootstrap run. func (o *Orchestrator) autoRecoverSealedVault(ctx context.Context) (bool, error) { if o.runner.DryRun { return false, nil } phase, err := o.kubectl(ctx, 15*time.Second, "-n", "vault", "get", "pod", "vault-0", "-o", "jsonpath={.status.phase}") if err != nil { if isNotFoundErr(err) { return false, nil } return false, fmt.Errorf("vault pod phase check failed: %w", err) } if strings.TrimSpace(phase) != "Running" { return false, nil } sealed, err := o.vaultSealed(ctx) if err != nil { return false, err } if !sealed { return false, nil } o.log.Printf("warning: detected sealed Vault after startup; attempting post-start auto-recovery") if err := o.ensureVaultUnsealed(ctx); err != nil { return false, err } return true, nil } // rerunVaultK8sAuthConfigJob runs one orchestration or CLI step. // Signature: (o *Orchestrator) rerunVaultK8sAuthConfigJob(ctx context.Context) error. // Why: post-unseal Vault recovery needs the auth-config job retriggered so // downstream secret consumers stop carrying stale failures from the sealed window. func (o *Orchestrator) rerunVaultK8sAuthConfigJob(ctx context.Context) error { if o.runner.DryRun { return nil } jobName := fmt.Sprintf("vault-k8s-auth-config-autoheal-%d", time.Now().Unix()) if _, err := o.kubectl( ctx, 25*time.Second, "-n", "vault", "create", "job", "--from=cronjob/vault-k8s-auth-config", jobName, ); err != nil { return fmt.Errorf("create job %s: %w", jobName, err) } o.log.Printf("triggered vault k8s auth config job %s after vault recovery", jobName) return nil } // repairBrokenKubeletProxies runs one orchestration or CLI step. // Signature: (o *Orchestrator) repairBrokenKubeletProxies(ctx context.Context) (int, error). // Why: a Ready node can still have a dead kubelet tunnel, which breaks Jenkins // exec/websocket agents until the k3s agent is restarted on that exact node. func (o *Orchestrator) repairBrokenKubeletProxies(ctx context.Context) (int, error) { if o.runner.DryRun { return 0, nil } nodes, err := o.readyNodeCandidates(ctx) if err != nil { return 0, err } repaired := 0 errs := []string{} for _, node := range nodes { healthy, checkErr := o.kubeletProxyHealthy(ctx, node.Name) if healthy { continue } if checkErr != nil && !isRepairableKubeletProxyErr(checkErr) { errs = append(errs, fmt.Sprintf("%s proxy health check: %v", node.Name, checkErr)) continue } if !o.sshManaged(node.Name) { errs = append(errs, fmt.Sprintf("%s proxy broken but node is not SSH-managed", node.Name)) continue } if err := o.recoverManagedNodeRuntime(ctx, node.Name, node.Unschedulable, "broken kubelet proxy before k3s-agent restart"); err != nil { errs = append(errs, fmt.Sprintf("%s %v", node.Name, err)) continue } repaired++ } if len(errs) > 0 { return repaired, errors.New(strings.Join(errs, "; ")) } return repaired, nil } // readyNodeCandidates runs one orchestration or CLI step. // Signature: (o *Orchestrator) readyNodeCandidates(ctx context.Context) ([]readyNodeCandidate, error). // Why: kubelet proxy repair should only touch nodes Kubernetes says are Ready, // preserving existing cordons when a node was intentionally kept out of service. func (o *Orchestrator) readyNodeCandidates(ctx context.Context) ([]readyNodeCandidate, error) { out, err := o.kubectl(ctx, 20*time.Second, "get", "nodes", "-o", "json") if err != nil { return nil, fmt.Errorf("query nodes: %w", err) } var nodes nodeReadyList if err := json.Unmarshal([]byte(out), &nodes); err != nil { return nil, fmt.Errorf("decode nodes: %w", err) } readyNodes := []readyNodeCandidate{} for _, item := range nodes.Items { ready := "" for _, cond := range item.Status.Conditions { if strings.EqualFold(strings.TrimSpace(cond.Type), "Ready") { ready = strings.TrimSpace(cond.Status) break } } if ready == "True" && item.Metadata.Name != "" { readyNodes = append(readyNodes, readyNodeCandidate{ Name: item.Metadata.Name, Unschedulable: item.Spec.Unschedulable, }) } } return readyNodes, nil } // kubeletProxyHealthy runs one orchestration or CLI step. // Signature: (o *Orchestrator) kubeletProxyHealthy(ctx context.Context, node string) (bool, error). // Why: the apiserver node proxy is the path Jenkins uses for pod exec; checking // it catches Ready-but-unusable nodes before agents start failing websockets. func (o *Orchestrator) kubeletProxyHealthy(ctx context.Context, node string) (bool, error) { var lastErr error for attempt := 0; attempt < 2; attempt++ { healthy, err := o.kubeletProxyHealthyOnce(ctx, node) if err == nil || !isTransientKubeletProxyCheckErr(err) { return healthy, err } lastErr = err o.log.Printf("warning: transient kubelet proxy health check failure for %s: %v; retrying", node, err) } return false, lastErr } // kubeletProxyHealthyOnce runs one orchestration or CLI step. // Signature: (o *Orchestrator) kubeletProxyHealthyOnce(ctx context.Context, node string) (bool, error). // Why: transient apiserver/kubectl cancellations should be retried by the caller // without losing the exact kubelet proxy error when the node is really broken. func (o *Orchestrator) kubeletProxyHealthyOnce(ctx context.Context, node string) (bool, error) { out, err := o.kubectl(ctx, 25*time.Second, "get", "--raw", fmt.Sprintf("/api/v1/nodes/%s/proxy/healthz", node), "--request-timeout=20s") if err != nil { if strings.TrimSpace(out) != "" { return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(out)) } return false, err } return true, nil } // isTransientKubeletProxyCheckErr runs one orchestration or CLI step. // Signature: isTransientKubeletProxyCheckErr(err error) bool. // Why: a killed or timed-out kubectl health probe is not proof the node proxy is // broken; retry before reporting or repairing a Ready node. func isTransientKubeletProxyCheckErr(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) transient := []string{ "signal: killed", "context deadline exceeded", "deadline exceeded", "i/o timeout", "request canceled", "client.timeout", "timeout awaiting", } for _, needle := range transient { if strings.Contains(msg, needle) { return true } } return false } // isRepairableKubeletProxyErr runs one orchestration or CLI step. // Signature: isRepairableKubeletProxyErr(err error) bool. // Why: keep this repair narrow so Ananke restarts k3s-agent for the known // kubelet-tunnel failure, not for every transient kubectl problem. func isRepairableKubeletProxyErr(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) repairable := []string{ "502", "bad gateway", "failed to find session", "error trying to reach service", "10250", } for _, needle := range repairable { if strings.Contains(msg, needle) { return true } } return false } // requestFluxReconcile runs one orchestration or CLI step. // Signature: (o *Orchestrator) requestFluxReconcile(ctx context.Context) error. // Why: post-start repairs need a lightweight way to refresh GitOps health // without reusing the broader startup flux-resume flow. func (o *Orchestrator) requestFluxReconcile(ctx context.Context) error { if o.runner.DryRun { return nil } now := time.Now().UTC().Format(time.RFC3339) if _, err := o.kubectl( ctx, 25*time.Second, "-n", "flux-system", "annotate", "gitrepository", "flux-system", "reconcile.fluxcd.io/requestedAt="+now, "--overwrite", ); err != nil { return fmt.Errorf("annotate flux source reconcile: %w", err) } if _, err := o.kubectl( ctx, 25*time.Second, "-n", "flux-system", "annotate", "kustomizations.kustomize.toolkit.fluxcd.io", "--all", "reconcile.fluxcd.io/requestedAt="+now, "--overwrite", ); err != nil { return fmt.Errorf("annotate flux kustomizations reconcile: %w", err) } if _, err := o.kubectl( ctx, 25*time.Second, "annotate", "--all-namespaces", "helmreleases.helm.toolkit.fluxcd.io", "--all", "reconcile.fluxcd.io/requestedAt="+now, "--overwrite", ); err != nil { o.log.Printf("warning: annotate helmreleases for post-start reconcile failed: %v", err) } if o.runOverride == nil && o.runner.CommandExists("flux") { if _, err := o.run(ctx, 75*time.Second, "flux", "reconcile", "source", "git", "flux-system", "-n", "flux-system", "--timeout=60s"); err != nil { o.log.Printf("warning: flux source reconcile command failed during post-start auto-heal: %v", err) } } return nil }