package gitops import ( "crypto/sha256" "fmt" "slices" "strings" "github.com/skyhook-io/radar/pkg/resourceid" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // StringValue returns v as a string, or "" if v is not a string. // Convenience helper for unstructured map[string]any access where typed // assertions would otherwise litter the call sites. func StringValue(v any) string { if s, ok := v.(string); ok { return s } return "" } // IsInClusterDestination reports whether an Argo Application deploys to the // cluster Radar is connected to — spec.destination is the local API server or // the "in-cluster" name — as opposed to a remote hub-spoke destination. Radar's // per-user SARs authorize against the local cluster only, so the desired/live // manifests of a remote destination cannot be authorized here. An Application // with no destination is invalid (Argo reports InvalidSpecError and deploys // nothing), so it is not local either: its status.resources may be left over // from a destination it no longer names. Fail closed: a nil Application is // treated as not-in-cluster. func IsInClusterDestination(app *unstructured.Unstructured) bool { if app == nil { return false } return IsInClusterArgoDestination(argoDestination(app)) } // IsInClusterArgoDestination reports whether an Argo CD destination, given by // name or server, is Argo CD's in-cluster destination: the name in-cluster or // the in-cluster API server address. Any other destination may still be this // cluster, registered under another name or its external address. func IsInClusterArgoDestination(name, server string) bool { return strings.EqualFold(name, "in-cluster") || isLocalAPIServer(server) } // ArgoComparedDestination returns the destination an Application's // status.resources describes: status.sync.comparedTo.destination, which Argo // CD records with each comparison, or spec.destination before one does. Until // Argo CD compares against a changed destination, the list still names the // old destination's objects. func ArgoComparedDestination(app *unstructured.Unstructured) (name, server, namespace string) { if app == nil { return "", "", "" } name, server, namespace = argoDestinationAt(app, "status", "sync", "comparedTo", "destination") if name == "" && server == "" { name, server, namespace = argoDestinationAt(app, "spec", "destination") } return name, server, namespace } func argoDestinationAt(app *unstructured.Unstructured, path ...string) (name, server, namespace string) { raw, _, _ := unstructured.NestedFieldNoCopy(app.Object, path...) destination, _ := raw.(map[string]any) name, _ = destination["name"].(string) server, _ = destination["server"].(string) namespace, _ = destination["namespace"].(string) return strings.TrimSpace(name), strings.TrimSpace(server), namespace } // HasArgoDestination reports whether an Argo Application names a destination // cluster at all, by server or by name. func HasArgoDestination(app *unstructured.Unstructured) bool { if app == nil { return false } name, server := argoDestination(app) return name != "" || server != "" } func argoDestination(app *unstructured.Unstructured) (name, server string) { name, _, _ = unstructured.NestedString(app.Object, "spec", "destination", "name") server, _, _ = unstructured.NestedString(app.Object, "spec", "destination", "server") return strings.TrimSpace(name), strings.TrimSpace(server) } // ArgoSyncPrunesEverything reports whether the last reported status.resources // list is nonempty and every entry has requiresPruning set. It does not account // for resource Prune=false/confirm options or a stale list, and does not prove // that a pruning sync will delete every resource. Sync status is independent: // IgnoreExtraneous resources can leave such an Application Synced. func ArgoSyncPrunesEverything(app *unstructured.Unstructured) bool { if app == nil { return false } resources, _, _ := unstructured.NestedSlice(app.Object, "status", "resources") if len(resources) == 0 { return false } for _, r := range resources { m, ok := r.(map[string]any) if !ok { return false } if prune, _ := m["requiresPruning"].(bool); !prune { return false } } return true } // ArgoEmptyRenderGuardHolds applies the test Argo's auto-sync makes before it // refuses with "auto-sync will wipe out all resources": auto-sync is on with // prune and without allowEmpty, the app is OutOfSync, and a sync would prune // everything. That condition is not re-evaluated while a sync window blocks // auto-sync, so it can outlive the empty render or a policy change; callers // confirm it against this instead of trusting its presence. func ArgoEmptyRenderGuardHolds(app *unstructured.Unstructured) bool { if app == nil { return false } automated, found, _ := unstructured.NestedMap(app.Object, "spec", "syncPolicy", "automated") if !found { return false } if enabled, ok, _ := unstructured.NestedBool(automated, "enabled"); ok && !enabled { return false } if prune, _, _ := unstructured.NestedBool(automated, "prune"); !prune { return false } if allowEmpty, _, _ := unstructured.NestedBool(automated, "allowEmpty"); allowEmpty { return false } sync, _, _ := unstructured.NestedString(app.Object, "status", "sync", "status") return strings.EqualFold(sync, "OutOfSync") && ArgoSyncPrunesEverything(app) } // ArgoCreateNamespaceEnabled reports whether an Application's // spec.syncPolicy.syncOptions turns on Argo's CreateNamespace behavior. Matches // Argo's own check (exact string equality on "CreateNamespace=true", as // SyncOptions.HasOption does): a differently-cased or mistyped value is not the // enabled option, so Radar must not treat it as set and mask the misconfiguration. // A nil Application or missing syncOptions is not set. func ArgoCreateNamespaceEnabled(app *unstructured.Unstructured) bool { if app == nil { return false } opts, _, _ := unstructured.NestedStringSlice(app.Object, "spec", "syncPolicy", "syncOptions") return slices.Contains(opts, "CreateNamespace=true") } // ArgoConditionMessages returns the messages of an Argo Application's // status.conditions entries of one type, in controller order. func ArgoConditionMessages(app *unstructured.Unstructured, condType string) []string { if app == nil { return nil } conditions, _, _ := unstructured.NestedSlice(app.Object, "status", "conditions") var out []string for _, c := range conditions { m, ok := c.(map[string]any) if !ok || StringValue(m["type"]) != condType { continue } out = append(out, StringValue(m["message"])) } return out } // ArgoCLIName is how the argocd CLI addresses an Application: namespace/name, // which resolves both control-plane and any-namespace Applications. func ArgoCLIName(app *unstructured.Unstructured) string { if app == nil || app.GetName() == "" { return "" } if app.GetNamespace() == "" { return app.GetName() } return app.GetNamespace() + "/" + app.GetName() } // ArgoAppliesElsewhere reports whether an Application's list, whether Radar // can read it or not, describes a destination Argo CD names and that isn't // this cluster. An Application naming no destination isn't known to apply // anywhere. func ArgoAppliesElsewhere(app *unstructured.Unstructured) bool { name, server, _ := ArgoComparedDestination(app) return (name != "" || server != "") && !IsInClusterArgoDestination(name, server) } // FluxTargetsLocalCluster reports whether a Flux Kustomization or HelmRelease // applies to the cluster it runs in. spec.kubeConfig points it at a cluster // Radar can't confirm is this one, whose objects its inventory then names. // Fail closed: a nil object is not local. func FluxTargetsLocalCluster(obj *unstructured.Unstructured) bool { if obj == nil { return false } _, remote, _ := unstructured.NestedMap(obj.Object, "spec", "kubeConfig") return !remote } // isLocalAPIServer matches the in-cluster Kubernetes API server URL Argo records // for a same-cluster destination (kubernetes.default.svc, with or without a // scheme, port, or trailing dot). Any other host isn't confirmed to be this // cluster. func isLocalAPIServer(server string) bool { if server == "" { return false } h := server if i := strings.Index(h, "://"); i >= 0 { h = h[i+3:] } if i := strings.IndexAny(h, "/?#"); i >= 0 { h = h[:i] } if i := strings.LastIndex(h, ":"); i >= 0 { h = h[:i] } h = strings.TrimSuffix(strings.ToLower(h), ".") // Accept every in-cluster form: the short names and the service FQDN with any // cluster domain (kubernetes.default.svc.cluster.local, or a custom domain). return h == "kubernetes" || h == "kubernetes.default" || h == "kubernetes.default.svc" || strings.HasPrefix(h, "kubernetes.default.svc.") } // ParseFluxInventoryID parses Flux's namespace_name_group_kind inventory key // from the right so resource names containing underscores remain intact. // Flux writes a colon in an RBAC object's name as a double underscore // (system:metrics-server is system__metrics-server) and reads every "__" back // as a colon; no other kind's name can hold either. func ParseFluxInventoryID(id string) (group, kind, namespace, name string, ok bool) { group, kind, namespace, name, ok = fluxInventoryParts(id) if !ok { return "", "", "", "", false } return group, kind, namespace, strings.ReplaceAll(name, "__", ":"), true } // fluxInventoryParts slices an inventory ID into its parts without copying, // the name still encoded. namespace_name_group_kind: a name is the only part // that can hold an underscore. Slicing rather than splitting reads a whole // inventory without an allocation per entry; only decoding a colon allocates. func fluxInventoryParts(id string) (group, kind, namespace, name string, ok bool) { last := strings.LastIndexByte(id, '_') if last < 0 { return "", "", "", "", false } beforeGroup := strings.LastIndexByte(id[:last], '_') first := strings.IndexByte(id, '_') if beforeGroup <= first { return "", "", "", "", false } kind = id[last+1:] group = resourceid.NormalizeGroup(id[beforeGroup+1 : last]) namespace = id[:first] name = id[first+1 : beforeGroup] if kind == "" || name == "" { return "", "", "", "", false } return group, kind, namespace, name, true } // GitOps controller families, as reported on the wire. const ( ToolArgoCD = "argocd" ToolFluxCD = "fluxcd" ) // ToolFor returns the controller family that owns objects of this API group // and kind, or "" when Radar has no GitOps view for that identity. The kind alone is // not identity: SIG Apps (app.k8s.io) defines an Application too. Argo CD // shares argoproj.io with Argo Rollouts, Workflows and Events, so it is // matched by kind as well. Flux uses the supported operation registry. func ToolFor(group, kind string) string { if group == "argoproj.io" { switch kind { case "Application", "ApplicationSet", "AppProject": return ToolArgoCD } return "" } if entry, err := ResolveFluxKind(kind); err == nil && entry.GVR.Group == group { return ToolFluxCD } return "" } // ToolOf is ToolFor for a live object, read from its apiVersion and kind. func ToolOf(obj *unstructured.Unstructured) string { if obj == nil { return "" } return ToolFor(resourceid.GroupFromAPIVersion(obj.GetAPIVersion()), obj.GetKind()) } // FluxHelmTarget names the Helm release a Flux HelmRelease's spec implies: the // release name helm-controller defaults to, the namespace it deploys into, and // the namespace Helm stores it in. status.history and status.storageNamespace // record the actual values once a release has been installed. func FluxHelmTarget(obj *unstructured.Unstructured) (releaseName, targetNamespace, storageNamespace string) { spec, _ := obj.Object["spec"].(map[string]any) targetNamespace, _ = spec["targetNamespace"].(string) if targetNamespace == "" { targetNamespace = obj.GetNamespace() } releaseName, _ = spec["releaseName"].(string) if releaseName == "" { if configuredTarget, _ := spec["targetNamespace"].(string); configuredTarget != "" { releaseName = configuredTarget + "-" + obj.GetName() } else { releaseName = obj.GetName() } releaseName = shortenFluxReleaseName(releaseName) } storageNamespace, _ = spec["storageNamespace"].(string) if storageNamespace == "" { storageNamespace = obj.GetNamespace() } return releaseName, targetNamespace, storageNamespace } func shortenFluxReleaseName(name string) string { if len(name) <= 53 { return name } sum := fmt.Sprintf("%x", sha256.Sum256([]byte(name))) return name[:40] + "-" + sum[:12] }