Ctrl K
ComunidadGuías…

pkg/gitops/helpers.go

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

Archivo original ↗Preguntar a KiBot
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]
}