// Package finalizers recognizes finalizer owners from vendor keys and workload // identity. It performs no cluster reads or issue-policy decisions. package finalizers import ( "path" "slices" "strings" "github.com/skyhook-io/radar/pkg/resourceid" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // Owner records controller identity and the reviewed limits of its cleanup advice. type Owner struct { // Controller is the human-readable controller name surfaced in // Issue messages ("argocd-application-controller is CrashLoopBackOff"). Controller string // Namespace is the conventional install namespace for GitOps enrichment; // the generic issues inventory searches all readable namespaces. Namespace string // SelectorKey + SelectorValue: label selector to find the // controller's pods. SelectorKey string SelectorValue string // OffClusterOfferings names managed offerings that run this controller // outside the cluster it serves. For these controllers, finding none of // their pods in the cluster does not show that they are gone. OffClusterOfferings string // ReleasesInfrastructure marks finalizers whose cleanup drains nodes or // releases cloud resources. Removing one leaves that infrastructure // behind even when the controller is down. ReleasesInfrastructure bool // AllowConditionalCleanup opts into removal advice after a complete negative // search, subject to the user confirming intentional uninstall or permanent // scale-down. Recognition alone never enables removal. Leave false for // unreviewed cleanup semantics, off-cluster controllers or infrastructure. AllowConditionalCleanup bool // Images are the repository paths of the controller's published image, // without registry host, tag or digest, so a mirror that prefixes the path // still matches. They recognize installs whose names and labels were // changed (a Helm nameOverride renames both). Images []string // matchesImageRole distinguishes components sharing a repository. A nil // predicate means every container from Images is this controller. matchesImageRole func(corev1.Container) bool requireImageMatch bool } // ResolveOwner returns a best-guess Owner for the // given finalizer key + resource. The `root` resource's apiVersion is // used to disambiguate generic Flux finalizers that span multiple // controllers (a single `finalizers.fluxcd.io` key is set by every Flux // controller; the resource kind tells us which one). // // Returns nil when the finalizer isn't recognized — caller should // degrade gracefully (skip the controller-health enrichment, keep the // rest of the Issue intact). Namespace is a convention, not a search boundary. func ResolveOwner(finalizer string, root *unstructured.Unstructured) *Owner { if strings.HasPrefix(finalizer, "karpenter.sh/") || strings.HasPrefix(finalizer, "karpenter.k8s.aws/") || strings.HasPrefix(finalizer, "karpenter.azure.com/") { return &Owner{ Controller: "karpenter", SelectorKey: "app.kubernetes.io/name", SelectorValue: "karpenter", OffClusterOfferings: "EKS Auto Mode, AKS node auto-provisioning", ReleasesInfrastructure: true, AllowConditionalCleanup: false, // public.ecr.aws/karpenter/controller (AWS provider chart), // mcr.microsoft.com/aks/karpenter/controller (Azure provider chart). Images: []string{"karpenter/controller", "aks/karpenter/controller"}, } } switch finalizer { case "apps.victoriametrics.com/finalizer": if resourceid.GroupFromAPIVersion(root.GetAPIVersion()) != "operator.victoriametrics.com" { return nil } return &Owner{ Controller: "victoria-metrics-operator", SelectorKey: "app.kubernetes.io/name", SelectorValue: "victoria-metrics-operator", Images: []string{"victoriametrics/operator"}, matchesImageRole: runsVictoriaMetricsOperator, AllowConditionalCleanup: true, } // Argo CD: a single Application controller owns all Argo-side // finalizers, including the deprecated "foreground-cascade" key // retained for installs that pre-date the rename. case "resources-finalizer.argocd.argoproj.io", "foreground-cascade.argocd.argoproj.io", "resources-finalizer.argocd.argoproj.io/foreground": return &Owner{ Controller: "argocd-application-controller", Namespace: "argocd", SelectorKey: "app.kubernetes.io/name", SelectorValue: "argocd-application-controller", OffClusterOfferings: "Amazon EKS Capabilities", AllowConditionalCleanup: false, // quay.io/argoproj/argocd also runs the API server, repo server // and the other Argo CD components. Images: []string{"argoproj/argocd"}, matchesImageRole: runsArgoApplicationController, } // Flux: legacy "finalizers.fluxcd.io" key is shared across all // Flux controllers. Disambiguate via the resource's apiVersion. case "finalizers.fluxcd.io": return resolveFluxOwnerByKind(root) // Flux: post-1.0 split finalizer keys per controller. Names are // stable since Flux v2.0+ — newer installs default to these. case "finalizers.kustomize.toolkit.fluxcd.io": return &fluxKustomizeController case "finalizers.helm.toolkit.fluxcd.io": return &fluxHelmController case "finalizers.source.toolkit.fluxcd.io": return &fluxSourceController case "finalizers.notification.toolkit.fluxcd.io": return &fluxNotificationController case "finalizers.image.toolkit.fluxcd.io": return resolveFluxImageOwner(root) } return nil } // resolveFluxOwnerByKind disambiguates the generic "finalizers.fluxcd.io" // finalizer by the resource's API group. Returns nil when the resource // is from an unfamiliar Flux group — better to skip the enrichment than // to misattribute to the wrong controller and send the operator // debugging the wrong pod. func resolveFluxOwnerByKind(root *unstructured.Unstructured) *Owner { api := strings.ToLower(root.GetAPIVersion()) switch { case strings.HasPrefix(api, "kustomize.toolkit.fluxcd.io/"): return &fluxKustomizeController case strings.HasPrefix(api, "helm.toolkit.fluxcd.io/"): return &fluxHelmController case strings.HasPrefix(api, "source.toolkit.fluxcd.io/"): return &fluxSourceController case strings.HasPrefix(api, "notification.toolkit.fluxcd.io/"): return &fluxNotificationController case strings.HasPrefix(api, "image.toolkit.fluxcd.io/"): return resolveFluxImageOwner(root) } return nil } // Two controllers serve image.toolkit.fluxcd.io: image-automation-controller // owns ImageUpdateAutomation, image-reflector-controller the rest. func resolveFluxImageOwner(root *unstructured.Unstructured) *Owner { if root.GetKind() == "ImageUpdateAutomation" { return &fluxImageAutomationController } return &fluxImageController } // Standard Flux controller catalog. Values match the official Helm chart // defaults for `flux-system`; images are ghcr.io/fluxcd/. var ( fluxKustomizeController = Owner{ Controller: "kustomize-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "kustomize-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/kustomize-controller"}, } fluxHelmController = Owner{ Controller: "helm-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "helm-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/helm-controller"}, // Other projects also publish a controller named helm-controller. requireImageMatch: true, } fluxSourceController = Owner{ Controller: "source-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "source-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/source-controller"}, } fluxNotificationController = Owner{ Controller: "notification-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "notification-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/notification-controller"}, } fluxImageController = Owner{ Controller: "image-reflector-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "image-reflector-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/image-reflector-controller"}, } fluxImageAutomationController = Owner{ Controller: "image-automation-controller", Namespace: "flux-system", SelectorKey: "app", SelectorValue: "image-automation-controller", AllowConditionalCleanup: true, Images: []string{"fluxcd/image-automation-controller"}, } ) // MatchesWorkload reports whether a workload runs this owner's controller, // recognized by the workload's name or labels, or by its pod template's // labels or container image. func (o *Owner) MatchesWorkload(workload metav1.Object, template corev1.PodTemplateSpec) bool { return (!o.requireImageMatch && o.MatchesWorkloadIdentity(workload, template)) || o.MatchesPod(template.Labels, template.Spec) } // MatchesWorkloadIdentity only checks names and labels. When an image is // required, this resemblance must not establish ownership or absence. func (o *Owner) MatchesWorkloadIdentity(workload metav1.Object, template corev1.PodTemplateSpec) bool { return workload.GetLabels()[o.SelectorKey] == o.SelectorValue || controllerNameMatches(workload.GetName(), o.Controller) || template.Labels[o.SelectorKey] == o.SelectorValue } // MatchesPod reports whether a pod (or pod template) runs this owner's // controller, recognized by the catalog label or a container image. func (o *Owner) MatchesPod(labels map[string]string, spec corev1.PodSpec) bool { if !o.requireImageMatch && labels[o.SelectorKey] == o.SelectorValue { return true } for _, container := range spec.Containers { if o.runsImage(container) { return true } } return false } func (o *Owner) runsImage(container corev1.Container) bool { repository := imageRepositoryPath(container.Image) if !slices.ContainsFunc(o.Images, func(image string) bool { return repository == image || strings.HasSuffix(repository, "/"+image) }) { return false } return o.matchesImageRole == nil || o.matchesImageRole(container) } func runsVictoriaMetricsOperator(container corev1.Container) bool { // The reloader shares the operator's repository; its container name // still identifies that role when the image is pinned by digest. image, _, _ := strings.Cut(container.Image, "@") _, tag, _ := strings.Cut(image[strings.LastIndex(image, "/")+1:], ":") return container.Name != "config-reloader" && !strings.HasPrefix(tag, "config-reloader") } func runsArgoApplicationController(container corev1.Container) bool { for _, token := range slices.Concat(container.Command, container.Args) { if path.Base(token) == "argocd-application-controller" { return true } } return false } // imageRepositoryPath returns an image reference's repository path without // its registry host, tag or digest: "ghcr.io/fluxcd/helm-controller:v1@sha256:…" // and "fluxcd/helm-controller" both give "fluxcd/helm-controller". func imageRepositoryPath(image string) string { ref, _, _ := strings.Cut(image, "@") if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { ref = ref[:i] } // Like Docker, the first segment is a registry host only when it has a dot // or a port, or is localhost; otherwise it is a Docker Hub namespace. if host, rest, ok := strings.Cut(ref, "/"); ok && (strings.ContainsAny(host, ".:") || host == "localhost") { ref = rest } return ref } // ControllerCandidate reports a workload whose name or labels match // an uncataloged finalizer's API group: the group's first DNS label plus // "-operator" or "operator-". The match names a candidate only. A short label // can match an unrelated operator (group cluster.example.io matches // rabbitmq-cluster-operator), so callers must not treat the candidate as the // finalizer's owner: not to judge it stopped or gone, not to raise severity, // not to offer removing the finalizer. Domain alone never matches. func ControllerCandidate(finalizer string, root *unstructured.Unstructured, workload metav1.Object) bool { if ResolveOwner(finalizer, root) != nil { return false } domain, _, qualified := strings.Cut(finalizer, "/") group := resourceid.GroupFromAPIVersion(root.GetAPIVersion()) if !qualified || domain != group || group == "k8s.io" || group == "kubernetes.io" || strings.HasSuffix(group, ".k8s.io") || strings.HasSuffix(group, ".kubernetes.io") { return false } token, _, _ := strings.Cut(group, ".") if token == "" || token == "operator" || token == "operators" || token == "apps" { return false } for _, value := range []string{workload.GetName(), workload.GetLabels()["app.kubernetes.io/name"], workload.GetLabels()["app.kubernetes.io/part-of"]} { if controllerNameMatches(value, token+"-operator") || controllerNameMatches(value, "operator-"+token) { return true } } return false } func controllerNameMatches(name, controller string) bool { return name == controller || strings.HasPrefix(name, controller+"-") || strings.HasSuffix(name, "-"+controller) || strings.Contains(name, "-"+controller+"-") }