Ctrl K
ComunidadGuías…

pkg/k8score/apply_warnings.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"fmt"
	"log"
	"sort"
	"strings"

	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/client-go/dynamic"
)

// detectExternalManager inspects labels and annotations for evidence that
// Helm, Argo CD, Flux, or another controller manages the resource. Returns
// the manager display name and a best-effort instance identifier; both empty
// when nothing was detected. Detection is deliberately conservative — radar and
// kubectl are not considered "external".
func detectExternalManager(obj *unstructured.Unstructured) (string, string) {
	if c := externalManagers(obj); len(c) > 0 {
		return c[0].name, c[0].instance
	}
	return "", ""
}

// externalManager is one manager an object's metadata names. claim: the
// metadata is a claim the GitOps manager answer weighs (gitops.Claims), so a
// caller reporting that answer needn't repeat it.
type externalManager struct {
	name, instance string
	claim          bool
}

// externalManagers returns every manager the metadata names, most specific
// first.
func externalManagers(obj *unstructured.Unstructured) []externalManager {
	if obj == nil {
		return nil
	}
	labels := obj.GetLabels()
	annotations := obj.GetAnnotations()
	var out []externalManager

	if v := labels["argocd.argoproj.io/instance"]; v != "" {
		out = append(out, externalManager{"Argo CD", v, true})
	}
	// A tracking ID copied from another object names that object's manager.
	if v := annotations["argocd.argoproj.io/tracking-id"]; v != "" && !copiedTrackingID(obj, v) {
		out = append(out, externalManager{"Argo CD", v, true})
	}

	if v := labels["kustomize.toolkit.fluxcd.io/name"]; v != "" {
		ns := labels["kustomize.toolkit.fluxcd.io/namespace"]
		instance := v
		if ns != "" {
			instance = ns + "/" + v
		}
		out = append(out, externalManager{"Flux (Kustomization)", instance, ns != ""})
	}
	if v := labels["helm.toolkit.fluxcd.io/name"]; v != "" {
		ns := labels["helm.toolkit.fluxcd.io/namespace"]
		instance := v
		if ns != "" {
			instance = ns + "/" + v
		}
		out = append(out, externalManager{"Flux (HelmRelease)", instance, ns != ""})
	}

	helmRelease := annotations["meta.helm.sh/release-name"]
	helmManaged := strings.EqualFold(labels["app.kubernetes.io/managed-by"], "Helm")
	if helmRelease != "" || helmManaged {
		instance := helmRelease
		if instance == "" {
			instance = labels["app.kubernetes.io/instance"]
		}
		out = append(out, externalManager{"Helm", instance, helmRelease != ""})
	}

	if mb := labels["app.kubernetes.io/managed-by"]; mb != "" && !strings.EqualFold(mb, "Helm") {
		low := strings.ToLower(mb)
		if low != "kubectl" && low != "radar" {
			out = append(out, externalManager{mb, labels["app.kubernetes.io/instance"], false})
		}
	}
	return out
}

// formatExternalManagerWarning words the warning as what the metadata says, not
// as a verdict: tracking metadata can be copied from another object or name a
// manager that no longer lists this one, and native Helm reconciles nothing
// between upgrades.
func formatExternalManagerWarning(manager, instance string) string {
	if manager == "" {
		return ""
	}
	frag := ""
	if instance != "" {
		frag = fmt.Sprintf(" (instance: %s)", instance)
	}
	if manager == "Helm" {
		return fmt.Sprintf("Its metadata says Helm installed it%s. The next helm upgrade or rollback of that release may overwrite direct edits; prefer changing the chart values.", frag)
	}
	return fmt.Sprintf("Its tracking metadata names %s%s. If that manager still tracks it, its next sync may revert direct edits; prefer changing the source of truth (Application spec / Git / operator CR).", manager, frag)
}

