pkg/k8score/apply_warnings.go
Rama main · réplica de lectura del repositorio original.
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)
}