package gitops import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" // Member is one object a GitOps manager's status lists, as recorded. type Member struct { Group, Kind, Namespace, Name string } // MemberList is what a GitOps manager's status records: for an Argo CD // Application, the objects its last comparison covered, including desired // objects not yet created, live ones awaiting pruning and ones another // Application also tracks; for Flux, the inventory of its last apply. An // entry says the manager recorded the object, not that it applied it or // alone manages it. type MemberList struct { Entries []Member // DestinationNamespace is the namespace of the destination an Argo CD // Application's list was compared against, where an entry recording no // namespace is; empty for Flux. DestinationNamespace string } // ArgoMembers reads an Application's status.resources, as of the destination // Argo CD compared it against. ok is false when the list can't be trusted, so // that any object may be a member: the Application has no destination, the // list is left out before Argo CD has reconciled it (reconciledAt unset), the // list is empty or left out after a failed comparison, or an entry names no // object. A nonempty list from a failed comparison is still what Argo CD // recorded, though it may leave objects out. func ArgoMembers(app *unstructured.Unstructured) (MemberList, bool) { return argoMembers(app, true) } // ArgoMembersReadable reports whether ArgoMembers reads app's list, making the // same checks without building the list, whose cost grows with it. func ArgoMembersReadable(app *unstructured.Unstructured) bool { _, ok := argoMembers(app, false) return ok } func argoMembers(app *unstructured.Unstructured, collect bool) (list MemberList, ok bool) { name, server, destination := ArgoComparedDestination(app) if name == "" && server == "" { return MemberList{}, false } list.DestinationNamespace = destination raw, _, err := unstructured.NestedFieldNoCopy(app.Object, "status", "resources") if err != nil { return MemberList{}, false } if raw == nil { // Argo CD omits an empty list, so once it has reconciled the // Application, no list is an empty one. if reconciled, _, _ := unstructured.NestedString(app.Object, "status", "reconciledAt"); reconciled == "" { return MemberList{}, false } if ArgoComparisonFailed(app) { return MemberList{}, false } return list, true } items, isList := raw.([]any) if !isList { return MemberList{}, false } if len(items) == 0 && ArgoComparisonFailed(app) { return MemberList{}, false } if collect && len(items) > 0 { list.Entries = make([]Member, 0, len(items)) } for _, item := range items { e, isMap := item.(map[string]any) if !isMap { return MemberList{}, false } kind, kindOK := e["kind"].(string) name, nameOK := e["name"].(string) group, groupOK := optionalString(e["group"]) namespace, namespaceOK := optionalString(e["namespace"]) if !kindOK || !nameOK || kind == "" || name == "" || !groupOK || !namespaceOK { return MemberList{}, false } if collect { list.Entries = append(list.Entries, Member{Group: group, Kind: kind, Namespace: namespace, Name: name}) } } return list, true } // FluxMembers reads a Kustomization's or HelmRelease's status.inventory. ok is // false when there is no inventory or an entry's ID doesn't parse. Flux // records no destination with its inventory, so after a spec.kubeConfig // change the inventory lists the previous destination's objects until a // reconcile applies the change. func FluxMembers(obj *unstructured.Unstructured) (MemberList, bool) { return fluxMembers(obj, true) } // FluxMembersReadable reports whether FluxMembers reads obj's list, making the // same checks without building the list, whose cost grows with it. func FluxMembersReadable(obj *unstructured.Unstructured) bool { _, ok := fluxMembers(obj, false) return ok } func fluxMembers(obj *unstructured.Unstructured, collect bool) (list MemberList, ok bool) { raw, _, err := unstructured.NestedFieldNoCopy(obj.Object, "status", "inventory") inventory, isMap := raw.(map[string]any) if err != nil || !isMap { return MemberList{}, false } items, isList := inventory["entries"].([]any) if !isList && inventory["entries"] != nil { return MemberList{}, false } if collect && len(items) > 0 { list.Entries = make([]Member, 0, len(items)) } for _, item := range items { e, isMap := item.(map[string]any) if !isMap { return MemberList{}, false } id, isString := e["id"].(string) if !isString { return MemberList{}, false } if !collect { if _, _, _, _, parsed := fluxInventoryParts(id); !parsed { return MemberList{}, false } continue } group, kind, namespace, name, parsed := ParseFluxInventoryID(id) if !parsed { return MemberList{}, false } list.Entries = append(list.Entries, Member{Group: group, Kind: kind, Namespace: namespace, Name: name}) } return list, true } // MemberNamespace is the namespace of the object an entry recording namespace // means, for a kind of known scope: none for a cluster-scoped kind, and the // destination for a namespaced one recorded without a namespace. func MemberNamespace(namespaced bool, namespace, destination string) string { switch { case !namespaced: return "" case namespace == "": return destination } return namespace } // optionalString reads a field that is either absent or a string. func optionalString(v any) (string, bool) { if v == nil { return "", true } s, ok := v.(string) return s, ok } // ArgoComparisonFailed reports whether sync status is Unknown or any condition // has type ComparisonError. Argo can also add ComparisonError beside a current // render, for example for a health assessment error. A comparison error can // leave an empty, partial or previous resource list, so the list alone does not // establish the current render or complete Application coverage. func ArgoComparisonFailed(app *unstructured.Unstructured) bool { if status, _, _ := unstructured.NestedString(app.Object, "status", "sync", "status"); status == "Unknown" { return true } conditions, _, _ := unstructured.NestedSlice(app.Object, "status", "conditions") for _, c := range conditions { if m, ok := c.(map[string]any); ok && m["type"] == "ComparisonError" { return true } } return false }