// podSpecPath returns the field path that contains the PodSpec for the given
// workload kind, or nil for kinds without an embedded PodSpec. Used by both
// the field-removal verification and the ConfigMap/Secret consumer lookup.
func podSpecPath(kind string) []string {
	switch kind {
	case "Deployment", "ReplicaSet", "StatefulSet", "DaemonSet", "Job":
		return []string{"spec", "template", "spec"}
	case "CronJob":
		return []string{"spec", "jobTemplate", "spec", "template", "spec"}
	case "Pod":
		return []string{"spec"}
	}
	return nil
}

// checkFieldRemoval verifies that fields the agent intended to remove are
// actually gone after apply. Server-side apply does not remove fields owned by
// other managers when those fields are merely omitted from the submission —
// the agent must explicitly set them to null. We check a focused set of
// high-impact paths under the PodSpec (probes, resources, scheduling).
//
// Returns one warning per field that survived the apply despite being omitted.
func checkFieldRemoval(submitted, pre, post *unstructured.Unstructured) []string {
	if submitted == nil || pre == nil || post == nil {
		return nil
	}
	specPath := podSpecPath(pre.GetKind())
	if len(specPath) == 0 {
		return nil
	}

	otherManagers := nonRadarManagers(post)

	var warnings []string

	for _, field := range []string{"nodeSelector", "affinity", "tolerations", "nodeName", "topologySpreadConstraints", "dnsPolicy", "dnsConfig", "hostAliases"} {
		path := append(append([]string{}, specPath...), field)
		if !hasPath(pre, path) || hasPath(submitted, path) {
			continue
		}
		if !hasPath(post, path) {
			continue
		}
		warnings = append(warnings, formatFieldRemovalWarning(strings.Join(path, "."), otherManagers))
	}

	warnings = append(warnings, checkContainerListRemoval("containers", specPath, submitted, pre, post, otherManagers)...)
	warnings = append(warnings, checkContainerListRemoval("initContainers", specPath, submitted, pre, post, otherManagers)...)

	return warnings
}

func checkContainerListRemoval(listField string, specPath []string, submitted, pre, post *unstructured.Unstructured, otherManagers []string) []string {
	preCs, _, _ := unstructured.NestedSlice(pre.Object, append(specPath, listField)...)
	submCs, _, _ := unstructured.NestedSlice(submitted.Object, append(specPath, listField)...)
	postCs, _, _ := unstructured.NestedSlice(post.Object, append(specPath, listField)...)
	submByName := containersByName(submCs)
	postByName := containersByName(postCs)

	var warnings []string
	for _, raw := range preCs {
		preC, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		name, _ := preC["name"].(string)
		if name == "" {
			continue
		}
		submC, hasSubm := submByName[name]
		postC, hasPost := postByName[name]
		if !hasPost {
			continue
		}
		if !hasSubm {
			fieldRef := fmt.Sprintf("%s.%s[name=%s]", strings.Join(specPath, "."), listField, name)
			warnings = append(warnings, formatFieldRemovalWarning(fieldRef, otherManagers))
			continue
		}
		for _, field := range []string{"readinessProbe", "livenessProbe", "startupProbe", "resources", "env", "envFrom"} {
			if _, in := preC[field]; !in {
				continue
			}
			if _, in := submC[field]; in {
				continue
			}
			if _, in := postC[field]; !in {
				continue
			}
			fieldRef := fmt.Sprintf("%s.%s[name=%s].%s", strings.Join(specPath, "."), listField, name, field)
			warnings = append(warnings, formatFieldRemovalWarning(fieldRef, otherManagers))
		}
		for _, fieldRef := range retainedHostPortRefs(specPath, listField, name, preC, submC, postC) {
			warnings = append(warnings, formatFieldRemovalWarning(fieldRef, otherManagers))
		}
	}

	return warnings
}

func retainedHostPortRefs(specPath []string, listField, containerName string, preC, submC, postC map[string]any) []string {
	prePorts := hostPortByPort(preC)
	if len(prePorts) == 0 {
		return nil
	}
	submPorts := hostPortByPort(submC)
	postPorts := hostPortByPort(postC)
	var refs []string
	for key := range prePorts {
		if submPorts[key] {
			continue
		}
		if !postPorts[key] {
			continue
		}
		refs = append(refs, fmt.Sprintf("%s.%s[name=%s].ports[containerPort=%s].hostPort", strings.Join(specPath, "."), listField, containerName, key))
	}
	sort.Strings(refs)
	return refs
}

func hostPortByPort(container map[string]any) map[string]bool {
	rawPorts, ok := container["ports"].([]any)
	if !ok {
		return nil
	}
	out := map[string]bool{}
	for _, raw := range rawPorts {
		p, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		if _, ok := p["hostPort"]; !ok {
			continue
		}
		containerPort, ok := scalarString(p["containerPort"])
		if !ok || containerPort == "" {
			continue
		}
		out[containerPort] = true
	}
	return out
}

func scalarString(v any) (string, bool) {
	switch t := v.(type) {
	case int:
		return fmt.Sprint(t), true
	case int32:
		return fmt.Sprint(t), true
	case int64:
		return fmt.Sprint(t), true
	case float64:
		return fmt.Sprintf("%.0f", t), true
	case string:
		return t, true
	default:
		return "", false
	}
}

func formatFieldRemovalWarning(field string, otherManagers []string) string {
	tail := "Server-side apply does not claim fields by omission. To actually remove the field, set its value to null in your YAML."
	if len(otherManagers) > 0 {
		tail = fmt.Sprintf("It is still owned by another field manager (%s). %s", strings.Join(otherManagers, ", "), tail)
	}
	return fmt.Sprintf("Field `%s` was omitted from your submission but is still present after apply. %s", field, tail)
}

func hasPath(obj *unstructured.Unstructured, path []string) bool {
	if obj == nil {
		return false
	}
	_, found, _ := unstructured.NestedFieldNoCopy(obj.Object, path...)
	return found
}

func containersByName(cs []any) map[string]map[string]any {
	out := make(map[string]map[string]any, len(cs))
	for _, c := range cs {
		m, ok := c.(map[string]any)
		if !ok {
			continue
		}
		name, _ := m["name"].(string)
		if name == "" {
			continue
		}
		out[name] = m
	}
	return out
}

// nonRadarManagers returns the names of field managers on the object other
// than radar itself, deduplicated and sorted for stable output. Used to tell
// the agent who else owns fields on the resource.
func nonRadarManagers(obj *unstructured.Unstructured) []string {
	if obj == nil {
		return nil
	}
	mfRaw, found, _ := unstructured.NestedSlice(obj.Object, "metadata", "managedFields")
	if !found {
		return nil
	}
	seen := map[string]struct{}{}
	for _, entry := range mfRaw {
		em, ok := entry.(map[string]any)
		if !ok {
			continue
		}
		name, _ := em["manager"].(string)
		if name == "" || strings.EqualFold(name, "radar") {
			continue
		}
		seen[name] = struct{}{}
	}
	if len(seen) == 0 {
		return nil
	}
	out := make([]string, 0, len(seen))
	for k := range seen {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// findConfigMapSecretConsumers returns the qualified names (Kind/Name) of
// workloads in the same namespace whose PodSpec references the given
// ConfigMap or Secret via env, envFrom, volumes, or projected volumes.
//
// partial is true when a workload kind could not be listed (e.g. RBAC denied
// the user the apply path impersonates, or the kind isn't in discovery). An
// empty result with partial=true means "scan incomplete", NOT "verified no
// consumers" — the caller must not present it as the latter.
func findConfigMapSecretConsumers(ctx context.Context, dynClient dynamic.Interface, discovery *ResourceDiscovery, namespace, refKind, refName string) (consumers []string, partial bool) {
	if dynClient == nil || discovery == nil || namespace == "" || refName == "" {
		return nil, false
	}
	if refKind != "ConfigMap" && refKind != "Secret" {
		return nil, false
	}

	for _, kind := range []string{"Deployment", "StatefulSet", "DaemonSet", "CronJob", "Job"} {
		gvr, ok := discovery.GetGVR(kind)
		if !ok {
			partial = true
			continue
		}
		list, err := dynClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
		if err != nil {
			log.Printf("[k8s] apply_resource: consumer scan list %s in %s failed: %v", kind, namespace, err)
			partial = true
			continue
		}
		for i := range list.Items {
			item := &list.Items[i]
			if podSpecReferences(item, refKind, refName) {
				consumers = append(consumers, fmt.Sprintf("%s/%s", kind, item.GetName()))
			}
		}
	}
	sort.Strings(consumers)
	return consumers, partial
}

func podSpecReferences(obj *unstructured.Unstructured, refKind, refName string) bool {
	specPath := podSpecPath(obj.GetKind())
	if len(specPath) == 0 {
		return false
	}

	for _, group := range []string{"containers", "initContainers"} {
		cs, _, _ := unstructured.NestedSlice(obj.Object, append(specPath, group)...)
		for _, raw := range cs {
			c, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			if containerReferences(c, refKind, refName) {
				return true
			}
		}
	}

	volumes, _, _ := unstructured.NestedSlice(obj.Object, append(specPath, "volumes")...)
	for _, raw := range volumes {
		v, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		if volumeReferences(v, refKind, refName) {
			return true
		}
	}
	return false
}

func containerReferences(c map[string]any, refKind, refName string) bool {
	envFromKey := "configMapRef"
	envKey := "configMapKeyRef"
	if refKind == "Secret" {
		envFromKey = "secretRef"
		envKey = "secretKeyRef"
	}

	if envFrom, ok := c["envFrom"].([]any); ok {
		for _, raw := range envFrom {
			ef, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			if ref, ok := ef[envFromKey].(map[string]any); ok {
				if n, _ := ref["name"].(string); n == refName {
					return true
				}
			}
		}
	}

	if env, ok := c["env"].([]any); ok {
		for _, raw := range env {
			ev, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			vf, ok := ev["valueFrom"].(map[string]any)
			if !ok {
				continue
			}
			if ref, ok := vf[envKey].(map[string]any); ok {
				if n, _ := ref["name"].(string); n == refName {
					return true
				}
			}
		}
	}
	return false
}

func volumeReferences(v map[string]any, refKind, refName string) bool {
	if refKind == "ConfigMap" {
		if src, ok := v["configMap"].(map[string]any); ok {
			if n, _ := src["name"].(string); n == refName {
				return true
			}
		}
	}
	if refKind == "Secret" {
		if src, ok := v["secret"].(map[string]any); ok {
			if n, _ := src["secretName"].(string); n == refName {
				return true
			}
		}
	}

	if proj, ok := v["projected"].(map[string]any); ok {
		if sources, ok := proj["sources"].([]any); ok {
			for _, raw := range sources {
				sm, ok := raw.(map[string]any)
				if !ok {
					continue
				}
				if refKind == "ConfigMap" {
					if src, ok := sm["configMap"].(map[string]any); ok {
						if n, _ := src["name"].(string); n == refName {
							return true
						}
					}
				}
				if refKind == "Secret" {
					if src, ok := sm["secret"].(map[string]any); ok {
						if n, _ := src["name"].(string); n == refName {
							return true
						}
					}
				}
			}
		}
	}
	return false
}

func formatConsumerWarning(refKind, refName string, consumers []string, partial bool) string {
	if len(consumers) == 0 {
		if partial {
			return fmt.Sprintf("Could not fully enumerate workloads referencing `%s` (some list calls failed — likely RBAC). If you changed its contents, manually verify which workloads mount it and restart any that don't watch for changes.", refName)
		}
		return ""
	}
	preview := consumers
	tail := ""
	if len(preview) > 5 {
		preview = preview[:5]
		tail = fmt.Sprintf(" (+%d more)", len(consumers)-5)
	}
	incomplete := ""
	if partial {
		incomplete = " (consumer list may be incomplete — some list calls failed)"
	}
	return fmt.Sprintf("%s changes typically reach pod filesystems within ~60s, but most applications must detect and reload the new contents. %d workload(s) reference `%s`: %s%s%s. If those apps don't watch for changes, restart them (e.g., `manage_workload restart`) so they pick up the edit.", refKind, len(consumers), refName, strings.Join(preview, ", "), tail, incomplete)
}